Add inline DSSE provenance documentation and Mongo schema

- Introduced a new document outlining the inline DSSE provenance for SBOM, VEX, scan, and derived events.
- Defined the Mongo schema for event patches, including key fields for provenance and trust verification.
- Documented the write path for ingesting provenance metadata and backfilling historical events.
- Created CI/CD snippets for uploading DSSE attestations and generating provenance metadata.
- Established Mongo indexes for efficient provenance queries and provided query recipes for various use cases.
- Outlined policy gates for managing VEX decisions based on provenance verification.
- Included UI nudges for displaying provenance information and implementation tasks for future enhancements.

---

Implement reachability lattice and scoring model

- Developed a comprehensive document detailing the reachability lattice and scoring model.
- Defined core types for reachability states, evidence, and mitigations with corresponding C# models.
- Established a scoring policy with base score contributions from various evidence classes.
- Mapped reachability states to VEX gates and provided a clear overview of evidence sources.
- Documented the event graph schema for persisting reachability data in MongoDB.
- Outlined the integration of runtime probes for evidence collection and defined a roadmap for future tasks.

---

Introduce uncertainty states and entropy scoring

- Created a draft document for tracking uncertainty states and their impact on risk scoring.
- Defined core uncertainty states with associated entropy values and evidence requirements.
- Established a schema for storing uncertainty states alongside findings.
- Documented the risk score calculation incorporating uncertainty and its effect on final risk assessments.
- Provided policy guidelines for handling uncertainty in decision-making processes.
- Outlined UI guidelines for displaying uncertainty information and suggested remediation actions.

---

Add Ruby package inventory management

- Implemented Ruby package inventory management with corresponding data models and storage mechanisms.
- Created C# records for Ruby package inventory, artifacts, provenance, and runtime details.
- Developed a repository for managing Ruby package inventory documents in MongoDB.
- Implemented a service for storing and retrieving Ruby package inventories.
- Added unit tests for the Ruby package inventory store to ensure functionality and data integrity.
This commit is contained in:
master
2025-11-13 00:20:33 +02:00
parent 86be324fc0
commit 7040984215
41 changed files with 1955 additions and 76 deletions

View File

@@ -6888,14 +6888,23 @@ internal static class CommandHandlers
logger.LogInformation("Resolving Ruby packages for scan {ScanId}.", identifier);
activity?.SetTag("stellaops.cli.scan_id", identifier);
var packages = await client.GetRubyPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
var report = RubyResolveReport.Create(identifier, packages);
var inventory = await client.GetRubyPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
if (inventory is null)
{
outcome = "empty";
Environment.ExitCode = 0;
AnsiConsole.MarkupLine("[yellow]Ruby package inventory is not available for scan {0}.[/]", Markup.Escape(identifier));
return;
}
var report = RubyResolveReport.Create(inventory);
if (!report.HasPackages)
{
outcome = "empty";
Environment.ExitCode = 0;
AnsiConsole.MarkupLine("[yellow]No Ruby packages found for scan {0}.[/]", Markup.Escape(identifier));
var displayScanId = string.IsNullOrWhiteSpace(report.ScanId) ? identifier : report.ScanId;
AnsiConsole.MarkupLine("[yellow]No Ruby packages found for scan {0}.[/]", Markup.Escape(displayScanId));
return;
}
@@ -7225,6 +7234,12 @@ internal static class CommandHandlers
[JsonPropertyName("scanId")]
public string ScanId { get; }
[JsonPropertyName("imageDigest")]
public string ImageDigest { get; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; }
[JsonPropertyName("groups")]
public IReadOnlyList<RubyResolveGroup> Groups { get; }
@@ -7234,15 +7249,17 @@ internal static class CommandHandlers
[JsonIgnore]
public int TotalPackages => Groups.Sum(static group => group.Packages.Count);
private RubyResolveReport(string scanId, IReadOnlyList<RubyResolveGroup> groups)
private RubyResolveReport(string scanId, string imageDigest, DateTimeOffset generatedAt, IReadOnlyList<RubyResolveGroup> groups)
{
ScanId = scanId;
ImageDigest = imageDigest;
GeneratedAt = generatedAt;
Groups = groups;
}
public static RubyResolveReport Create(string scanId, IReadOnlyList<RubyPackageArtifactModel>? packages)
public static RubyResolveReport Create(RubyPackageInventoryModel inventory)
{
var resolved = (packages ?? Array.Empty<RubyPackageArtifactModel>())
var resolved = (inventory.Packages ?? Array.Empty<RubyPackageArtifactModel>())
.Select(RubyResolvePackage.FromModel)
.ToArray();
@@ -7272,7 +7289,9 @@ internal static class CommandHandlers
.ToArray()))
.ToArray();
return new RubyResolveReport(scanId, grouped);
var normalizedScanId = inventory.ScanId ?? string.Empty;
var normalizedDigest = inventory.ImageDigest ?? string.Empty;
return new RubyResolveReport(normalizedScanId, normalizedDigest, inventory.GeneratedAt, grouped);
}
}

