feat: Add Promotion-Time Attestations for Stella Ops

- Introduced a new document for promotion-time attestations, detailing the purpose, predicate schema, producer workflow, verification flow, APIs, and security considerations.
- Implemented the `stella.ops/promotion@v1` predicate schema to capture promotion evidence including image digest, SBOM/VEX artifacts, and Rekor proof.
- Defined producer responsibilities and workflows for CLI orchestration, signer responsibilities, and Export Center integration.
- Added verification steps for auditors to validate promotion attestations offline.

feat: Create Symbol Manifest v1 Specification

- Developed a specification for Symbol Manifest v1 to provide a deterministic format for publishing debug symbols and source maps.
- Defined the manifest structure, including schema, entries, source maps, toolchain, and provenance.
- Outlined upload and verification processes, resolve APIs, runtime proxy, caching, and offline bundle generation.
- Included security considerations and related tasks for implementation.

chore: Add Ruby Analyzer with Git Sources

- Created a Gemfile and Gemfile.lock for Ruby analyzer with dependencies on git-gem, httparty, and path-gem.
- Implemented main application logic to utilize the defined gems and output their versions.
- Added expected JSON output for the Ruby analyzer to validate the integration of the new gems and their functionalities.
- Developed internal observation classes for Ruby packages, runtime edges, and capabilities, including serialization logic for observations.

test: Add tests for Ruby Analyzer

- Created test fixtures for Ruby analyzer, including Gemfile, Gemfile.lock, main application, and expected JSON output.
- Ensured that the tests validate the correct integration and functionality of the Ruby analyzer with the specified gems.
This commit is contained in:
master
2025-11-11 15:30:22 +02:00
parent 56c687253f
commit c2c6b58b41
56 changed files with 2305 additions and 198 deletions

View File

