up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 08:51:10 +02:00
parent ea970ead2a
commit c34fb7256d
126 changed files with 18553 additions and 693 deletions

View File

@@ -645,7 +645,7 @@ internal static class NodePackageCollector
packageSha256: packageSha256,
isYarnPnp: yarnPnpPresent);
AttachEntrypoints(package, root, relativeDirectory);
AttachEntrypoints(context, package, root, relativeDirectory);
return package;
}

View File

@@ -4,15 +4,21 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
internal static class RubyObservationBuilder
{
private const string SchemaVersion = "stellaops.ruby.observation@1";
public static RubyObservationDocument Build(
IReadOnlyList<RubyPackage> packages,
RubyLockData lockData,
RubyRuntimeGraph runtimeGraph,
RubyCapabilities capabilities,
RubyBundlerConfig bundlerConfig,
string? bundledWith)
{
ArgumentNullException.ThrowIfNull(packages);
ArgumentNullException.ThrowIfNull(lockData);
ArgumentNullException.ThrowIfNull(runtimeGraph);
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentNullException.ThrowIfNull(bundlerConfig);
var packageItems = packages
.OrderBy(static package => package.Name, StringComparer.OrdinalIgnoreCase)
@@ -20,6 +26,9 @@ internal static class RubyObservationBuilder
.Select(CreatePackage)
.ToImmutableArray();
var entrypoints = BuildEntrypoints(runtimeGraph, packages);
var dependencyItems = BuildDependencyEdges(lockData);
var runtimeItems = packages
.Select(package => CreateRuntimeEdge(package, runtimeGraph))
.Where(static edge => edge is not null)
@@ -27,6 +36,8 @@ internal static class RubyObservationBuilder
.OrderBy(static edge => edge.Package, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var environment = BuildEnvironment(lockData, bundlerConfig, capabilities, bundledWith);
var capabilitySummary = new RubyObservationCapabilitySummary(
capabilities.UsesExec,
capabilities.UsesNetwork,
@@ -39,7 +50,134 @@ internal static class RubyObservationBuilder
? null
: bundledWith.Trim();
return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary, normalizedBundler);
return new RubyObservationDocument(
SchemaVersion,
packageItems,
entrypoints,
dependencyItems,
runtimeItems,
environment,
capabilitySummary,
normalizedBundler);
}
private static ImmutableArray<RubyObservationEntrypoint> BuildEntrypoints(
RubyRuntimeGraph runtimeGraph,
IReadOnlyList<RubyPackage> packages)
{
var entrypoints = new List<RubyObservationEntrypoint>();
var packageNames = packages.Select(static p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var entryFile in runtimeGraph.GetEntrypointFiles())
{
var type = InferEntrypointType(entryFile);
var requiredGems = runtimeGraph.GetRequiredGems(entryFile)
.Where(gem => packageNames.Contains(gem))
.OrderBy(static gem => gem, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
entrypoints.Add(new RubyObservationEntrypoint(entryFile, type, requiredGems));
}
return entrypoints
.OrderBy(static e => e.Path, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static string InferEntrypointType(string path)
{
var fileName = Path.GetFileName(path);
if (fileName.Equals("config.ru", StringComparison.OrdinalIgnoreCase))
{
return "rack";
}
if (fileName.Equals("Rakefile", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".rake", StringComparison.OrdinalIgnoreCase))
{
return "rake";
}
if (path.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("\\bin\\", StringComparison.OrdinalIgnoreCase))
{
return "executable";
}
if (fileName.Equals("Gemfile", StringComparison.OrdinalIgnoreCase))
{
return "gemfile";
}
return "script";
}
private static RubyObservationEnvironment BuildEnvironment(
RubyLockData lockData,
RubyBundlerConfig bundlerConfig,
RubyCapabilities capabilities,
string? bundledWith)
{
var bundlePaths = bundlerConfig.BundlePaths
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var gemfiles = bundlerConfig.Gemfiles
.Select(static p => p.Replace('\\', '/'))
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var lockFiles = lockData.Entries
.Select(static e => e.LockFileRelativePath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var frameworks = DetectFrameworks(capabilities)
.OrderBy(static f => f, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new RubyObservationEnvironment(
RubyVersion: null,
BundlerVersion: string.IsNullOrWhiteSpace(bundledWith) ? null : bundledWith.Trim(),
bundlePaths,
gemfiles,
lockFiles,
frameworks);
}
private static IEnumerable<string> DetectFrameworks(RubyCapabilities capabilities)
{
if (capabilities.HasJobSchedulers)
{
foreach (var scheduler in capabilities.JobSchedulers)
{
yield return scheduler;
}
}
}
private static ImmutableArray<RubyObservationDependencyEdge> BuildDependencyEdges(RubyLockData lockData)
{
var edges = new List<RubyObservationDependencyEdge>();
foreach (var entry in lockData.Entries)
{
var fromPackage = $"pkg:gem/{entry.Name}@{entry.Version}";
foreach (var dep in entry.Dependencies)
{
edges.Add(new RubyObservationDependencyEdge(
fromPackage,
dep.DependencyName,
dep.VersionConstraint));
}
}
return edges
.OrderBy(static edge => edge.FromPackage, StringComparer.OrdinalIgnoreCase)
.ThenBy(static edge => edge.ToPackage, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static RubyObservationPackage CreatePackage(RubyPackage package)

View File

@@ -2,9 +2,17 @@ using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
/// <summary>
/// AOC-compliant observation document for Ruby project analysis.
/// Contains components, entrypoints, dependency edges, and environment profiles.
/// </summary>
internal sealed record RubyObservationDocument(
string Schema,
ImmutableArray<RubyObservationPackage> Packages,
ImmutableArray<RubyObservationEntrypoint> Entrypoints,
ImmutableArray<RubyObservationDependencyEdge> DependencyEdges,
ImmutableArray<RubyObservationRuntimeEdge> RuntimeEdges,
RubyObservationEnvironment Environment,
RubyObservationCapabilitySummary Capabilities,
string? BundledWith);
@@ -18,6 +26,19 @@ internal sealed record RubyObservationPackage(
string? Artifact,
ImmutableArray<string> Groups);
/// <summary>
/// Entrypoint detected in the Ruby project (Rakefile, bin scripts, config.ru, etc).
/// </summary>
internal sealed record RubyObservationEntrypoint(
string Path,
string Type,
ImmutableArray<string> RequiredGems);
internal sealed record RubyObservationDependencyEdge(
string FromPackage,
string ToPackage,
string? VersionConstraint);
internal sealed record RubyObservationRuntimeEdge(
string Package,
bool UsedByEntrypoint,
@@ -25,6 +46,17 @@ internal sealed record RubyObservationRuntimeEdge(
ImmutableArray<string> Entrypoints,
ImmutableArray<string> Reasons);
/// <summary>
/// Environment profile with Ruby version, Bundler settings, and paths.
/// </summary>
internal sealed record RubyObservationEnvironment(
string? RubyVersion,
string? BundlerVersion,
ImmutableArray<string> BundlePaths,
ImmutableArray<string> Gemfiles,
ImmutableArray<string> LockFiles,
ImmutableArray<string> Frameworks);
internal sealed record RubyObservationCapabilitySummary(
bool UsesExec,
bool UsesNetwork,

View File

@@ -17,8 +17,12 @@ internal static class RubyObservationSerializer
{
writer.WriteStartObject();
writer.WriteString("$schema", document.Schema);
WritePackages(writer, document.Packages);
WriteEntrypoints(writer, document.Entrypoints);
WriteDependencyEdges(writer, document.DependencyEdges);
WriteRuntimeEdges(writer, document.RuntimeEdges);
WriteEnvironment(writer, document.Environment);
WriteCapabilities(writer, document.Capabilities);
WriteBundledWith(writer, document.BundledWith);
@@ -72,6 +76,46 @@ internal static class RubyObservationSerializer
writer.WriteEndArray();
}
private static void WriteEntrypoints(Utf8JsonWriter writer, ImmutableArray<RubyObservationEntrypoint> entrypoints)
{
writer.WritePropertyName("entrypoints");
writer.WriteStartArray();
foreach (var entrypoint in entrypoints)
{
writer.WriteStartObject();
writer.WriteString("path", entrypoint.Path);
writer.WriteString("type", entrypoint.Type);
if (entrypoint.RequiredGems.Length > 0)
{
WriteStringArray(writer, "requiredGems", entrypoint.RequiredGems);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
private static void WriteDependencyEdges(Utf8JsonWriter writer, ImmutableArray<RubyObservationDependencyEdge> dependencyEdges)
{
writer.WritePropertyName("dependencyEdges");
writer.WriteStartArray();
foreach (var edge in dependencyEdges)
{
writer.WriteStartObject();
writer.WriteString("from", edge.FromPackage);
writer.WriteString("to", edge.ToPackage);
if (!string.IsNullOrWhiteSpace(edge.VersionConstraint))
{
writer.WriteString("constraint", edge.VersionConstraint);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
private static void WriteRuntimeEdges(Utf8JsonWriter writer, ImmutableArray<RubyObservationRuntimeEdge> runtimeEdges)
{
writer.WritePropertyName("runtimeEdges");
@@ -90,6 +134,44 @@ internal static class RubyObservationSerializer
writer.WriteEndArray();
}
private static void WriteEnvironment(Utf8JsonWriter writer, RubyObservationEnvironment environment)
{
writer.WritePropertyName("environment");
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(environment.RubyVersion))
{
writer.WriteString("rubyVersion", environment.RubyVersion);
}
if (!string.IsNullOrWhiteSpace(environment.BundlerVersion))
{
writer.WriteString("bundlerVersion", environment.BundlerVersion);
}
if (environment.BundlePaths.Length > 0)
{
WriteStringArray(writer, "bundlePaths", environment.BundlePaths);
}
if (environment.Gemfiles.Length > 0)
{
WriteStringArray(writer, "gemfiles", environment.Gemfiles);
}
if (environment.LockFiles.Length > 0)
{
WriteStringArray(writer, "lockfiles", environment.LockFiles);
}
if (environment.Frameworks.Length > 0)
{
WriteStringArray(writer, "frameworks", environment.Frameworks);
}
writer.WriteEndObject();
}
private static void WriteCapabilities(Utf8JsonWriter writer, RubyObservationCapabilitySummary summary)
{
writer.WritePropertyName("capabilities");

View File

@@ -21,6 +21,8 @@ internal static class RubyLockCollector
"coverage"
};
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
private const int MaxDiscoveryDepth = 3;
private static readonly IReadOnlyCollection<string> DefaultGroups = new[] { "default" };
@@ -61,6 +63,7 @@ internal static class RubyLockCollector
spec.Source,
spec.Platform,
groups,
spec.Dependencies,
relativeLockPath));
}
}
@@ -186,6 +189,20 @@ internal static class RubyLockCollector
TryAdd(candidate);
}
// Also discover lock files in container layers
foreach (var layerRoot in EnumerateLayerRoots(rootPath))
{
foreach (var name in LockFileNames)
{
TryAdd(Path.Combine(layerRoot, name));
}
foreach (var candidate in EnumerateLockFiles(layerRoot))
{
TryAdd(candidate);
}
}
return discovered
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -294,4 +311,53 @@ internal static class RubyLockCollector
Path.GetFullPath(manifestDirectory),
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
/// <summary>
/// Enumerates OCI container layer roots for Ruby project discovery.
/// Looks for layers/, .layers/, layer/ directories containing layer subdirectories.
/// </summary>
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
{
foreach (var candidate in LayerRootCandidates)
{
var root = Path.Combine(workspaceRoot, candidate);
if (!Directory.Exists(root))
{
continue;
}
IEnumerable<string>? directories = null;
try
{
directories = Directory.EnumerateDirectories(root);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (directories is null)
{
continue;
}
foreach (var layerDirectory in directories)
{
// Check for fs/ subdirectory (extracted layer filesystem)
var fsDirectory = Path.Combine(layerDirectory, "fs");
if (Directory.Exists(fsDirectory))
{
yield return fsDirectory;
}
else
{
yield return layerDirectory;
}
}
}
}
}

View File

@@ -6,4 +6,5 @@ internal sealed record RubyLockEntry(
string Source,
string? Platform,
IReadOnlyCollection<string> Groups,
IReadOnlyList<RubyDependencyEdge> Dependencies,
string LockFileRelativePath);

View File

@@ -15,6 +15,7 @@ internal static class RubyLockParser
}
private static readonly Regex SpecLineRegex = new(@"^\s{4}(?<name>[^\s]+)\s\((?<version>[^)]+)\)", RegexOptions.Compiled);
private static readonly Regex DependencyLineRegex = new(@"^\s{6}(?<name>[^\s]+)(?:\s\((?<constraint>[^)]+)\))?", RegexOptions.Compiled);
public static RubyLockParserResult Parse(string contents)
{
@@ -23,13 +24,14 @@ internal static class RubyLockParser
return new RubyLockParserResult(Array.Empty<RubyLockParserEntry>(), string.Empty);
}
var entries = new List<RubyLockParserEntry>();
var specBuilders = new List<SpecBuilder>();
var section = RubyLockSection.None;
var bundledWith = string.Empty;
var inSpecs = false;
string? currentRemote = null;
string? currentRevision = null;
string? currentPath = null;
SpecBuilder? currentSpec = null;
using var reader = new StringReader(contents);
string? line;
@@ -47,6 +49,7 @@ internal static class RubyLockParser
currentRemote = null;
currentRevision = null;
currentPath = null;
currentSpec = null;
if (section == RubyLockSection.Gem)
{
@@ -76,13 +79,15 @@ internal static class RubyLockParser
ref currentRemote,
ref currentRevision,
ref currentPath,
entries);
ref currentSpec,
specBuilders);
break;
default:
break;
}
}
var entries = specBuilders.Select(static builder => builder.Build()).ToArray();
return new RubyLockParserResult(entries, bundledWith);
}
@@ -93,7 +98,8 @@ internal static class RubyLockParser
ref string? currentRemote,
ref string? currentRevision,
ref string? currentPath,
List<RubyLockParserEntry> entries)
ref SpecBuilder? currentSpec,
List<SpecBuilder> specBuilders)
{
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
{
@@ -130,15 +136,33 @@ internal static class RubyLockParser
return;
}
var match = SpecLineRegex.Match(line);
if (!match.Success)
// Check for nested dependency line (6 spaces indent)
if (line.Length > 6 && line.StartsWith(" ") && !char.IsWhiteSpace(line[6]))
{
if (currentSpec is not null)
{
var depMatch = DependencyLineRegex.Match(line);
if (depMatch.Success)
{
var depName = depMatch.Groups["name"].Value.Trim();
var constraint = depMatch.Groups["constraint"].Success
? depMatch.Groups["constraint"].Value.Trim()
: null;
if (!string.IsNullOrEmpty(depName))
{
currentSpec.Dependencies.Add(new RubyDependencyEdge(depName, constraint));
}
}
}
return;
}
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
// Top-level spec line (4 spaces indent)
var match = SpecLineRegex.Match(line);
if (!match.Success)
{
// Nested dependency entry under a spec.
return;
}
@@ -151,7 +175,30 @@ internal static class RubyLockParser
var (version, platform) = ParseVersion(match.Groups["version"].Value);
var source = ResolveSource(section, currentRemote, currentRevision, currentPath);
entries.Add(new RubyLockParserEntry(name, version, source, platform));
currentSpec = new SpecBuilder(name, version, source, platform);
specBuilders.Add(currentSpec);
}
private sealed class SpecBuilder
{
public SpecBuilder(string name, string version, string source, string? platform)
{
Name = name;
Version = version;
Source = source;
Platform = platform;
}
public string Name { get; }
public string Version { get; }
public string Source { get; }
public string? Platform { get; }
public List<RubyDependencyEdge> Dependencies { get; } = new();
public RubyLockParserEntry Build()
{
return new RubyLockParserEntry(Name, Version, Source, Platform, Dependencies.ToArray());
}
}
private static RubyLockSection ParseSection(string value)
@@ -213,6 +260,15 @@ internal static class RubyLockParser
}
}
internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform);
internal sealed record RubyLockParserEntry(
string Name,
string Version,
string Source,
string? Platform,
IReadOnlyList<RubyDependencyEdge> Dependencies);
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockParserEntry> Entries, string BundledWith);
internal sealed record RubyDependencyEdge(string DependencyName, string? VersionConstraint);
internal sealed record RubyLockParserResult(
IReadOnlyList<RubyLockParserEntry> Entries,
string BundledWith);

View File

@@ -374,6 +374,38 @@ internal sealed class RubyRuntimeGraph
return false;
}
/// <summary>
/// Gets all entrypoint files across all gem usages.
/// </summary>
public IEnumerable<string> GetEntrypointFiles()
{
return _usages.Values
.Where(static usage => usage.HasEntrypoints)
.SelectMany(static usage => usage.Entrypoints)
.Distinct(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Gets the gems required by a specific file.
/// </summary>
public IEnumerable<string> GetRequiredGems(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
yield break;
}
var normalizedPath = filePath.Replace('\\', '/');
foreach (var (gemName, usage) in _usages)
{
if (usage.ReferencingFiles.Any(f => f.Equals(normalizedPath, StringComparison.OrdinalIgnoreCase)))
{
yield return gemName;
}
}
}
private static IEnumerable<string> EnumerateCandidateKeys(string name)
{
if (string.IsNullOrWhiteSpace(name))

View File

@@ -8,6 +8,8 @@ internal static class RubyVendorArtifactCollector
Path.Combine(".bundle", "cache")
};
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
private static readonly string[] DirectoryBlockList =
{
".git",
@@ -65,6 +67,14 @@ internal static class RubyVendorArtifactCollector
TryAdd(Path.Combine(bundlePath, "cache"));
}
// Also check container layers for vendor directories and gems
foreach (var layerRoot in EnumerateLayerRoots(context.RootPath))
{
TryAdd(Path.Combine(layerRoot, "vendor", "cache"));
TryAdd(Path.Combine(layerRoot, "vendor", "bundle"));
TryAdd(Path.Combine(layerRoot, ".bundle", "cache"));
}
var artifacts = new List<RubyVendorArtifact>();
foreach (var root in roots.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase))
{
@@ -261,6 +271,55 @@ internal static class RubyVendorArtifactCollector
return path + Path.DirectorySeparatorChar;
}
/// <summary>
/// Enumerates OCI container layer roots for Ruby vendor artifact discovery.
/// Looks for layers/, .layers/, layer/ directories containing layer subdirectories.
/// </summary>
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
{
foreach (var candidate in LayerRootCandidates)
{
var root = Path.Combine(workspaceRoot, candidate);
if (!Directory.Exists(root))
{
continue;
}
IEnumerable<string>? directories = null;
try
{
directories = Directory.EnumerateDirectories(root);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (directories is null)
{
continue;
}
foreach (var layerDirectory in directories)
{
// Check for fs/ subdirectory (extracted layer filesystem)
var fsDirectory = Path.Combine(layerDirectory, "fs");
if (Directory.Exists(fsDirectory))
{
yield return fsDirectory;
}
else
{
yield return layerDirectory;
}
}
}
}
}
internal sealed record RubyVendorArtifact(

View File

@@ -0,0 +1,307 @@
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
/// <summary>
/// Provides the Ruby runtime shim that captures runtime events via TracePoint into NDJSON.
/// This shim is written to disk alongside the analyzer to be invoked by the worker/CLI.
/// </summary>
internal static class RubyRuntimeShim
{
private const string ShimFileName = "trace-shim.rb";
public static string FileName => ShimFileName;
public static async Task<string> WriteAsync(string directory, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, ShimFileName);
await File.WriteAllTextAsync(path, ShimSource, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
return path;
}
// NOTE: This shim is intentionally self-contained, offline, and deterministic.
// Uses Ruby's TracePoint API for runtime introspection with append-only evidence collection.
private const string ShimSource = """
# frozen_string_literal: true
# Ruby runtime trace shim (offline, deterministic)
# Captures require, load, and method call events via TracePoint.
# Emits NDJSON to ruby-runtime.ndjson for evidence collection.
require 'json'
require 'digest/sha2'
require 'time'
module StellaTracer
EVENTS = []
MUTEX = Mutex.new
CWD = Dir.pwd.tr('\\', '/')
ENTRYPOINT_ENV = 'STELLA_RUBY_ENTRYPOINT'
OUTPUT_FILE = 'ruby-runtime.ndjson'
# Patterns for redacting sensitive data
REDACT_PATTERNS = [
/password/i,
/secret/i,
/api[_-]?key/i,
/auth[_-]?token/i,
/bearer/i,
/credential/i,
/private[_-]?key/i
].freeze
# Gems known to have security-relevant capabilities
CAPABILITY_GEMS = {
exec: %w[open3 open4 shellwords pty childprocess posix-spawn].freeze,
net: %w[net/http net/https net/ftp socket httparty faraday rest-client typhoeus patron curb excon httpclient].freeze,
serialize: %w[yaml json marshal oj msgpack ox multi_json yajl].freeze,
scheduler: %w[rufus-scheduler clockwork sidekiq resque delayed_job good_job que karafka sucker_punch shoryuken].freeze,
ffi: %w[ffi fiddle].freeze
}.freeze
class << self
def now_iso
Time.now.utc.iso8601(3)
end
def sha256_hex(value)
Digest::SHA256.hexdigest(value.to_s)
end
def relative_path(path)
candidate = path.to_s.tr('\\', '/')
return candidate if candidate.empty?
# Strip file:// prefix if present
candidate = candidate.sub(%r{^file://}, '')
# Make absolute if relative
unless candidate.start_with?('/') || candidate.match?(/^[A-Za-z]:/)
candidate = File.join(CWD, candidate)
end
# Make relative to CWD
if candidate.start_with?(CWD)
offset = CWD.end_with?('/') ? CWD.length : CWD.length + 1
candidate = candidate[offset..]
end
candidate&.sub(%r{^\./}, '')&.sub(%r{^/+}, '') || '.'
end
def normalize_feature(path)
rel = relative_path(path)
{
normalized: rel,
path_sha256: sha256_hex(rel)
}
end
def redact_value(value)
str = value.to_s
REDACT_PATTERNS.any? { |pat| str.match?(pat) } ? '[REDACTED]' : str
end
def detect_capability(feature_name)
CAPABILITY_GEMS.each do |cap, gems|
return cap if gems.any? { |g| feature_name.include?(g) }
end
nil
end
def add_event(evt)
MUTEX.synchronize { EVENTS << evt }
end
def record_require(feature, path, success)
normalized = normalize_feature(path || feature)
capability = detect_capability(feature)
event = {
type: 'ruby.require',
ts: now_iso,
feature: feature,
module: normalized,
success: success
}
event[:capability] = capability if capability
add_event(event)
end
def record_load(path, wrap)
normalized = normalize_feature(path)
add_event({
type: 'ruby.load',
ts: now_iso,
module: normalized,
wrap: wrap
})
end
def record_method_call(klass, method_id, location)
return if location.nil?
path = relative_path(location.path)
add_event({
type: 'ruby.method.call',
ts: now_iso,
class: redact_value(klass.to_s),
method: method_id.to_s,
location: {
path: path,
line: location.lineno,
path_sha256: sha256_hex(path)
}
})
end
def record_error(message, location = nil)
event = {
type: 'ruby.runtime.error',
ts: now_iso,
message: redact_value(message)
}
if location
event[:location] = {
path: relative_path(location),
path_sha256: sha256_hex(relative_path(location))
}
end
add_event(event)
end
def flush
MUTEX.synchronize do
sorted = EVENTS.sort_by { |e| [e[:ts].to_s, e[:type].to_s] }
File.open(OUTPUT_FILE, 'w') do |f|
sorted.each { |e| f.puts(JSON.generate(e)) }
end
end
rescue => e
warn "stella-tracer: failed to write trace: #{e.message}"
end
def enabled_capabilities
caps = Set.new
$LOADED_FEATURES.each do |feature|
cap = detect_capability(feature)
caps << cap if cap
end
caps.to_a.sort
end
end
end
# Track loaded features at startup
$stella_initial_features = $LOADED_FEATURES.dup
# Hook require
module Kernel
alias_method :stella_original_require, :require
alias_method :stella_original_require_relative, :require_relative
alias_method :stella_original_load, :load
def require(feature)
success = false
result = stella_original_require(feature)
success = result
result
rescue LoadError => e
StellaTracer.record_error("LoadError: #{e.message}", feature)
raise
ensure
path = $LOADED_FEATURES.find { |f| f.include?(feature.to_s.gsub(/\.rb$/, '')) }
StellaTracer.record_require(feature.to_s, path, success)
end
def require_relative(feature)
# Resolve the path relative to the caller
caller_path = caller_locations(1, 1)&.first&.path || __FILE__
dir = File.dirname(caller_path)
absolute = File.expand_path(feature, dir)
require(absolute)
end
def load(path, wrap = false)
result = stella_original_load(path, wrap)
StellaTracer.record_load(path.to_s, wrap)
result
rescue => e
StellaTracer.record_error("LoadError: #{e.message}", path)
raise
end
end
# TracePoint for method calls (optional, configurable)
$stella_method_trace = nil
def stella_enable_method_trace(filter_classes: nil)
$stella_method_trace = TracePoint.new(:call) do |tp|
next if tp.path&.start_with?('<internal')
next if tp.defined_class.to_s.start_with?('StellaTracer')
if filter_classes.nil? || filter_classes.any? { |c| tp.defined_class.to_s.include?(c) }
StellaTracer.record_method_call(tp.defined_class, tp.method_id, tp)
end
end
$stella_method_trace.enable
end
def stella_disable_method_trace
$stella_method_trace&.disable
$stella_method_trace = nil
end
# Ensure flush on exit
at_exit do
# Record final capability snapshot
caps = StellaTracer.enabled_capabilities
StellaTracer.add_event({
type: 'ruby.runtime.end',
ts: StellaTracer.now_iso,
loaded_features_count: $LOADED_FEATURES.length - $stella_initial_features.length,
capabilities: caps
})
StellaTracer.flush
end
# Main execution
entrypoint = ENV[StellaTracer::ENTRYPOINT_ENV]
if entrypoint.nil? || entrypoint.empty?
StellaTracer.record_error('STELLA_RUBY_ENTRYPOINT not set')
exit 1
end
unless File.exist?(entrypoint)
StellaTracer.record_error("Entrypoint not found: #{entrypoint}")
exit 1
end
StellaTracer.add_event({
type: 'ruby.runtime.start',
ts: StellaTracer.now_iso,
module: StellaTracer.normalize_feature(entrypoint),
reason: 'shim-start',
ruby_version: RUBY_VERSION,
ruby_platform: RUBY_PLATFORM
})
# Optionally enable method tracing for specific classes
trace_classes = ENV['STELLA_RUBY_TRACE_CLASSES']&.split(',')&.map(&:strip)
stella_enable_method_trace(filter_classes: trace_classes) if trace_classes && !trace_classes.empty?
begin
load entrypoint
rescue => e
StellaTracer.record_error("#{e.class}: #{e.message}", entrypoint)
raise
end
""";
}

View File

@@ -0,0 +1,268 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
/// <summary>
/// Reads and parses Ruby runtime trace NDJSON output.
/// </summary>
internal static class RubyRuntimeTraceReader
{
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
/// <summary>
/// Reads runtime trace events from an NDJSON file.
/// </summary>
public static async Task<RubyRuntimeTrace> ReadAsync(string path, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
{
return RubyRuntimeTrace.Empty;
}
var events = new List<RubyRuntimeEvent>();
var requires = new List<RubyRequireEvent>();
var loads = new List<RubyLoadEvent>();
var methodCalls = new List<RubyMethodCallEvent>();
var errors = new List<RubyRuntimeErrorEvent>();
string? rubyVersion = null;
string? rubyPlatform = null;
string[]? finalCapabilities = null;
int? loadedFeaturesCount = null;
await foreach (var line in File.ReadLinesAsync(path, cancellationToken).ConfigureAwait(false))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeProp))
{
continue;
}
var type = typeProp.GetString();
var timestamp = root.TryGetProperty("ts", out var tsProp) ? tsProp.GetString() : null;
switch (type)
{
case "ruby.runtime.start":
rubyVersion = root.TryGetProperty("ruby_version", out var vProp) ? vProp.GetString() : null;
rubyPlatform = root.TryGetProperty("ruby_platform", out var pProp) ? pProp.GetString() : null;
break;
case "ruby.runtime.end":
loadedFeaturesCount = root.TryGetProperty("loaded_features_count", out var fcProp)
? fcProp.GetInt32()
: null;
if (root.TryGetProperty("capabilities", out var capsProp) && capsProp.ValueKind == JsonValueKind.Array)
{
finalCapabilities = capsProp.EnumerateArray()
.Select(e => e.GetString())
.Where(s => s is not null)
.Cast<string>()
.ToArray();
}
break;
case "ruby.require":
var reqFeature = root.TryGetProperty("feature", out var fProp) ? fProp.GetString() : null;
var reqSuccess = root.TryGetProperty("success", out var sProp) && sProp.GetBoolean();
var reqCapability = root.TryGetProperty("capability", out var cProp) ? cProp.GetString() : null;
var reqModule = ParseModuleRef(root);
if (reqFeature is not null)
{
requires.Add(new RubyRequireEvent(
timestamp,
reqFeature,
reqModule?.Normalized,
reqModule?.PathSha256,
reqSuccess,
reqCapability));
}
break;
case "ruby.load":
var loadModule = ParseModuleRef(root);
var wrap = root.TryGetProperty("wrap", out var wProp) && wProp.GetBoolean();
if (loadModule is not null)
{
loads.Add(new RubyLoadEvent(
timestamp,
loadModule.Normalized,
loadModule.PathSha256,
wrap));
}
break;
case "ruby.method.call":
var className = root.TryGetProperty("class", out var clsProp) ? clsProp.GetString() : null;
var methodName = root.TryGetProperty("method", out var mtdProp) ? mtdProp.GetString() : null;
var location = ParseLocation(root);
if (className is not null && methodName is not null)
{
methodCalls.Add(new RubyMethodCallEvent(
timestamp,
className,
methodName,
location?.Path,
location?.Line));
}
break;
case "ruby.runtime.error":
var errorMsg = root.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null;
var errorLocation = root.TryGetProperty("location", out var locProp) ? ParseLocationDirect(locProp) : null;
if (errorMsg is not null)
{
errors.Add(new RubyRuntimeErrorEvent(timestamp, errorMsg, errorLocation?.Path));
}
break;
}
events.Add(new RubyRuntimeEvent(type ?? "unknown", timestamp));
}
catch (JsonException)
{
// Skip malformed lines
}
}
return new RubyRuntimeTrace(
events.ToArray(),
requires.ToArray(),
loads.ToArray(),
methodCalls.ToArray(),
errors.ToArray(),
rubyVersion,
rubyPlatform,
finalCapabilities ?? [],
loadedFeaturesCount);
}
private static ModuleRef? ParseModuleRef(JsonElement root)
{
if (!root.TryGetProperty("module", out var moduleProp) || moduleProp.ValueKind != JsonValueKind.Object)
{
return null;
}
var normalized = moduleProp.TryGetProperty("normalized", out var nProp) ? nProp.GetString() : null;
var sha256 = moduleProp.TryGetProperty("path_sha256", out var sProp) ? sProp.GetString() : null;
return normalized is not null ? new ModuleRef(normalized, sha256) : null;
}
private static LocationRef? ParseLocation(JsonElement root)
{
if (!root.TryGetProperty("location", out var locProp) || locProp.ValueKind != JsonValueKind.Object)
{
return null;
}
return ParseLocationDirect(locProp);
}
private static LocationRef? ParseLocationDirect(JsonElement locProp)
{
if (locProp.ValueKind != JsonValueKind.Object)
{
return null;
}
var path = locProp.TryGetProperty("path", out var pProp) ? pProp.GetString() : null;
var line = locProp.TryGetProperty("line", out var lProp) ? lProp.GetInt32() : (int?)null;
return path is not null ? new LocationRef(path, line) : null;
}
private sealed record ModuleRef(string Normalized, string? PathSha256);
private sealed record LocationRef(string Path, int? Line);
}
/// <summary>
/// Represents a complete Ruby runtime trace.
/// </summary>
internal sealed record RubyRuntimeTrace(
RubyRuntimeEvent[] Events,
RubyRequireEvent[] Requires,
RubyLoadEvent[] Loads,
RubyMethodCallEvent[] MethodCalls,
RubyRuntimeErrorEvent[] Errors,
string? RubyVersion,
string? RubyPlatform,
string[] Capabilities,
int? LoadedFeaturesCount)
{
public static RubyRuntimeTrace Empty { get; } = new(
[],
[],
[],
[],
[],
null,
null,
[],
null);
public bool IsEmpty => Events.Length == 0;
}
/// <summary>
/// Base runtime event with type and timestamp.
/// </summary>
internal sealed record RubyRuntimeEvent(string Type, string? Timestamp);
/// <summary>
/// A require event capturing a gem/file being loaded.
/// </summary>
internal sealed record RubyRequireEvent(
string? Timestamp,
string Feature,
string? NormalizedPath,
string? PathSha256,
bool Success,
string? Capability);
/// <summary>
/// A load event for explicit file loads.
/// </summary>
internal sealed record RubyLoadEvent(
string? Timestamp,
string NormalizedPath,
string? PathSha256,
bool Wrap);
/// <summary>
/// A method call event from TracePoint.
/// </summary>
internal sealed record RubyMethodCallEvent(
string? Timestamp,
string ClassName,
string MethodName,
string? Path,
int? Line);
/// <summary>
/// A runtime error event.
/// </summary>
internal sealed record RubyRuntimeErrorEvent(
string? Timestamp,
string Message,
string? Path);

