From 8c8f0c632d7968fc799ff2ec6da0108de500f378 Mon Sep 17 00:00:00 2001 From: StellaOps Bot Date: Mon, 15 Dec 2025 09:03:56 +0200 Subject: [PATCH] update --- .../Program.cs | 214 ++++++++++++++++++ .../StellaOps.Attestor.Types.Tests.csproj | 7 + .../EvidenceBundle.cs | 53 +++++ .../EvidenceBundleBuilder.cs | 61 +++++ .../EvidenceBundlePredicate.cs | 15 ++ .../EvidenceHashSet.cs | 37 +++ .../EvidenceStatusSummary.cs | 12 + .../ServiceCollectionExtensions.cs | 24 ++ 8 files changed, 423 insertions(+) create mode 100644 src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundle.cs create mode 100644 src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundleBuilder.cs create mode 100644 src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundlePredicate.cs create mode 100644 src/__Libraries/StellaOps.Evidence.Bundle/EvidenceHashSet.cs create mode 100644 src/__Libraries/StellaOps.Evidence.Bundle/EvidenceStatusSummary.cs create mode 100644 src/__Libraries/StellaOps.Evidence.Bundle/ServiceCollectionExtensions.cs diff --git a/src/Attestor/StellaOps.Attestor.Types/Tools/StellaOps.Attestor.Types.Generator/Program.cs b/src/Attestor/StellaOps.Attestor.Types/Tools/StellaOps.Attestor.Types.Generator/Program.cs index 4069cdd4d..138dd695a 100644 --- a/src/Attestor/StellaOps.Attestor.Types/Tools/StellaOps.Attestor.Types.Generator/Program.cs +++ b/src/Attestor/StellaOps.Attestor.Types/Tools/StellaOps.Attestor.Types.Generator/Program.cs @@ -255,6 +255,123 @@ internal sealed class TypeRegistry customEvidence.Properties.Add(new PropertySpec("generatedAt", PrimitiveShape.String(format: "date-time"), true, "Timestamp when the evidence was generated.")); customEvidence.Properties.Add(new PropertySpec("properties", new ArrayShape(new ObjectShape(customProperty), 0), false, "Optional key/value properties for additional context.")); + // Smart-Diff predicate (schema-only; consumed as in-toto predicate payload) + var smartDiffMaterialChangeType = RegisterEnum( + "MaterialChangeType", + "Material change types emitted by Smart-Diff.", + "reachability_flip", + "vex_flip", + "range_boundary", + "intelligence_flip"); + + var smartDiffVexStatusType = RegisterEnum( + "VexStatusType", + "VEX status values captured in Smart-Diff risk state.", + "affected", + "not_affected", + "fixed", + "under_investigation", + "unknown"); + + var findingKey = RegisterObject("FindingKey", "Unique identifier for a vulnerability finding."); + findingKey.Properties.Add(new PropertySpec("componentPurl", PrimitiveShape.String(), true, "Component package URL (purl).")); + findingKey.Properties.Add(new PropertySpec("componentVersion", PrimitiveShape.String(), true, "Component version string.")); + findingKey.Properties.Add(new PropertySpec("cveId", PrimitiveShape.String(), true, "Vulnerability identifier (e.g., CVE).")); + + var riskState = RegisterObject("RiskState", "Risk state captured for a finding at a point in time."); + riskState.Properties.Add(new PropertySpec("reachable", PrimitiveShape.Boolean(), false, "Reachability flag (null/absent indicates unknown).")); + riskState.Properties.Add(new PropertySpec("vexStatus", new EnumShape(smartDiffVexStatusType), true, "VEX status value.")); + riskState.Properties.Add(new PropertySpec("inAffectedRange", PrimitiveShape.Boolean(), false, "True if the component version is within the affected range.")); + riskState.Properties.Add(new PropertySpec("kev", PrimitiveShape.Boolean(), true, "True if the vulnerability is in the KEV catalog.")); + riskState.Properties.Add(new PropertySpec("epssScore", PrimitiveShape.Number(minimum: 0, maximum: 1), false, "EPSS score (0..1).")); + riskState.Properties.Add(new PropertySpec("policyFlags", new ArrayShape(PrimitiveShape.String(), 0), false, "Policy flags contributing to the decision.")); + + var materialChange = RegisterObject("MaterialChange", "Single material change detected for a finding."); + materialChange.Properties.Add(new PropertySpec("findingKey", new ObjectShape(findingKey), true, "Finding key for the change.")); + materialChange.Properties.Add(new PropertySpec("changeType", new EnumShape(smartDiffMaterialChangeType), true, "Type of material change detected.")); + materialChange.Properties.Add(new PropertySpec("reason", PrimitiveShape.String(), true, "Human-readable reason for the change.")); + materialChange.Properties.Add(new PropertySpec("previousState", new ObjectShape(riskState), false, "Previous risk state (when available).")); + materialChange.Properties.Add(new PropertySpec("currentState", new ObjectShape(riskState), false, "Current risk state (when available).")); + materialChange.Properties.Add(new PropertySpec("priorityScore", PrimitiveShape.Integer(minimum: 0), false, "Priority score derived from change rules and intelligence.")); + + var imageReference = RegisterObject("ImageReference", "Reference to a container image."); + imageReference.Properties.Add(new PropertySpec("digest", PrimitiveShape.String(pattern: "^sha256:[A-Fa-f0-9]{64}$"), true, "Image digest.")); + imageReference.Properties.Add(new PropertySpec("name", PrimitiveShape.String(), false, "Image name.")); + imageReference.Properties.Add(new PropertySpec("tag", PrimitiveShape.String(), false, "Image tag.")); + + var licenseDelta = RegisterObject("LicenseDelta", "License delta for a package or file."); + licenseDelta.Properties.Add(new PropertySpec("added", new ArrayShape(PrimitiveShape.String(), 0), false, "Licenses added.")); + licenseDelta.Properties.Add(new PropertySpec("removed", new ArrayShape(PrimitiveShape.String(), 0), false, "Licenses removed.")); + + var packageChange = RegisterObject("PackageChange", "Package version change between the base and target scan."); + packageChange.Properties.Add(new PropertySpec("name", PrimitiveShape.String(), true, "Package name.")); + packageChange.Properties.Add(new PropertySpec("from", PrimitiveShape.String(), true, "Previous package version.")); + packageChange.Properties.Add(new PropertySpec("to", PrimitiveShape.String(), true, "Current package version.")); + packageChange.Properties.Add(new PropertySpec("purl", PrimitiveShape.String(), false, "Package URL (purl).")); + packageChange.Properties.Add(new PropertySpec("licenseDelta", new ObjectShape(licenseDelta), false, "License delta between versions.")); + + var packageRef = RegisterObject("PackageRef", "Package reference used in diffs."); + packageRef.Properties.Add(new PropertySpec("name", PrimitiveShape.String(), true, "Package name.")); + packageRef.Properties.Add(new PropertySpec("version", PrimitiveShape.String(), true, "Package version.")); + packageRef.Properties.Add(new PropertySpec("purl", PrimitiveShape.String(), false, "Package URL (purl).")); + + var diffHunk = RegisterObject("DiffHunk", "Single diff hunk for a file change."); + diffHunk.Properties.Add(new PropertySpec("startLine", PrimitiveShape.Integer(minimum: 0), true, "Start line number.")); + diffHunk.Properties.Add(new PropertySpec("lineCount", PrimitiveShape.Integer(minimum: 0), true, "Number of lines in the hunk.")); + diffHunk.Properties.Add(new PropertySpec("content", PrimitiveShape.String(), false, "Optional hunk content.")); + + var fileChange = RegisterObject("FileChange", "File-level delta captured by Smart-Diff."); + fileChange.Properties.Add(new PropertySpec("path", PrimitiveShape.String(), true, "File path.")); + fileChange.Properties.Add(new PropertySpec("hunks", new ArrayShape(new ObjectShape(diffHunk), 0), false, "Optional hunks describing the file change.")); + fileChange.Properties.Add(new PropertySpec("fromHash", PrimitiveShape.String(), false, "Previous file hash (when available).")); + fileChange.Properties.Add(new PropertySpec("toHash", PrimitiveShape.String(), false, "Current file hash (when available).")); + + var diffPayload = RegisterObject("DiffPayload", "Diff payload describing file and package deltas."); + diffPayload.Properties.Add(new PropertySpec("filesAdded", new ArrayShape(PrimitiveShape.String(), 0), false, "Paths of files added.")); + diffPayload.Properties.Add(new PropertySpec("filesRemoved", new ArrayShape(PrimitiveShape.String(), 0), false, "Paths of files removed.")); + diffPayload.Properties.Add(new PropertySpec("filesChanged", new ArrayShape(new ObjectShape(fileChange), 0), false, "Collection of file changes.")); + diffPayload.Properties.Add(new PropertySpec("packagesChanged", new ArrayShape(new ObjectShape(packageChange), 0), false, "Collection of package changes.")); + diffPayload.Properties.Add(new PropertySpec("packagesAdded", new ArrayShape(new ObjectShape(packageRef), 0), false, "Packages added.")); + diffPayload.Properties.Add(new PropertySpec("packagesRemoved", new ArrayShape(new ObjectShape(packageRef), 0), false, "Packages removed.")); + + var userContext = RegisterObject("UserContext", "Runtime user context for the image."); + userContext.Properties.Add(new PropertySpec("uid", PrimitiveShape.Integer(minimum: 0), false, "User ID.")); + userContext.Properties.Add(new PropertySpec("gid", PrimitiveShape.Integer(minimum: 0), false, "Group ID.")); + userContext.Properties.Add(new PropertySpec("caps", new ArrayShape(PrimitiveShape.String(), 0), false, "Linux capabilities (string names).")); + + var runtimeContext = RegisterObject("RuntimeContext", "Runtime context used for reachability gating and policy decisions."); + runtimeContext.Properties.Add(new PropertySpec("entrypoint", new ArrayShape(PrimitiveShape.String(), 0), false, "Entrypoint command array.")); + runtimeContext.Properties.Add(new PropertySpec("env", new MapShape(PrimitiveShape.String()), false, "Environment variables map.")); + runtimeContext.Properties.Add(new PropertySpec("user", new ObjectShape(userContext), false, "Runtime user context.")); + + var reachabilityGate = RegisterObject("ReachabilityGate", "3-bit reachability gate derived from the 7-state lattice."); + reachabilityGate.Properties.Add(new PropertySpec("reachable", PrimitiveShape.Boolean(), false, "True/false if reachability is known; absent indicates unknown.")); + reachabilityGate.Properties.Add(new PropertySpec("configActivated", PrimitiveShape.Boolean(), false, "True if configuration activates the finding.")); + reachabilityGate.Properties.Add(new PropertySpec("runningUser", PrimitiveShape.Boolean(), false, "True if running user enables the finding.")); + reachabilityGate.Properties.Add(new PropertySpec("class", PrimitiveShape.Integer(minimum: -1, maximum: 7), true, "Derived 3-bit class (0..7), or -1 if any bit is unknown.")); + reachabilityGate.Properties.Add(new PropertySpec("rationale", PrimitiveShape.String(), false, "Optional human-readable rationale for the gate.")); + + var scannerInfo = RegisterObject("ScannerInfo", "Scanner identity and ruleset information."); + scannerInfo.Properties.Add(new PropertySpec("name", PrimitiveShape.String(), true, "Scanner name.")); + scannerInfo.Properties.Add(new PropertySpec("version", PrimitiveShape.String(), true, "Scanner version string.")); + scannerInfo.Properties.Add(new PropertySpec("ruleset", PrimitiveShape.String(), false, "Optional ruleset identifier.")); + + var smartDiffPredicate = RegisterObject( + "SmartDiffPredicate", + "Smart-Diff predicate describing differential analysis between two scans.", + isRoot: true, + schemaFileStem: "stellaops-smart-diff.v1", + qualifiedVersion: "1.0.0"); + smartDiffPredicate.Properties.Add(new PropertySpec("schemaVersion", PrimitiveShape.String(constValue: "1.0.0"), true, "Schema version (semver).")); + smartDiffPredicate.Properties.Add(new PropertySpec("baseImage", new ObjectShape(imageReference), true, "Base scan image reference.")); + smartDiffPredicate.Properties.Add(new PropertySpec("targetImage", new ObjectShape(imageReference), true, "Target scan image reference.")); + smartDiffPredicate.Properties.Add(new PropertySpec("diff", new ObjectShape(diffPayload), true, "Diff payload between base and target.")); + smartDiffPredicate.Properties.Add(new PropertySpec("context", new ObjectShape(runtimeContext), false, "Optional runtime context.")); + smartDiffPredicate.Properties.Add(new PropertySpec("reachabilityGate", new ObjectShape(reachabilityGate), true, "Derived reachability gate.")); + smartDiffPredicate.Properties.Add(new PropertySpec("scanner", new ObjectShape(scannerInfo), true, "Scanner identity.")); + smartDiffPredicate.Properties.Add(new PropertySpec("suppressedCount", PrimitiveShape.Integer(minimum: 0), false, "Number of findings suppressed by pre-filters.")); + smartDiffPredicate.Properties.Add(new PropertySpec("materialChanges", new ArrayShape(new ObjectShape(materialChange), 0), false, "Optional list of material changes.")); + return new TypeRegistry(roots, objects, enums); } } @@ -336,6 +453,8 @@ internal sealed record ObjectShape(ObjectSpec Object) : TypeShape; internal sealed record ArrayShape(TypeShape Item, int? MinItems = null) : TypeShape; +internal sealed record MapShape(TypeShape Value) : TypeShape; + internal static class SchemaBuilder { public static string Build(ObjectSpec root) @@ -512,6 +631,13 @@ internal static class SchemaBuilder return arraySchema; + case MapShape mapShape: + return new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = BuildPropertySchema(mapShape.Value, defs, visitedObjects, visitedEnums) + }; + default: throw new InvalidOperationException("Unsupported property type."); } @@ -692,6 +818,9 @@ internal static class TypeScriptEmitter case ArrayShape arrayShape: lines.AddRange(EmitArrayAssertion(arrayShape, accessor, pathExpression)); break; + case MapShape mapShape: + lines.AddRange(EmitMapAssertion(mapShape, accessor, pathExpression)); + break; default: lines.Add("// Unsupported type encountered during validation."); break; @@ -754,6 +883,29 @@ internal static class TypeScriptEmitter return lines; } + private static IReadOnlyList EmitMapAssertion(MapShape mapShape, string accessor, string pathExpression) + { + var lines = new List + { + "if (!isRecord(" + accessor + ")) {", + " throw new Error(`${pathString(" + pathExpression + ")} must be an object.`);", + "}" + }; + + lines.Add("for (const key of Object.keys(" + accessor + ")) {"); + lines.Add(" const entry = (" + accessor + " as Record)[key];"); + lines.Add(" const entryPath = [..." + pathExpression + ", key];"); + + var childLines = EmitTsAssertion(mapShape.Value, "entry", "entryPath"); + foreach (var line in childLines) + { + lines.Add(" " + line); + } + + lines.Add("}"); + return lines; + } + private static string ToTsType(TypeShape type) => type switch { @@ -766,6 +918,7 @@ internal static class TypeScriptEmitter EnumShape enumShape => enumShape.Enum.Name, ObjectShape objectShape => objectShape.Object.Name, ArrayShape array => $"Array<{ToTsType(array.Item)}>", + MapShape map => $"Record", _ => "unknown" }; } @@ -898,6 +1051,9 @@ internal static class GoEmitter case ArrayShape arrayShape: EmitArrayValidation(builder, arrayShape, accessor, path, indent); break; + case MapShape mapShape: + EmitMapValidation(builder, mapShape, accessor, path, indent); + break; } } @@ -1014,6 +1170,62 @@ internal static class GoEmitter } } + private static void EmitMapValidation(StringBuilder builder, MapShape mapShape, string accessor, string path, int indent) + { + if (!NeedsArrayItemValidation(mapShape.Value)) + { + return; + } + + AppendLine(builder, indent, $"for key, item := range {accessor} {{"); + EmitMapValueValidation(builder, mapShape.Value, "item", path, "key", indent + 1); + AppendLine(builder, indent, "}"); + } + + private static void EmitMapValueValidation(StringBuilder builder, TypeShape valueType, string accessor, string path, string keyVar, int indent) + { + switch (valueType) + { + case PrimitiveShape primitive: + EmitMapPrimitiveValidation(builder, primitive, accessor, path, keyVar, indent); + break; + case EnumShape: + AppendLine(builder, indent, $"if err := {accessor}.Validate(); err != nil {{"); + AppendLine(builder, indent + 1, $"return fmt.Errorf(\"invalid {path}[%s]: %w\", {keyVar}, err)"); + AppendLine(builder, indent, "}"); + break; + case ObjectShape: + AppendLine(builder, indent, $"if err := {accessor}.Validate(); err != nil {{"); + AppendLine(builder, indent + 1, $"return fmt.Errorf(\"invalid {path}[%s]: %w\", {keyVar}, err)"); + AppendLine(builder, indent, "}"); + break; + } + } + + private static void EmitMapPrimitiveValidation(StringBuilder builder, PrimitiveShape primitive, string accessor, string path, string keyVar, int indent) + { + if (primitive.Kind == PrimitiveKind.String && !string.IsNullOrEmpty(primitive.ConstValue)) + { + AppendLine(builder, indent, $"if {accessor} != \"{primitive.ConstValue}\" {{"); + AppendLine(builder, indent + 1, $"return fmt.Errorf(\"{path}[%s] must equal {primitive.ConstValue}\", {keyVar})"); + AppendLine(builder, indent, "}"); + } + + if ((primitive.Kind == PrimitiveKind.Number || primitive.Kind == PrimitiveKind.Integer) && primitive.Minimum.HasValue) + { + AppendLine(builder, indent, $"if {accessor} < {primitive.Minimum.Value} {{"); + AppendLine(builder, indent + 1, $"return fmt.Errorf(\"{path}[%s] must be >= {primitive.Minimum.Value}\", {keyVar})"); + AppendLine(builder, indent, "}"); + } + + if ((primitive.Kind == PrimitiveKind.Number || primitive.Kind == PrimitiveKind.Integer) && primitive.Maximum.HasValue) + { + AppendLine(builder, indent, $"if {accessor} > {primitive.Maximum.Value} {{"); + AppendLine(builder, indent + 1, $"return fmt.Errorf(\"{path}[%s] must be <= {primitive.Maximum.Value}\", {keyVar})"); + AppendLine(builder, indent, "}"); + } + } + private static void EmitArrayItemValidation(StringBuilder builder, TypeShape itemType, string accessor, string path, string indexVar, int indent) { switch (itemType) @@ -1091,6 +1303,7 @@ internal static class GoEmitter ObjectShape objectShape when property.Required => objectShape.Object.Name, ObjectShape objectShape => "*" + objectShape.Object.Name, ArrayShape arrayShape => $"[]{GoElementType(arrayShape.Item)}", + MapShape mapShape => $"map[string]{GoElementType(mapShape.Value)}", _ => "interface{}" }; } @@ -1104,6 +1317,7 @@ internal static class GoEmitter EnumShape enumShape => enumShape.Enum.Name, ObjectShape objectShape => objectShape.Object.Name, ArrayShape nested => $"[]{GoElementType(nested.Item)}", + MapShape mapShape => $"map[string]{GoElementType(mapShape.Value)}", _ => "interface{}" }; diff --git a/src/Attestor/__Tests/StellaOps.Attestor.Types.Tests/StellaOps.Attestor.Types.Tests.csproj b/src/Attestor/__Tests/StellaOps.Attestor.Types.Tests/StellaOps.Attestor.Types.Tests.csproj index f58077af5..225236798 100644 --- a/src/Attestor/__Tests/StellaOps.Attestor.Types.Tests/StellaOps.Attestor.Types.Tests.csproj +++ b/src/Attestor/__Tests/StellaOps.Attestor.Types.Tests/StellaOps.Attestor.Types.Tests.csproj @@ -1,15 +1,22 @@ net10.0 + preview enable enable false true + false + + + + + diff --git a/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundle.cs b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundle.cs new file mode 100644 index 000000000..34a735897 --- /dev/null +++ b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundle.cs @@ -0,0 +1,53 @@ +namespace StellaOps.Evidence.Bundle; + +/// A complete evidence bundle for a single finding/alert. Contains all evidence required for triage decision. +public sealed class EvidenceBundle +{ + public string BundleId { get; init; } = Guid.NewGuid().ToString("N"); + public string SchemaVersion { get; init; } = "1.0"; + public required string AlertId { get; init; } + public required string ArtifactId { get; init; } + public ReachabilityEvidence? Reachability { get; init; } + public CallStackEvidence? CallStack { get; init; } + public ProvenanceEvidence? Provenance { get; init; } + public VexStatusEvidence? VexStatus { get; init; } + public DiffEvidence? Diff { get; init; } + public GraphRevisionEvidence? GraphRevision { get; init; } + public required EvidenceHashSet Hashes { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + + /// Compute evidence completeness score (0-4 based on core evidence types). + public int ComputeCompletenessScore() + { + var score = 0; + if (Reachability?.Status == EvidenceStatus.Available) score++; + if (CallStack?.Status == EvidenceStatus.Available) score++; + if (Provenance?.Status == EvidenceStatus.Available) score++; + if (VexStatus?.Status == EvidenceStatus.Available) score++; + return score; + } + + /// Create status summary from evidence. + public EvidenceStatusSummary CreateStatusSummary() => new() + { + Reachability = Reachability?.Status ?? EvidenceStatus.Unavailable, + CallStack = CallStack?.Status ?? EvidenceStatus.Unavailable, + Provenance = Provenance?.Status ?? EvidenceStatus.Unavailable, + VexStatus = VexStatus?.Status ?? EvidenceStatus.Unavailable, + Diff = Diff?.Status ?? EvidenceStatus.Unavailable, + GraphRevision = GraphRevision?.Status ?? EvidenceStatus.Unavailable + }; + + /// Create DSSE predicate for signing. + public EvidenceBundlePredicate ToSigningPredicate() => new() + { + BundleId = BundleId, + AlertId = AlertId, + ArtifactId = ArtifactId, + CompletenessScore = ComputeCompletenessScore(), + Hashes = Hashes, + StatusSummary = CreateStatusSummary(), + CreatedAt = CreatedAt, + SchemaVersion = SchemaVersion + }; +} diff --git a/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundleBuilder.cs b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundleBuilder.cs new file mode 100644 index 000000000..2460d889d --- /dev/null +++ b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundleBuilder.cs @@ -0,0 +1,61 @@ +namespace StellaOps.Evidence.Bundle; + +/// Builder for constructing evidence bundles. +public sealed class EvidenceBundleBuilder +{ + private readonly TimeProvider _timeProvider; + private string? _alertId; + private string? _artifactId; + private ReachabilityEvidence? _reachability; + private CallStackEvidence? _callStack; + private ProvenanceEvidence? _provenance; + private VexStatusEvidence? _vexStatus; + private DiffEvidence? _diff; + private GraphRevisionEvidence? _graphRevision; + + public EvidenceBundleBuilder(TimeProvider timeProvider) + { + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public EvidenceBundleBuilder() : this(TimeProvider.System) { } + + public EvidenceBundleBuilder WithAlertId(string alertId) { _alertId = alertId; return this; } + public EvidenceBundleBuilder WithArtifactId(string artifactId) { _artifactId = artifactId; return this; } + public EvidenceBundleBuilder WithReachability(ReachabilityEvidence evidence) { _reachability = evidence; return this; } + public EvidenceBundleBuilder WithCallStack(CallStackEvidence evidence) { _callStack = evidence; return this; } + public EvidenceBundleBuilder WithProvenance(ProvenanceEvidence evidence) { _provenance = evidence; return this; } + public EvidenceBundleBuilder WithVexStatus(VexStatusEvidence evidence) { _vexStatus = evidence; return this; } + public EvidenceBundleBuilder WithDiff(DiffEvidence evidence) { _diff = evidence; return this; } + public EvidenceBundleBuilder WithGraphRevision(GraphRevisionEvidence evidence) { _graphRevision = evidence; return this; } + + public EvidenceBundle Build() + { + if (string.IsNullOrWhiteSpace(_alertId)) + throw new InvalidOperationException("AlertId is required"); + if (string.IsNullOrWhiteSpace(_artifactId)) + throw new InvalidOperationException("ArtifactId is required"); + + var hashes = new Dictionary(); + if (_reachability?.Hash is not null) hashes["reachability"] = _reachability.Hash; + if (_callStack?.Hash is not null) hashes["callstack"] = _callStack.Hash; + if (_provenance?.Hash is not null) hashes["provenance"] = _provenance.Hash; + if (_vexStatus?.Hash is not null) hashes["vex"] = _vexStatus.Hash; + if (_diff?.Hash is not null) hashes["diff"] = _diff.Hash; + if (_graphRevision?.Hash is not null) hashes["graph"] = _graphRevision.Hash; + + return new EvidenceBundle + { + AlertId = _alertId, + ArtifactId = _artifactId, + Reachability = _reachability, + CallStack = _callStack, + Provenance = _provenance, + VexStatus = _vexStatus, + Diff = _diff, + GraphRevision = _graphRevision, + Hashes = hashes.Count > 0 ? EvidenceHashSet.Compute(hashes) : EvidenceHashSet.Empty(), + CreatedAt = _timeProvider.GetUtcNow() + }; + } +} diff --git a/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundlePredicate.cs b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundlePredicate.cs new file mode 100644 index 000000000..4c4ef2f78 --- /dev/null +++ b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceBundlePredicate.cs @@ -0,0 +1,15 @@ +namespace StellaOps.Evidence.Bundle; + +/// DSSE predicate for signed evidence bundles. Predicate type: stellaops.dev/predicates/evidence-bundle@v1 +public sealed class EvidenceBundlePredicate +{ + public const string PredicateType = "stellaops.dev/predicates/evidence-bundle@v1"; + public required string BundleId { get; init; } + public required string AlertId { get; init; } + public required string ArtifactId { get; init; } + public required int CompletenessScore { get; init; } + public required EvidenceHashSet Hashes { get; init; } + public required EvidenceStatusSummary StatusSummary { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public string SchemaVersion { get; init; } = "1.0"; +} diff --git a/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceHashSet.cs b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceHashSet.cs new file mode 100644 index 000000000..2f7c8370b --- /dev/null +++ b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceHashSet.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace StellaOps.Evidence.Bundle; + +/// Content-addressed hash set for all evidence artifacts. +public sealed class EvidenceHashSet +{ + public string Algorithm { get; init; } = "SHA-256"; + public required IReadOnlyList Hashes { get; init; } + public required string CombinedHash { get; init; } + public IReadOnlyDictionary? LabeledHashes { get; init; } + + public static EvidenceHashSet Compute(IDictionary labeledHashes) + { + ArgumentNullException.ThrowIfNull(labeledHashes); + var sorted = labeledHashes.OrderBy(kvp => kvp.Key, StringComparer.Ordinal).ToList(); + var combined = string.Join(":", sorted.Select(kvp => $"{kvp.Key}={kvp.Value}")); + var hash = ComputeSha256(combined); + return new EvidenceHashSet + { + Hashes = sorted.Select(kvp => kvp.Value).ToList(), + CombinedHash = hash, + LabeledHashes = sorted.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + }; + } + + public static EvidenceHashSet Empty() => new() + { + Hashes = Array.Empty(), + CombinedHash = ComputeSha256(string.Empty), + LabeledHashes = new Dictionary() + }; + + private static string ComputeSha256(string input) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))).ToLowerInvariant(); +} diff --git a/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceStatusSummary.cs b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceStatusSummary.cs new file mode 100644 index 000000000..45e64cea1 --- /dev/null +++ b/src/__Libraries/StellaOps.Evidence.Bundle/EvidenceStatusSummary.cs @@ -0,0 +1,12 @@ +namespace StellaOps.Evidence.Bundle; + +/// Summary of evidence status for all evidence types. +public sealed class EvidenceStatusSummary +{ + public required EvidenceStatus Reachability { get; init; } + public required EvidenceStatus CallStack { get; init; } + public required EvidenceStatus Provenance { get; init; } + public required EvidenceStatus VexStatus { get; init; } + public EvidenceStatus Diff { get; init; } = EvidenceStatus.Unavailable; + public EvidenceStatus GraphRevision { get; init; } = EvidenceStatus.Unavailable; +} diff --git a/src/__Libraries/StellaOps.Evidence.Bundle/ServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Evidence.Bundle/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..8143e5a11 --- /dev/null +++ b/src/__Libraries/StellaOps.Evidence.Bundle/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Evidence.Bundle; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEvidenceBundleServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(TimeProvider.System); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddEvidenceBundleServices(this IServiceCollection services, TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(timeProvider); + services.AddSingleton(timeProvider); + services.TryAddTransient(); + return services; + } +}