@@ -0,0 +1,83 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
internal static class RubyObservationBuilder
{
public static RubyObservationDocument Build(
IReadOnlyList<RubyPackage> packages,
RubyRuntimeGraph runtimeGraph,
RubyCapabilities capabilities)
{
ArgumentNullException.ThrowIfNull(packages);
ArgumentNullException.ThrowIfNull(runtimeGraph);
ArgumentNullException.ThrowIfNull(capabilities);
var packageItems = packages
.OrderBy(static package => package.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static package => package.Version, StringComparer.OrdinalIgnoreCase)
.Select(CreatePackage)
.ToImmutableArray();
var runtimeItems = packages
.Select(package => CreateRuntimeEdge(package, runtimeGraph))
.Where(static edge => edge is not null)
.Select(static edge => edge!)
.OrderBy(static edge => edge.Package, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var capabilitySummary = new RubyObservationCapabilitySummary(
capabilities.UsesExec,
capabilities.UsesNetwork,
capabilities.UsesSerialization,
capabilities.JobSchedulers
.OrderBy(static scheduler => scheduler, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray());
return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary);
}
private static RubyObservationPackage CreatePackage(RubyPackage package)
{
var groups = package.Groups
.OrderBy(static group => group, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new RubyObservationPackage(
package.Name,
package.Version,
package.Source,
package.Platform,
package.DeclaredOnly,
package.LockfileLocator,
package.ArtifactLocator,
groups);
}
private static RubyObservationRuntimeEdge? CreateRuntimeEdge(RubyPackage package, RubyRuntimeGraph runtimeGraph)
{
if (!runtimeGraph.TryGetUsage(package, out var usage) || usage is null || !usage.HasFiles)
{
return null;
}
var files = usage.ReferencingFiles
.OrderBy(static file => file, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var entrypoints = usage.Entrypoints
.OrderBy(static file => file, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var reasons = usage.Reasons
.OrderBy(static reason => reason, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new RubyObservationRuntimeEdge(
package.Name,
usage.UsedByEntrypoint,
files,
entrypoints,
reasons);
}
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
internal sealed record RubyObservationDocument(
ImmutableArray<RubyObservationPackage> Packages,
ImmutableArray<RubyObservationRuntimeEdge> RuntimeEdges,
RubyObservationCapabilitySummary Capabilities);
internal sealed record RubyObservationPackage(
string Name,
string Version,
string Source,
string? Platform,
bool DeclaredOnly,
string? Lockfile,
string? Artifact,
ImmutableArray<string> Groups);
internal sealed record RubyObservationRuntimeEdge(
string Package,
bool UsedByEntrypoint,
ImmutableArray<string> Files,
ImmutableArray<string> Entrypoints,
ImmutableArray<string> Reasons);
internal sealed record RubyObservationCapabilitySummary(
bool UsesExec,
bool UsesNetwork,
bool UsesSerialization,
ImmutableArray<string> JobSchedulers);

View File

@@ -0,0 +1,114 @@
using System.Buffers;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
internal static class RubyObservationSerializer
{
public static string Serialize(RubyObservationDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false }))
{
writer.WriteStartObject();
WritePackages(writer, document.Packages);
WriteRuntimeEdges(writer, document.RuntimeEdges);
WriteCapabilities(writer, document.Capabilities);
writer.WriteEndObject();
writer.Flush();
}
return Encoding.UTF8.GetString(buffer.WrittenSpan);
}
public static string ComputeSha256(string value)
{
ArgumentNullException.ThrowIfNull(value);
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static void WritePackages(Utf8JsonWriter writer, ImmutableArray<RubyObservationPackage> packages)
{
writer.WritePropertyName("packages");
writer.WriteStartArray();
foreach (var package in packages)
{
writer.WriteStartObject();
writer.WriteString("name", package.Name);
writer.WriteString("version", package.Version);
writer.WriteString("source", package.Source);
if (!string.IsNullOrWhiteSpace(package.Platform))
{
writer.WriteString("platform", package.Platform);
}
writer.WriteBoolean("declaredOnly", package.DeclaredOnly);
if (!string.IsNullOrWhiteSpace(package.Lockfile))
{
writer.WriteString("lockfile", package.Lockfile);
}
if (!string.IsNullOrWhiteSpace(package.Artifact))
{
writer.WriteString("artifact", package.Artifact);
}
WriteStringArray(writer, "groups", package.Groups);
writer.WriteEndObject();
}
writer.WriteEndArray();
}
private static void WriteRuntimeEdges(Utf8JsonWriter writer, ImmutableArray<RubyObservationRuntimeEdge> runtimeEdges)
{
writer.WritePropertyName("runtimeEdges");
writer.WriteStartArray();
foreach (var edge in runtimeEdges)
{
writer.WriteStartObject();
writer.WriteString("package", edge.Package);
writer.WriteBoolean("usedByEntrypoint", edge.UsedByEntrypoint);
WriteStringArray(writer, "files", edge.Files);
WriteStringArray(writer, "entrypoints", edge.Entrypoints);
WriteStringArray(writer, "reasons", edge.Reasons);
writer.WriteEndObject();
}
writer.WriteEndArray();
}
private static void WriteCapabilities(Utf8JsonWriter writer, RubyObservationCapabilitySummary summary)
{
writer.WritePropertyName("capabilities");
writer.WriteStartObject();
writer.WriteBoolean("usesExec", summary.UsesExec);
writer.WriteBoolean("usesNetwork", summary.UsesNetwork);
writer.WriteBoolean("usesSerialization", summary.UsesSerialization);
WriteStringArray(writer, "jobSchedulers", summary.JobSchedulers);
writer.WriteEndObject();
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
writer.WritePropertyName(propertyName);
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
}

View File

@@ -21,7 +21,7 @@ internal sealed class RubyBundlerConfig
return Empty;
}
var configPath = Path.Combine(rootPath, \".bundle\", \"config\");
var configPath = Path.Combine(rootPath, ".bundle", "config");
if (!File.Exists(configPath))
{
return Empty;
@@ -35,7 +35,9 @@ internal sealed class RubyBundlerConfig
foreach (var rawLine in File.ReadAllLines(configPath))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith(\"#\", StringComparison.Ordinal) || line.StartsWith(\"---\", StringComparison.Ordinal))
if (line.Length == 0
|| line.StartsWith("#", StringComparison.Ordinal)
|| line.StartsWith("---", StringComparison.Ordinal))
{
continue;
}
@@ -53,13 +55,13 @@ internal sealed class RubyBundlerConfig
continue;
}
value = value.Trim('\"', '\'');
value = value.Trim('"', '\'');
if (key.Equals(\"BUNDLE_GEMFILE\", StringComparison.OrdinalIgnoreCase))
if (key.Equals("BUNDLE_GEMFILE", StringComparison.OrdinalIgnoreCase))
{
AddPath(gemfiles, rootPath, value);
}
else if (key.Equals(\"BUNDLE_PATH\", StringComparison.OrdinalIgnoreCase))
else if (key.Equals("BUNDLE_PATH", StringComparison.OrdinalIgnoreCase))
{
AddPath(bundlePaths, rootPath, value);
}

View File

@@ -103,19 +103,19 @@ internal static class RubyLockParser
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase))
{
currentRevision = line[10..].Trim();
currentRevision = ExtractValue(line);
return;
}
if (line.StartsWith(" ref:", StringComparison.OrdinalIgnoreCase) && currentRevision is null)
{
currentRevision = line[6..].Trim();
currentRevision = ExtractValue(line);
return;
}
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase))
{
currentPath = line[6..].Trim();
currentPath = ExtractValue(line);
return;
}
@@ -200,6 +200,17 @@ internal static class RubyLockParser
_ => "rubygems",
};
}
private static string ExtractValue(string line)
{
var separatorIndex = line.IndexOf(':');
if (separatorIndex < 0 || separatorIndex + 1 >= line.Length)
{
return line.Trim();
}
return line[(separatorIndex + 1)..].Trim();
}
}
internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform);

View File

@@ -112,7 +112,7 @@ internal sealed class RubyPackageBuilder
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
var source = _lockSource ?? _artifactSource ?? "unknown";
var source = _artifactSource ?? _lockSource ?? "unknown";
var evidenceSource = _hasVendor
? _artifactEvidenceSource ?? "vendor"
: _lockEvidenceSource ?? "Gemfile.lock";

View File

@@ -219,12 +219,37 @@ internal static class RubyVendorArtifactCollector
{
var normalized = relativePath.Replace('\\', '/');
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
if (segments.Length >= 2)
{
return normalized;
if (MatchesPrefix(segments, "vendor", "cache"))
{
return "vendor-cache";
}
if (MatchesPrefix(segments, "vendor", "bundle"))
{
return "vendor-bundle";
}
if (MatchesPrefix(segments, ".bundle", "cache"))
{
return "bundle-cache";
}
}
return segments[0];
if (segments.Length > 0)
{
return segments[0];
}
return normalized;
}
private static bool MatchesPrefix(IReadOnlyList<string> segments, string first, string second)
{
return segments.Count >= 2
&& segments[0].Equals(first, StringComparison.OrdinalIgnoreCase)
&& segments[1].Equals(second, StringComparison.OrdinalIgnoreCase);
}
private static string EnsureTrailingSeparator(string path)

View File

@@ -1,4 +1,8 @@
using System.Globalization;
using System.Text;
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Validation;
@@ -43,6 +47,11 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
evidence: package.CreateEvidence(),
usedByEntrypoint: runtimeUsage?.UsedByEntrypoint ?? false);
}
if (packages.Count > 0)
{
EmitObservation(context, writer, packages, runtimeGraph, capabilities);
}
}
private static async ValueTask EnsureSurfaceValidationAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
@@ -60,16 +69,120 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["analyzerId"] = \"ruby\",
[\"rootPath\"] = context.RootPath
["analyzerId"] = "ruby",
["rootPath"] = context.RootPath
};
var validationContext = SurfaceValidationContext.Create(
context.Services,
\"StellaOps.Scanner.Analyzers.Lang.Ruby\",
"StellaOps.Scanner.Analyzers.Lang.Ruby",
environment.Settings,
properties);
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
}
private void EmitObservation(
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
IReadOnlyList<RubyPackage> packages,
RubyRuntimeGraph runtimeGraph,
RubyCapabilities capabilities)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(packages);
ArgumentNullException.ThrowIfNull(runtimeGraph);
ArgumentNullException.ThrowIfNull(capabilities);
var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities);
var observationJson = RubyObservationSerializer.Serialize(observationDocument);
var observationHash = RubyObservationSerializer.ComputeSha256(observationJson);
var observationBytes = Encoding.UTF8.GetBytes(observationJson);
var observationMetadata = BuildObservationMetadata(
packages.Count,
observationDocument.RuntimeEdges.Length,
observationDocument.Capabilities);
TryPersistObservation(Id, context, observationBytes, observationMetadata);
var observationEvidence = new[]
{
new LanguageComponentEvidence(
LanguageEvidenceKind.Derived,
"ruby.observation",
"document",
observationJson,
observationHash)
};
writer.AddFromExplicitKey(
analyzerId: Id,
componentKey: "observation::ruby",
purl: null,
name: "Ruby Observation Summary",
version: null,
type: "ruby-observation",
metadata: observationMetadata,
evidence: observationEvidence);
}
private static IEnumerable<KeyValuePair<string, string?>> BuildObservationMetadata(
int packageCount,
int runtimeEdgeCount,
RubyObservationCapabilitySummary capabilities)
{
yield return new KeyValuePair<string, string?>("ruby.observation.packages", packageCount.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");
yield return new KeyValuePair<string, string?>("ruby.observation.capability.serialization", capabilities.UsesSerialization ? "true" : "false");
yield return new KeyValuePair<string, string?>("ruby.observation.capability.schedulers", capabilities.JobSchedulers.Length.ToString(CultureInfo.InvariantCulture));
}
private static void TryPersistObservation(
string analyzerId,
LanguageAnalyzerContext context,
byte[] observationBytes,
IEnumerable<KeyValuePair<string, string?>> metadata)
{
if (string.IsNullOrWhiteSpace(analyzerId))
{
throw new ArgumentException("Analyzer id is required", nameof(analyzerId));
}
if (context.AnalysisStore is not { } analysisStore)
{
return;
}
var metadataDictionary = CreateMetadata(metadata);
var payload = new AnalyzerObservationPayload(
analyzerId: analyzerId,
kind: "ruby.observation",
mediaType: "application/json",
content: observationBytes,
metadata: metadataDictionary,
view: "observations");
analysisStore.Set(ScanAnalysisKeys.RubyObservationPayload, payload);
}
private static IReadOnlyDictionary<string, string?>? CreateMetadata(IEnumerable<KeyValuePair<string, string?>> metadata)
{
Dictionary<string, string?>? dictionary = null;
foreach (var pair in metadata ?? Array.Empty<KeyValuePair<string, string?>>())
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
dictionary ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
dictionary[pair.Key] = pair.Value;
}
return dictionary;
}
}

View File

@@ -16,5 +16,6 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,6 +2,6 @@
| Task ID | State | Notes |
| --- | --- | --- |
| `SCANNER-ENG-0016` | DOING (2025-11-10) | Building RubyLockCollector + multi-source vendor ingestion per design §4.14.3 (Codex agent). |
| `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. |

View File

@@ -21,4 +21,6 @@ public static class ScanAnalysisKeys
public const string RegistryCredentials = "analysis.registry.credentials";
public const string DenoObservationPayload = "analysis.lang.deno.observation";
public const string RubyObservationPayload = "analysis.lang.ruby.observation";
}