View File

@@ -892,7 +892,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return result;
}
public async Task<IReadOnlyList<RubyPackageArtifactModel>> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken)
public async Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
@@ -907,7 +907,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Array.Empty<RubyPackageArtifactModel>();
return null;
}
if (!response.IsSuccessStatusCode)
@@ -916,11 +916,25 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
throw new InvalidOperationException(failure);
}
var packages = await response.Content
.ReadFromJsonAsync<IReadOnlyList<RubyPackageArtifactModel>>(SerializerOptions, cancellationToken)
var inventory = await response.Content
.ReadFromJsonAsync<RubyPackageInventoryModel>(SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return packages ?? Array.Empty<RubyPackageArtifactModel>();
if (inventory is null)
{
throw new InvalidOperationException("Ruby package response payload was empty.");
}
var normalizedScanId = string.IsNullOrWhiteSpace(inventory.ScanId) ? scanId : inventory.ScanId;
var normalizedDigest = inventory.ImageDigest ?? string.Empty;
var packages = inventory.Packages ?? Array.Empty<RubyPackageArtifactModel>();
return inventory with
{
ScanId = normalizedScanId,
ImageDigest = normalizedDigest,
Packages = packages
};
}
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(

View File

@@ -49,7 +49,7 @@ internal interface IBackendOperationsClient
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
Task<IReadOnlyList<RubyPackageArtifactModel>> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken);
Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken);
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
@@ -26,3 +27,8 @@ internal sealed record RubyPackageRuntime(
[property: JsonPropertyName("files")] IReadOnlyList<string>? Files,
[property: JsonPropertyName("reasons")] IReadOnlyList<string>? Reasons);
internal sealed record RubyPackageInventoryModel(
[property: JsonPropertyName("scanId")] string ScanId,
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("packages")] IReadOnlyList<RubyPackageArtifactModel> Packages);

View File

@@ -2,5 +2,4 @@
| Task ID | State | Notes |
| --- | --- | --- |
| `SCANNER-CLI-0001` | DOING (2025-11-09) | Add Ruby-specific verbs/help, refresh docs & goldens per Sprint 138. |
| `SCANNER-CLI-0001` | DONE (2025-11-12) | Ruby verbs now consume the persisted `RubyPackageInventory`, warn when inventories are missing, and docs/tests were refreshed per Sprint 138. |

View File