View File

@@ -0,0 +1,164 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.Lang;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
/// <summary>
/// Optional harness that executes the emitted Ruby runtime shim when an entrypoint is provided via environment variable.
/// This keeps runtime capture opt-in and offline-friendly.
/// </summary>
internal static class RubyRuntimeTraceRunner
{
private const string EntrypointEnvVar = "STELLA_RUBY_ENTRYPOINT";
private const string BinaryEnvVar = "STELLA_RUBY_BINARY";
private const string TraceClassesEnvVar = "STELLA_RUBY_TRACE_CLASSES";
private const string RuntimeFileName = "ruby-runtime.ndjson";
private const int DefaultTimeoutMs = 60_000; // 1 minute default timeout
public static async Task<bool> TryExecuteAsync(
LanguageAnalyzerContext context,
ILogger? logger,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var entrypoint = Environment.GetEnvironmentVariable(EntrypointEnvVar);
if (string.IsNullOrWhiteSpace(entrypoint))
{
logger?.LogDebug("Ruby runtime trace skipped: {EnvVar} not set", EntrypointEnvVar);
return false;
}
var entrypointPath = Path.GetFullPath(Path.Combine(context.RootPath, entrypoint));
if (!File.Exists(entrypointPath))
{
logger?.LogWarning("Ruby runtime trace skipped: entrypoint '{Entrypoint}' missing", entrypointPath);
return false;
}
var shimPath = Path.Combine(context.RootPath, RubyRuntimeShim.FileName);
if (!File.Exists(shimPath))
{
await RubyRuntimeShim.WriteAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
}
var binary = Environment.GetEnvironmentVariable(BinaryEnvVar);
if (string.IsNullOrWhiteSpace(binary))
{
binary = "ruby";
}
var startInfo = new ProcessStartInfo
{
FileName = binary,
WorkingDirectory = context.RootPath,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
// Ruby arguments for sandboxed execution
// -W0: Suppress warnings
// -T: Taint mode (restrict dangerous operations) - optional, may not be available in all Ruby versions
startInfo.ArgumentList.Add("-W0");
startInfo.ArgumentList.Add(shimPath);
// Pass through the entrypoint
startInfo.Environment[EntrypointEnvVar] = entrypointPath;
// Pass through trace classes filter if set
var traceClasses = Environment.GetEnvironmentVariable(TraceClassesEnvVar);
if (!string.IsNullOrWhiteSpace(traceClasses))
{
startInfo.Environment[TraceClassesEnvVar] = traceClasses;
}
// Sandbox guidance: Set restrictive environment variables
startInfo.Environment["BUNDLE_DISABLE_EXEC_LOAD"] = "1";
startInfo.Environment["BUNDLE_FROZEN"] = "1";
try
{
using var process = Process.Start(startInfo);
if (process is null)
{
logger?.LogWarning("Ruby runtime trace skipped: failed to start 'ruby' process");
return false;
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(DefaultTimeoutMs);
try
{
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timeout - kill the process
logger?.LogWarning("Ruby runtime trace timed out after {Timeout}ms", DefaultTimeoutMs);
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// Best effort
}
return false;
}
if (process.ExitCode != 0)
{
var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false);
logger?.LogWarning(
"Ruby runtime trace failed with exit code {ExitCode}. stderr: {Error}",
process.ExitCode,
Truncate(stderr));
// Still check for output file - partial traces may be useful
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Ruby runtime trace skipped: {Message}", ex.Message);
return false;
}
var runtimePath = Path.Combine(context.RootPath, RuntimeFileName);
if (!File.Exists(runtimePath))
{
logger?.LogWarning(
"Ruby runtime trace finished but did not emit {RuntimeFile}",
RuntimeFileName);
return false;
}
logger?.LogDebug("Ruby runtime trace completed: {RuntimeFile}", runtimePath);
return true;
}
/// <summary>
/// Gets the path to the expected runtime trace output file.
/// </summary>
public static string GetOutputPath(string rootPath) => Path.Combine(rootPath, RuntimeFileName);
/// <summary>
/// Checks if a runtime trace output exists for the given root path.
/// </summary>
public static bool OutputExists(string rootPath) => File.Exists(GetOutputPath(rootPath));
private static string Truncate(string? value, int maxLength = 400)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value.Length <= maxLength ? value : value[..maxLength];
}
}

View File

@@ -30,6 +30,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
var capabilities = await RubyCapabilityDetector.DetectAsync(context, cancellationToken).ConfigureAwait(false);
var runtimeGraph = await RubyRuntimeGraphBuilder.BuildAsync(context, packages, cancellationToken).ConfigureAwait(false);
var bundlerConfig = RubyBundlerConfig.Load(context.RootPath);
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
{
@@ -50,7 +51,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
if (packages.Count > 0)
{
EmitObservation(context, writer, packages, runtimeGraph, capabilities, lockData.BundledWith);
EmitObservation(context, writer, packages, lockData, runtimeGraph, capabilities, bundlerConfig, lockData.BundledWith);
}
}
@@ -86,23 +87,28 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
IReadOnlyList<RubyPackage> packages,
RubyLockData lockData,
RubyRuntimeGraph runtimeGraph,
RubyCapabilities capabilities,
RubyBundlerConfig bundlerConfig,
string? bundledWith)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(packages);
ArgumentNullException.ThrowIfNull(lockData);
ArgumentNullException.ThrowIfNull(runtimeGraph);
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentNullException.ThrowIfNull(bundlerConfig);
var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities, bundledWith);
var observationDocument = RubyObservationBuilder.Build(packages, lockData, runtimeGraph, capabilities, bundlerConfig, bundledWith);
var observationJson = RubyObservationSerializer.Serialize(observationDocument);
var observationHash = RubyObservationSerializer.ComputeSha256(observationJson);
var observationBytes = Encoding.UTF8.GetBytes(observationJson);
var observationMetadata = BuildObservationMetadata(
packages.Count,
observationDocument.DependencyEdges.Length,
observationDocument.RuntimeEdges.Length,
observationDocument.Capabilities,
observationDocument.BundledWith);
@@ -132,11 +138,13 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
private static IEnumerable<KeyValuePair<string, string?>> BuildObservationMetadata(
int packageCount,
int dependencyEdgeCount,
int runtimeEdgeCount,
RubyObservationCapabilitySummary capabilities,
string? bundledWith)
{
yield return new KeyValuePair<string, string?>("ruby.observation.packages", packageCount.ToString(CultureInfo.InvariantCulture));
yield return new KeyValuePair<string, string?>("ruby.observation.dependency_edges", dependencyEdgeCount.ToString(CultureInfo.InvariantCulture));
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_edges", runtimeEdgeCount.ToString(CultureInfo.InvariantCulture));
yield return new KeyValuePair<string, string?>("ruby.observation.capability.exec", capabilities.UsesExec ? "true" : "false");
yield return new KeyValuePair<string, string?>("ruby.observation.capability.net", capabilities.UsesNetwork ? "true" : "false");

View File

@@ -6,3 +6,8 @@
| `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector merged with vendor cache ingestion; workspace overrides, bundler groups, git/path fixture, and offline-kit mirror updated. |
| `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. |
| `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. |
| `SCANNER-ANALYZERS-RUBY-28-001` | DONE (2025-11-27) | Added OCI container layer support (layers/, .layers/, layer/) to RubyLockCollector and RubyVendorArtifactCollector for VFS/container workspace discovery. Existing implementation already covered Gemfile/lock, vendor/bundle, .gem archives, .bundle/config, Rack configs, and framework fingerprints. |
| `SCANNER-ANALYZERS-RUBY-28-002` | DONE (2025-11-27) | Enhanced RubyLockParser to capture gem dependency edges with version constraints from Gemfile.lock; added RubyDependencyEdge type; updated RubyLockEntry, RubyObservationDocument, observation builder and serializer to produce dependencyEdges with from/to/constraint fields. PURLs and resolver traces now included. |
| `SCANNER-ANALYZERS-RUBY-28-003` | DONE (2025-11-27) | AOC-compliant observations integration: added schema field, RubyObservationEntrypoint and RubyObservationEnvironment types; builder generates entrypoints (path/type/requiredGems) and environment profiles (bundlePaths/gemfiles/lockfiles/frameworks); RubyRuntimeGraph provides GetEntrypointFiles/GetRequiredGems; bundlerConfig wired through analyzer for complete observation coverage. |
| `SCANNER-ANALYZERS-RUBY-28-004` | DONE (2025-11-27) | Fixtures/benchmarks for Ruby analyzer: created cli-app fixture with Thor/TTY-Prompt CLI gems, updated expected.json golden files for simple-app and complex-app with dependency edges format, added CliWorkspaceProducesDeterministicOutputAsync test; all 4 determinism tests pass. |
| `SCANNER-ANALYZERS-RUBY-28-005` | DONE (2025-11-27) | Runtime capture (tracepoint) hooks: created Internal/Runtime/ with RubyRuntimeShim.cs (trace-shim.rb using TracePoint for require/load events, capability detection, sensitive data redaction), RubyRuntimeTraceRunner.cs (opt-in harness via STELLA_RUBY_ENTRYPOINT env var, sandbox guidance), and RubyRuntimeTraceReader.cs (NDJSON parser for trace events). |

View File

@@ -0,0 +1,24 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.ruby",
"displayName": "StellaOps Ruby Analyzer",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Ruby.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Ruby.RubyAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"ruby",
"rubygems",
"bundler"
],
"metadata": {
"org.stellaops.analyzer.language": "ruby",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.runtime-capture": "optional"
}
}