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
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:
@@ -645,7 +645,7 @@ internal static class NodePackageCollector
|
||||
packageSha256: packageSha256,
|
||||
isYarnPnp: yarnPnpPresent);
|
||||
|
||||
AttachEntrypoints(package, root, relativeDirectory);
|
||||
AttachEntrypoints(context, package, root, relativeDirectory);
|
||||
|
||||
return package;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ internal sealed record RubyLockEntry(
|
||||
string Source,
|
||||
string? Platform,
|
||||
IReadOnlyCollection<string> Groups,
|
||||
IReadOnlyList<RubyDependencyEdge> Dependencies,
|
||||
string LockFileRelativePath);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
""";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user