@@ -515,16 +515,18 @@ public sealed class CommandHandlersTests
var originalExit = Environment.ExitCode;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
RubyPackages = new[]
{
CreateRubyPackageArtifact("pkg-rack", "rack", "3.1.0", new[] { "default", "web" }, runtimeUsed: true),
CreateRubyPackageArtifact("pkg-sidekiq", "sidekiq", "7.2.1", groups: null, runtimeUsed: false, metadataOverrides: new Dictionary<string, string?>
RubyInventory = CreateRubyInventory(
"scan-ruby",
new[]
{
["groups"] = "jobs",
["runtime.entrypoints"] = "config/jobs.rb",
["runtime.files"] = "config/jobs.rb"
CreateRubyPackageArtifact("pkg-rack", "rack", "3.1.0", new[] { "default", "web" }, runtimeUsed: true),
CreateRubyPackageArtifact("pkg-sidekiq", "sidekiq", "7.2.1", groups: null, runtimeUsed: false, metadataOverrides: new Dictionary<string, string?>
{
["groups"] = "jobs",
["runtime.entrypoints"] = "config/jobs.rb",
["runtime.files"] = "config/jobs.rb"
})
})
}
};
var provider = BuildServiceProvider(backend);
@@ -557,15 +559,17 @@ public sealed class CommandHandlersTests
public async Task HandleRubyResolveAsync_WritesJson()
{
var originalExit = Environment.ExitCode;
const string identifier = "ruby-scan-json";
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
RubyPackages = new[]
{
CreateRubyPackageArtifact("pkg-rack-json", "rack", "3.1.0", new[] { "default" }, runtimeUsed: true)
}
RubyInventory = CreateRubyInventory(
identifier,
new[]
{
CreateRubyPackageArtifact("pkg-rack-json", "rack", "3.1.0", new[] { "default" }, runtimeUsed: true)
})
};
var provider = BuildServiceProvider(backend);
const string identifier = "ruby-scan-json";
try
{
@@ -608,6 +612,35 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandleRubyResolveAsync_NotifiesWhenInventoryMissing()
{
var originalExit = Environment.ExitCode;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var provider = BuildServiceProvider(backend);
try
{
var output = await CaptureTestConsoleAsync(async _ =>
{
await CommandHandlers.HandleRubyResolveAsync(
provider,
imageReference: null,
scanId: "scan-missing",
format: "table",
verbose: false,
cancellationToken: CancellationToken.None);
});
Assert.Equal(0, Environment.ExitCode);
Assert.Contains("not available", output.Combined, StringComparison.OrdinalIgnoreCase);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleAdviseRunAsync_WritesOutputAndSetsExitCode()
{
@@ -3520,6 +3553,18 @@ spec:
mergedMetadata);
}
private static RubyPackageInventoryModel CreateRubyInventory(
string scanId,
IReadOnlyList<RubyPackageArtifactModel> packages,
string? imageDigest = null)
{
return new RubyPackageInventoryModel(
scanId,
imageDigest ?? "sha256:inventory",
DateTimeOffset.UtcNow,
packages);
}
private static string ComputeSha256Base64(string path)
{
@@ -3601,8 +3646,8 @@ spec:
public string? LastExcititorRoute { get; private set; }
public HttpMethod? LastExcititorMethod { get; private set; }
public object? LastExcititorPayload { get; private set; }
public IReadOnlyList<RubyPackageArtifactModel> RubyPackages { get; set; } = Array.Empty<RubyPackageArtifactModel>();
public Exception? RubyPackagesException { get; set; }
public RubyPackageInventoryModel? RubyInventory { get; set; }
public Exception? RubyInventoryException { get; set; }
public string? LastRubyPackagesScanId { get; private set; }
public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new();
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
@@ -3830,15 +3875,15 @@ spec:
return Task.FromResult(EntryTraceResponse);
}
public Task<IReadOnlyList<RubyPackageArtifactModel>> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken)
public Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken)
{
LastRubyPackagesScanId = scanId;
if (RubyPackagesException is not null)
if (RubyInventoryException is not null)
{
throw RubyPackagesException;
throw RubyInventoryException;
}
return Task.FromResult(RubyPackages);
return Task.FromResult(RubyInventory);
}
public Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken)