Merge branch 'main' into HEAD

This commit is contained in:
StellaOps Bot
2025-12-15 09:07:59 +02:00
8 changed files with 423 additions and 0 deletions

View File

@@ -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("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.")); 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); 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 ArrayShape(TypeShape Item, int? MinItems = null) : TypeShape;
internal sealed record MapShape(TypeShape Value) : TypeShape;
internal static class SchemaBuilder internal static class SchemaBuilder
{ {
public static string Build(ObjectSpec root) public static string Build(ObjectSpec root)
@@ -512,6 +631,13 @@ internal static class SchemaBuilder
return arraySchema; return arraySchema;
case MapShape mapShape:
return new JsonObject
{
["type"] = "object",
["additionalProperties"] = BuildPropertySchema(mapShape.Value, defs, visitedObjects, visitedEnums)
};
default: default:
throw new InvalidOperationException("Unsupported property type."); throw new InvalidOperationException("Unsupported property type.");
} }
@@ -692,6 +818,9 @@ internal static class TypeScriptEmitter
case ArrayShape arrayShape: case ArrayShape arrayShape:
lines.AddRange(EmitArrayAssertion(arrayShape, accessor, pathExpression)); lines.AddRange(EmitArrayAssertion(arrayShape, accessor, pathExpression));
break; break;
case MapShape mapShape:
lines.AddRange(EmitMapAssertion(mapShape, accessor, pathExpression));
break;
default: default:
lines.Add("// Unsupported type encountered during validation."); lines.Add("// Unsupported type encountered during validation.");
break; break;
@@ -754,6 +883,29 @@ internal static class TypeScriptEmitter
return lines; return lines;
} }
private static IReadOnlyList<string> EmitMapAssertion(MapShape mapShape, string accessor, string pathExpression)
{
var lines = new List<string>
{
"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<string, unknown>)[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) private static string ToTsType(TypeShape type)
=> type switch => type switch
{ {
@@ -766,6 +918,7 @@ internal static class TypeScriptEmitter
EnumShape enumShape => enumShape.Enum.Name, EnumShape enumShape => enumShape.Enum.Name,
ObjectShape objectShape => objectShape.Object.Name, ObjectShape objectShape => objectShape.Object.Name,
ArrayShape array => $"Array<{ToTsType(array.Item)}>", ArrayShape array => $"Array<{ToTsType(array.Item)}>",
MapShape map => $"Record<string, {ToTsType(map.Value)}>",
_ => "unknown" _ => "unknown"
}; };
} }
@@ -898,6 +1051,9 @@ internal static class GoEmitter
case ArrayShape arrayShape: case ArrayShape arrayShape:
EmitArrayValidation(builder, arrayShape, accessor, path, indent); EmitArrayValidation(builder, arrayShape, accessor, path, indent);
break; 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) private static void EmitArrayItemValidation(StringBuilder builder, TypeShape itemType, string accessor, string path, string indexVar, int indent)
{ {
switch (itemType) switch (itemType)
@@ -1091,6 +1303,7 @@ internal static class GoEmitter
ObjectShape objectShape when property.Required => objectShape.Object.Name, ObjectShape objectShape when property.Required => objectShape.Object.Name,
ObjectShape objectShape => "*" + objectShape.Object.Name, ObjectShape objectShape => "*" + objectShape.Object.Name,
ArrayShape arrayShape => $"[]{GoElementType(arrayShape.Item)}", ArrayShape arrayShape => $"[]{GoElementType(arrayShape.Item)}",
MapShape mapShape => $"map[string]{GoElementType(mapShape.Value)}",
_ => "interface{}" _ => "interface{}"
}; };
} }
@@ -1104,6 +1317,7 @@ internal static class GoEmitter
EnumShape enumShape => enumShape.Enum.Name, EnumShape enumShape => enumShape.Enum.Name,
ObjectShape objectShape => objectShape.Object.Name, ObjectShape objectShape => objectShape.Object.Name,
ArrayShape nested => $"[]{GoElementType(nested.Item)}", ArrayShape nested => $"[]{GoElementType(nested.Item)}",
MapShape mapShape => $"map[string]{GoElementType(mapShape.Value)}",
_ => "interface{}" _ => "interface{}"
}; };

View File

@@ -1,15 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\StellaOps.Attestor.Types\samples\**\*.json" LinkBase="samples" CopyToOutputDirectory="PreserveNewest" /> <None Include="..\..\StellaOps.Attestor.Types\samples\**\*.json" LinkBase="samples" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\StellaOps.Attestor.Types\schemas\**\*.schema.json" LinkBase="schemas" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,53 @@
namespace StellaOps.Evidence.Bundle;
/// <summary>A complete evidence bundle for a single finding/alert. Contains all evidence required for triage decision.</summary>
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; }
/// <summary>Compute evidence completeness score (0-4 based on core evidence types).</summary>
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;
}
/// <summary>Create status summary from evidence.</summary>
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
};
/// <summary>Create DSSE predicate for signing.</summary>
public EvidenceBundlePredicate ToSigningPredicate() => new()
{
BundleId = BundleId,
AlertId = AlertId,
ArtifactId = ArtifactId,
CompletenessScore = ComputeCompletenessScore(),
Hashes = Hashes,
StatusSummary = CreateStatusSummary(),
CreatedAt = CreatedAt,
SchemaVersion = SchemaVersion
};
}

View File

@@ -0,0 +1,61 @@
namespace StellaOps.Evidence.Bundle;
/// <summary>Builder for constructing evidence bundles.</summary>
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<string, string>();
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()
};
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Evidence.Bundle;
/// <summary>DSSE predicate for signed evidence bundles. Predicate type: stellaops.dev/predicates/evidence-bundle@v1</summary>
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";
}

View File

@@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Evidence.Bundle;
/// <summary>Content-addressed hash set for all evidence artifacts.</summary>
public sealed class EvidenceHashSet
{
public string Algorithm { get; init; } = "SHA-256";
public required IReadOnlyList<string> Hashes { get; init; }
public required string CombinedHash { get; init; }
public IReadOnlyDictionary<string, string>? LabeledHashes { get; init; }
public static EvidenceHashSet Compute(IDictionary<string, string> 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<string>(),
CombinedHash = ComputeSha256(string.Empty),
LabeledHashes = new Dictionary<string, string>()
};
private static string ComputeSha256(string input) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))).ToLowerInvariant();
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Evidence.Bundle;
/// <summary>Summary of evidence status for all evidence types.</summary>
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;
}

View File

@@ -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<EvidenceBundleBuilder>();
return services;
}
public static IServiceCollection AddEvidenceBundleServices(this IServiceCollection services, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(timeProvider);
services.AddSingleton(timeProvider);
services.TryAddTransient<EvidenceBundleBuilder>();
return services;
}
}