new advisories work and features gaps work

This commit is contained in:
master
2026-01-14 18:39:19 +02:00
parent 95d5898650
commit 15aeac8e8b
148 changed files with 16731 additions and 554 deletions

View File

@@ -0,0 +1,77 @@
{
"$comment": "Sample path witness predicate payload. Sprint: SPRINT_20260112_006_ATTESTOR_path_witness_predicate (PW-ATT-002)",
"witness_id": "550e8400-e29b-41d4-a716-446655440000",
"witness_hash": "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"witness_type": "reachability_path",
"provenance": {
"graph_hash": "blake3:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
"scan_id": "660f9500-f3ac-52e5-b827-557766550111",
"run_id": "770fa600-g4bd-63f6-c938-668877660222",
"analyzer_version": "1.0.0",
"analysis_timestamp": "2026-01-14T12:00:00Z"
},
"path": {
"entrypoint": {
"fqn": "com.example.MyController.handleRequest",
"kind": "http_handler",
"location": {
"file": "src/main/java/com/example/MyController.java",
"line": 42
},
"node_hash": "sha256:entry1111111111111111111111111111111111111111111111111111111111"
},
"sink": {
"fqn": "org.apache.log4j.Logger.log",
"cve": "CVE-2021-44228",
"package": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"node_hash": "sha256:sink22222222222222222222222222222222222222222222222222222222222"
},
"steps": [
{
"index": 0,
"fqn": "com.example.MyController.handleRequest",
"call_site": "MyController.java:45",
"edge_type": "call",
"node_hash": "sha256:entry1111111111111111111111111111111111111111111111111111111111"
},
{
"index": 1,
"fqn": "com.example.LoggingService.logMessage",
"call_site": "LoggingService.java:23",
"edge_type": "call",
"node_hash": "sha256:middle333333333333333333333333333333333333333333333333333333333"
},
{
"index": 2,
"fqn": "org.apache.log4j.Logger.log",
"call_site": "Logger.java:156",
"edge_type": "sink",
"node_hash": "sha256:sink22222222222222222222222222222222222222222222222222222222222"
}
],
"hop_count": 3,
"path_hash": "sha256:pathab4567890abcdef1234567890abcdef1234567890abcdef1234567890ab",
"node_hashes": [
"sha256:entry1111111111111111111111111111111111111111111111111111111111",
"sha256:middle333333333333333333333333333333333333333333333333333333333",
"sha256:sink22222222222222222222222222222222222222222222222222222222222"
]
},
"gates": [
{
"type": "auth_required",
"location": "MyController.java:40",
"description": "Requires authenticated user via Spring Security"
}
],
"evidence": {
"graph_fragment_hash": "blake3:ijkl9012345678901234567890123456789012345678901234567890123456",
"path_hash": "blake3:mnop3456789012345678901234567890123456789012345678901234567890"
},
"evidence_uris": {
"graph": "cas://sha256:graphabc123456789012345678901234567890123456789012345678901234",
"sbom": "cas://sha256:sbomdef4567890123456789012345678901234567890123456789012345678",
"attestation": "cas://sha256:dsseghi7890123456789012345678901234567890123456789012345678901",
"rekor": "https://rekor.sigstore.dev/api/v1/log/entries/abc123def456"
}
}

View File

@@ -0,0 +1,228 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stella.ops/schemas/predicates/path-witness/v1",
"title": "StellaOps Path Witness Predicate v1",
"description": "In-toto predicate for path witness attestations proving reachability from entrypoint to vulnerable sink. Sprint: SPRINT_20260112_006_ATTESTOR_path_witness_predicate (PW-ATT-002)",
"type": "object",
"required": ["witness_id", "witness_hash", "provenance", "path"],
"properties": {
"witness_id": {
"type": "string",
"format": "uuid",
"description": "Unique identifier for this witness"
},
"witness_hash": {
"type": "string",
"pattern": "^(blake3|sha256):[a-f0-9]{64}$",
"description": "Hash of the canonical witness payload"
},
"witness_type": {
"type": "string",
"enum": ["reachability_path", "gate_proof"],
"default": "reachability_path"
},
"provenance": {
"type": "object",
"required": ["graph_hash", "analyzer_version", "analysis_timestamp"],
"properties": {
"graph_hash": {
"type": "string",
"pattern": "^(blake3|sha256):[a-f0-9]{64}$",
"description": "Hash of the source rich graph"
},
"scan_id": {
"type": "string",
"format": "uuid"
},
"run_id": {
"type": "string",
"format": "uuid"
},
"analyzer_version": {
"type": "string"
},
"analysis_timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"path": {
"type": "object",
"required": ["entrypoint", "sink", "steps", "hop_count"],
"properties": {
"entrypoint": {
"$ref": "#/$defs/pathNode"
},
"sink": {
"$ref": "#/$defs/sinkNode"
},
"steps": {
"type": "array",
"items": {
"$ref": "#/$defs/pathStep"
},
"minItems": 1
},
"hop_count": {
"type": "integer",
"minimum": 1
},
"path_hash": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$",
"description": "Canonical path hash computed from node hashes"
},
"node_hashes": {
"type": "array",
"items": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"description": "Top-K node hashes for efficient lookup"
}
}
},
"gates": {
"type": "array",
"items": {
"$ref": "#/$defs/gate"
},
"description": "Protective controls encountered along the path"
},
"evidence": {
"type": "object",
"properties": {
"graph_fragment_hash": {
"type": "string",
"pattern": "^(blake3|sha256):[a-f0-9]{64}$"
},
"path_hash": {
"type": "string",
"pattern": "^(blake3|sha256):[a-f0-9]{64}$"
}
}
},
"evidence_uris": {
"type": "object",
"properties": {
"graph": {
"type": "string",
"pattern": "^cas://sha256:[a-f0-9]{64}$"
},
"sbom": {
"type": "string",
"pattern": "^cas://sha256:[a-f0-9]{64}$"
},
"attestation": {
"type": "string",
"pattern": "^cas://sha256:[a-f0-9]{64}$"
},
"rekor": {
"type": "string",
"format": "uri"
}
}
}
},
"$defs": {
"pathNode": {
"type": "object",
"required": ["fqn"],
"properties": {
"fqn": {
"type": "string",
"description": "Fully qualified name of the node"
},
"kind": {
"type": "string",
"enum": ["http_handler", "grpc_handler", "cli_main", "scheduler", "message_handler", "other"]
},
"location": {
"$ref": "#/$defs/sourceLocation"
},
"node_hash": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
}
}
},
"sinkNode": {
"type": "object",
"required": ["fqn"],
"properties": {
"fqn": {
"type": "string"
},
"cve": {
"type": "string",
"pattern": "^CVE-\\d{4}-\\d+$"
},
"package": {
"type": "string",
"description": "Package URL (PURL) of the vulnerable package"
},
"node_hash": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
}
}
},
"pathStep": {
"type": "object",
"required": ["index", "fqn", "edge_type"],
"properties": {
"index": {
"type": "integer",
"minimum": 0
},
"fqn": {
"type": "string"
},
"call_site": {
"type": "string"
},
"edge_type": {
"type": "string",
"enum": ["call", "virtual", "static", "sink", "interface", "delegate"]
},
"node_hash": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
}
}
},
"sourceLocation": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"line": {
"type": "integer",
"minimum": 1
},
"column": {
"type": "integer",
"minimum": 1
}
}
},
"gate": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["auth_required", "feature_flag", "admin_only", "non_default_config", "rate_limited", "other"]
},
"location": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
}
}

View File

@@ -0,0 +1,69 @@
// <copyright file="PathWitnessPredicateTypes.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_006_ATTESTOR_path_witness_predicate (PW-ATT-003)
// </copyright>
namespace StellaOps.Attestor.Core;
/// <summary>
/// Constants for path witness predicate types used in attestations.
/// </summary>
public static class PathWitnessPredicateTypes
{
/// <summary>
/// Canonical predicate type for path witness attestations.
/// </summary>
public const string PathWitnessV1 = "https://stella.ops/predicates/path-witness/v1";
/// <summary>
/// Alias predicate type using @ version format.
/// </summary>
public const string PathWitnessV1Alias = "stella.ops/pathWitness@v1";
/// <summary>
/// Alias predicate type using HTTPS with camelCase.
/// </summary>
public const string PathWitnessV1HttpsAlias = "https://stella.ops/pathWitness/v1";
/// <summary>
/// All accepted predicate types for path witness attestations.
/// </summary>
public static readonly IReadOnlyList<string> AllAcceptedTypes =
[
PathWitnessV1,
PathWitnessV1Alias,
PathWitnessV1HttpsAlias
];
/// <summary>
/// Checks if the given predicate type is a path witness type.
/// </summary>
/// <param name="predicateType">The predicate type to check.</param>
/// <returns>True if it's a path witness type, false otherwise.</returns>
public static bool IsPathWitnessType(string? predicateType)
{
if (string.IsNullOrEmpty(predicateType))
{
return false;
}
return string.Equals(predicateType, PathWitnessV1, StringComparison.Ordinal)
|| string.Equals(predicateType, PathWitnessV1Alias, StringComparison.Ordinal)
|| string.Equals(predicateType, PathWitnessV1HttpsAlias, StringComparison.Ordinal);
}
/// <summary>
/// Normalizes a path witness predicate type to the canonical form.
/// </summary>
/// <param name="predicateType">The predicate type to normalize.</param>
/// <returns>The canonical predicate type, or the original if not a path witness type.</returns>
public static string NormalizeToCanonical(string predicateType)
{
if (IsPathWitnessType(predicateType))
{
return PathWitnessV1;
}
return predicateType;
}
}

View File

@@ -0,0 +1,333 @@
// <copyright file="RekorEntryEvent.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_007_ATTESTOR_rekor_entry_events (ATT-REKOR-001, ATT-REKOR-002)
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Rekor;
/// <summary>
/// Event emitted when a DSSE bundle is logged to Rekor and inclusion proof is available.
/// Used to drive policy reanalysis and evidence graph updates.
/// </summary>
public sealed record RekorEntryEvent
{
/// <summary>
/// Unique event identifier (deterministic based on bundle digest and log index).
/// </summary>
[JsonPropertyName("eventId")]
public required string EventId { get; init; }
/// <summary>
/// Event type constant.
/// </summary>
[JsonPropertyName("eventType")]
public string EventType { get; init; } = RekorEventTypes.EntryLogged;
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant")]
public required string Tenant { get; init; }
/// <summary>
/// SHA-256 digest of the DSSE bundle that was logged.
/// </summary>
[JsonPropertyName("bundleDigest")]
public required string BundleDigest { get; init; }
/// <summary>
/// Predicate type from the DSSE envelope.
/// </summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>
/// Log index where the entry was recorded.
/// </summary>
[JsonPropertyName("logIndex")]
public required long LogIndex { get; init; }
/// <summary>
/// Log ID identifying the Rekor instance.
/// </summary>
[JsonPropertyName("logId")]
public required string LogId { get; init; }
/// <summary>
/// Entry UUID in the Rekor log.
/// </summary>
[JsonPropertyName("entryUuid")]
public required string EntryUuid { get; init; }
/// <summary>
/// Unix timestamp when the entry was integrated.
/// </summary>
[JsonPropertyName("integratedTime")]
public required long IntegratedTime { get; init; }
/// <summary>
/// RFC3339 formatted integrated time for display.
/// </summary>
[JsonPropertyName("integratedTimeRfc3339")]
public required string IntegratedTimeRfc3339 { get; init; }
/// <summary>
/// URL to the Rekor entry for UI linking.
/// </summary>
[JsonPropertyName("entryUrl")]
public string? EntryUrl { get; init; }
/// <summary>
/// Whether inclusion proof was verified.
/// </summary>
[JsonPropertyName("inclusionVerified")]
public required bool InclusionVerified { get; init; }
/// <summary>
/// Policy reanalysis hints extracted from the predicate.
/// </summary>
[JsonPropertyName("reanalysisHints")]
public RekorReanalysisHints? ReanalysisHints { get; init; }
/// <summary>
/// UTC timestamp when this event was created.
/// </summary>
[JsonPropertyName("createdAtUtc")]
public required DateTimeOffset CreatedAtUtc { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("traceId")]
public string? TraceId { get; init; }
}
/// <summary>
/// Hints for policy reanalysis extracted from the logged predicate.
/// </summary>
public sealed record RekorReanalysisHints
{
/// <summary>
/// CVE identifiers affected by this attestation.
/// </summary>
[JsonPropertyName("cveIds")]
public ImmutableArray<string> CveIds { get; init; } = [];
/// <summary>
/// Product keys (PURLs) affected by this attestation.
/// </summary>
[JsonPropertyName("productKeys")]
public ImmutableArray<string> ProductKeys { get; init; } = [];
/// <summary>
/// Artifact digests covered by this attestation.
/// </summary>
[JsonPropertyName("artifactDigests")]
public ImmutableArray<string> ArtifactDigests { get; init; } = [];
/// <summary>
/// Whether this attestation may change a policy decision.
/// </summary>
[JsonPropertyName("mayAffectDecision")]
public bool MayAffectDecision { get; init; }
/// <summary>
/// Suggested reanalysis scope (e.g., "cve", "product", "artifact", "all").
/// </summary>
[JsonPropertyName("reanalysisScope")]
public string ReanalysisScope { get; init; } = "none";
}
/// <summary>
/// Well-known Rekor event types.
/// </summary>
public static class RekorEventTypes
{
/// <summary>
/// Entry was successfully logged to Rekor with verified inclusion.
/// </summary>
public const string EntryLogged = "rekor.entry.logged";
/// <summary>
/// Entry was queued for logging (offline mode).
/// </summary>
public const string EntryQueued = "rekor.entry.queued";
/// <summary>
/// Inclusion proof was verified for a previously logged entry.
/// </summary>
public const string InclusionVerified = "rekor.inclusion.verified";
/// <summary>
/// Entry logging failed.
/// </summary>
public const string EntryFailed = "rekor.entry.failed";
}
/// <summary>
/// Factory for creating deterministic Rekor entry events.
/// </summary>
public static class RekorEntryEventFactory
{
/// <summary>
/// Creates a Rekor entry logged event with deterministic event ID.
/// </summary>
public static RekorEntryEvent CreateEntryLogged(
string tenant,
string bundleDigest,
string predicateType,
RekorReceipt receipt,
DateTimeOffset createdAtUtc,
RekorReanalysisHints? reanalysisHints = null,
string? traceId = null)
{
var eventId = ComputeEventId(
RekorEventTypes.EntryLogged,
bundleDigest,
receipt.LogIndex);
var integratedTimeRfc3339 = DateTimeOffset
.FromUnixTimeSeconds(receipt.IntegratedTime)
.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
var entryUrl = !string.IsNullOrEmpty(receipt.LogUrl)
? $"{receipt.LogUrl.TrimEnd('/')}/api/v1/log/entries/{receipt.Uuid}"
: null;
return new RekorEntryEvent
{
EventId = eventId,
EventType = RekorEventTypes.EntryLogged,
Tenant = tenant,
BundleDigest = bundleDigest,
PredicateType = predicateType,
LogIndex = receipt.LogIndex,
LogId = receipt.LogId,
EntryUuid = receipt.Uuid,
IntegratedTime = receipt.IntegratedTime,
IntegratedTimeRfc3339 = integratedTimeRfc3339,
EntryUrl = entryUrl,
InclusionVerified = true,
ReanalysisHints = reanalysisHints,
CreatedAtUtc = createdAtUtc,
TraceId = traceId
};
}
/// <summary>
/// Creates a Rekor entry queued event (for offline mode).
/// </summary>
public static RekorEntryEvent CreateEntryQueued(
string tenant,
string bundleDigest,
string predicateType,
string queueId,
DateTimeOffset createdAtUtc,
RekorReanalysisHints? reanalysisHints = null,
string? traceId = null)
{
var eventId = ComputeEventId(
RekorEventTypes.EntryQueued,
bundleDigest,
0); // No log index yet
return new RekorEntryEvent
{
EventId = eventId,
EventType = RekorEventTypes.EntryQueued,
Tenant = tenant,
BundleDigest = bundleDigest,
PredicateType = predicateType,
LogIndex = -1, // Not yet logged
LogId = "pending",
EntryUuid = queueId,
IntegratedTime = 0,
IntegratedTimeRfc3339 = "pending",
EntryUrl = null,
InclusionVerified = false,
ReanalysisHints = reanalysisHints,
CreatedAtUtc = createdAtUtc,
TraceId = traceId
};
}
/// <summary>
/// Extracts reanalysis hints from a predicate based on its type.
/// </summary>
public static RekorReanalysisHints ExtractReanalysisHints(
string predicateType,
IReadOnlyList<string>? cveIds = null,
IReadOnlyList<string>? productKeys = null,
IReadOnlyList<string>? artifactDigests = null)
{
// Determine if this predicate type affects policy decisions
var mayAffect = IsDecisionAffectingPredicate(predicateType);
var scope = DetermineReanalysisScope(predicateType, cveIds, productKeys, artifactDigests);
return new RekorReanalysisHints
{
CveIds = cveIds?.ToImmutableArray() ?? [],
ProductKeys = productKeys?.ToImmutableArray() ?? [],
ArtifactDigests = artifactDigests?.ToImmutableArray() ?? [],
MayAffectDecision = mayAffect,
ReanalysisScope = scope
};
}
private static bool IsDecisionAffectingPredicate(string predicateType)
{
// Predicate types that can change policy decisions
return predicateType.Contains("vex", StringComparison.OrdinalIgnoreCase)
|| predicateType.Contains("verdict", StringComparison.OrdinalIgnoreCase)
|| predicateType.Contains("path-witness", StringComparison.OrdinalIgnoreCase)
|| predicateType.Contains("evidence", StringComparison.OrdinalIgnoreCase)
|| predicateType.Contains("override", StringComparison.OrdinalIgnoreCase);
}
private static string DetermineReanalysisScope(
string predicateType,
IReadOnlyList<string>? cveIds,
IReadOnlyList<string>? productKeys,
IReadOnlyList<string>? artifactDigests)
{
if (cveIds?.Count > 0)
{
return "cve";
}
if (productKeys?.Count > 0)
{
return "product";
}
if (artifactDigests?.Count > 0)
{
return "artifact";
}
// Default scope based on predicate type
if (predicateType.Contains("vex", StringComparison.OrdinalIgnoreCase))
{
return "product";
}
if (predicateType.Contains("sbom", StringComparison.OrdinalIgnoreCase))
{
return "artifact";
}
return "none";
}
private static string ComputeEventId(string eventType, string bundleDigest, long logIndex)
{
var input = $"{eventType}|{bundleDigest}|{logIndex}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"rekor-evt-{Convert.ToHexStringLower(hash)[..16]}";
}
}

View File

@@ -31,7 +31,13 @@ public sealed class PredicateTypeRouter : IPredicateTypeRouter
// Delta predicate types for lineage comparison (Sprint 20251228_007)
"stella.ops/vex-delta@v1",
"stella.ops/sbom-delta@v1",
"stella.ops/verdict-delta@v1"
"stella.ops/verdict-delta@v1",
// Path witness predicates (Sprint: SPRINT_20260112_006_ATTESTOR_path_witness_predicate PW-ATT-001)
// Canonical predicate type
"https://stella.ops/predicates/path-witness/v1",
// Aliases for backward compatibility
"stella.ops/pathWitness@v1",
"https://stella.ops/pathWitness/v1"
};
public PredicateTypeRouter(

View File

@@ -0,0 +1,165 @@
// -----------------------------------------------------------------------------
// VexOverridePredicate.cs
// Sprint: SPRINT_20260112_004_ATTESTOR_vex_override_predicate (ATT-VEX-001)
// Description: VEX override predicate models for attestations
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Attestor.StandardPredicates.VexOverride;
/// <summary>
/// VEX override predicate type URI.
/// </summary>
public static class VexOverridePredicateTypes
{
/// <summary>
/// The predicate type URI for VEX override attestations.
/// </summary>
public const string PredicateTypeUri = "https://stellaops.dev/attestations/vex-override/v1";
}
/// <summary>
/// VEX override decision indicating the operator's assessment.
/// </summary>
public enum VexOverrideDecision
{
/// <summary>
/// The vulnerability does not affect this artifact/configuration.
/// </summary>
NotAffected = 1,
/// <summary>
/// The vulnerability is mitigated by compensating controls.
/// </summary>
Mitigated = 2,
/// <summary>
/// The vulnerability has been accepted as a known risk.
/// </summary>
Accepted = 3,
/// <summary>
/// The vulnerability assessment is still under investigation.
/// </summary>
UnderInvestigation = 4
}
/// <summary>
/// VEX override predicate payload for in-toto/DSSE attestations.
/// Represents an operator decision to override or annotate a vulnerability status.
/// </summary>
public sealed record VexOverridePredicate
{
/// <summary>
/// The predicate type URI.
/// </summary>
public string PredicateType { get; init; } = VexOverridePredicateTypes.PredicateTypeUri;
/// <summary>
/// Artifact digest this override applies to (e.g., sha256:abc123...).
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Vulnerability ID being overridden (e.g., CVE-2024-12345).
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// The operator's decision.
/// </summary>
public required VexOverrideDecision Decision { get; init; }
/// <summary>
/// Human-readable justification for the decision.
/// </summary>
public required string Justification { get; init; }
/// <summary>
/// UTC timestamp when the decision was made.
/// </summary>
public required DateTimeOffset DecisionTime { get; init; }
/// <summary>
/// Identifier of the operator/user who made the decision.
/// </summary>
public required string OperatorId { get; init; }
/// <summary>
/// Optional expiration time for this override.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Evidence references supporting this decision.
/// </summary>
public ImmutableArray<EvidenceReference> EvidenceRefs { get; init; } = ImmutableArray<EvidenceReference>.Empty;
/// <summary>
/// Tool information that created this predicate.
/// </summary>
public ToolInfo? Tool { get; init; }
/// <summary>
/// Rule digest that triggered or was overridden by this decision.
/// </summary>
public string? RuleDigest { get; init; }
/// <summary>
/// Hash of the reachability trace at decision time, if applicable.
/// </summary>
public string? TraceHash { get; init; }
/// <summary>
/// Additional metadata as key-value pairs.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Reference to supporting evidence for a VEX override decision.
/// </summary>
public sealed record EvidenceReference
{
/// <summary>
/// Type of evidence (e.g., "document", "ticket", "scan_report").
/// </summary>
public required string Type { get; init; }
/// <summary>
/// URI or identifier for the evidence.
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Optional digest of the evidence content.
/// </summary>
public string? Digest { get; init; }
/// <summary>
/// Optional description of the evidence.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Tool information for the predicate.
/// </summary>
public sealed record ToolInfo
{
/// <summary>
/// Tool name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Tool version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Optional tool vendor.
/// </summary>
public string? Vendor { get; init; }
}

View File

@@ -0,0 +1,333 @@
// -----------------------------------------------------------------------------
// VexOverridePredicateBuilder.cs
// Sprint: SPRINT_20260112_004_ATTESTOR_vex_override_predicate (ATT-VEX-002)
// Description: Builder for VEX override predicate payloads with DSSE envelope creation
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace StellaOps.Attestor.StandardPredicates.VexOverride;
/// <summary>
/// Builder for creating VEX override predicate payloads.
/// Produces RFC 8785 canonical JSON for deterministic hashing.
/// </summary>
public sealed class VexOverridePredicateBuilder
{
private string? _artifactDigest;
private string? _vulnerabilityId;
private VexOverrideDecision? _decision;
private string? _justification;
private DateTimeOffset? _decisionTime;
private string? _operatorId;
private DateTimeOffset? _expiresAt;
private readonly List<EvidenceReference> _evidenceRefs = new();
private ToolInfo? _tool;
private string? _ruleDigest;
private string? _traceHash;
private readonly Dictionary<string, string> _metadata = new(StringComparer.Ordinal);
/// <summary>
/// Sets the artifact digest this override applies to.
/// </summary>
public VexOverridePredicateBuilder WithArtifactDigest(string artifactDigest)
{
_artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest));
return this;
}
/// <summary>
/// Sets the vulnerability ID being overridden.
/// </summary>
public VexOverridePredicateBuilder WithVulnerabilityId(string vulnerabilityId)
{
_vulnerabilityId = vulnerabilityId ?? throw new ArgumentNullException(nameof(vulnerabilityId));
return this;
}
/// <summary>
/// Sets the operator's decision.
/// </summary>
public VexOverridePredicateBuilder WithDecision(VexOverrideDecision decision)
{
_decision = decision;
return this;
}
/// <summary>
/// Sets the justification for the decision.
/// </summary>
public VexOverridePredicateBuilder WithJustification(string justification)
{
_justification = justification ?? throw new ArgumentNullException(nameof(justification));
return this;
}
/// <summary>
/// Sets the decision time.
/// </summary>
public VexOverridePredicateBuilder WithDecisionTime(DateTimeOffset decisionTime)
{
_decisionTime = decisionTime;
return this;
}
/// <summary>
/// Sets the operator ID.
/// </summary>
public VexOverridePredicateBuilder WithOperatorId(string operatorId)
{
_operatorId = operatorId ?? throw new ArgumentNullException(nameof(operatorId));
return this;
}
/// <summary>
/// Sets the optional expiration time.
/// </summary>
public VexOverridePredicateBuilder WithExpiresAt(DateTimeOffset expiresAt)
{
_expiresAt = expiresAt;
return this;
}
/// <summary>
/// Adds an evidence reference.
/// </summary>
public VexOverridePredicateBuilder AddEvidenceRef(EvidenceReference evidenceRef)
{
_evidenceRefs.Add(evidenceRef ?? throw new ArgumentNullException(nameof(evidenceRef)));
return this;
}
/// <summary>
/// Adds an evidence reference.
/// </summary>
public VexOverridePredicateBuilder AddEvidenceRef(string type, string uri, string? digest = null, string? description = null)
{
_evidenceRefs.Add(new EvidenceReference
{
Type = type,
Uri = uri,
Digest = digest,
Description = description
});
return this;
}
/// <summary>
/// Sets the tool information.
/// </summary>
public VexOverridePredicateBuilder WithTool(string name, string version, string? vendor = null)
{
_tool = new ToolInfo
{
Name = name,
Version = version,
Vendor = vendor
};
return this;
}
/// <summary>
/// Sets the rule digest.
/// </summary>
public VexOverridePredicateBuilder WithRuleDigest(string ruleDigest)
{
_ruleDigest = ruleDigest;
return this;
}
/// <summary>
/// Sets the trace hash.
/// </summary>
public VexOverridePredicateBuilder WithTraceHash(string traceHash)
{
_traceHash = traceHash;
return this;
}
/// <summary>
/// Adds metadata.
/// </summary>
public VexOverridePredicateBuilder WithMetadata(string key, string value)
{
_metadata[key] = value;
return this;
}
/// <summary>
/// Builds the VEX override predicate.
/// </summary>
public VexOverridePredicate Build()
{
if (string.IsNullOrWhiteSpace(_artifactDigest))
{
throw new InvalidOperationException("ArtifactDigest is required.");
}
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
{
throw new InvalidOperationException("VulnerabilityId is required.");
}
if (_decision is null)
{
throw new InvalidOperationException("Decision is required.");
}
if (string.IsNullOrWhiteSpace(_justification))
{
throw new InvalidOperationException("Justification is required.");
}
if (_decisionTime is null)
{
throw new InvalidOperationException("DecisionTime is required.");
}
if (string.IsNullOrWhiteSpace(_operatorId))
{
throw new InvalidOperationException("OperatorId is required.");
}
return new VexOverridePredicate
{
ArtifactDigest = _artifactDigest,
VulnerabilityId = _vulnerabilityId,
Decision = _decision.Value,
Justification = _justification,
DecisionTime = _decisionTime.Value,
OperatorId = _operatorId,
ExpiresAt = _expiresAt,
EvidenceRefs = _evidenceRefs.ToImmutableArray(),
Tool = _tool,
RuleDigest = _ruleDigest,
TraceHash = _traceHash,
Metadata = _metadata.ToImmutableDictionary()
};
}
/// <summary>
/// Builds and serializes the predicate to canonical JSON.
/// </summary>
public string BuildCanonicalJson()
{
var predicate = Build();
var json = SerializeToJson(predicate);
return JsonCanonicalizer.Canonicalize(json);
}
/// <summary>
/// Builds and serializes the predicate to JSON bytes.
/// </summary>
public byte[] BuildJsonBytes()
{
var canonicalJson = BuildCanonicalJson();
return Encoding.UTF8.GetBytes(canonicalJson);
}
private static string SerializeToJson(VexOverridePredicate predicate)
{
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
writer.WriteStartObject();
// Write fields in deterministic order (alphabetical)
writer.WriteString("artifactDigest", predicate.ArtifactDigest);
writer.WriteString("decision", DecisionToString(predicate.Decision));
writer.WriteString("decisionTime", predicate.DecisionTime.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
// evidenceRefs (only if non-empty)
if (predicate.EvidenceRefs.Length > 0)
{
writer.WriteStartArray("evidenceRefs");
foreach (var evidenceRef in predicate.EvidenceRefs.OrderBy(e => e.Type, StringComparer.Ordinal)
.ThenBy(e => e.Uri, StringComparer.Ordinal))
{
writer.WriteStartObject();
if (evidenceRef.Description is not null)
{
writer.WriteString("description", evidenceRef.Description);
}
if (evidenceRef.Digest is not null)
{
writer.WriteString("digest", evidenceRef.Digest);
}
writer.WriteString("type", evidenceRef.Type);
writer.WriteString("uri", evidenceRef.Uri);
writer.WriteEndObject();
}
writer.WriteEndArray();
}
// expiresAt (optional)
if (predicate.ExpiresAt.HasValue)
{
writer.WriteString("expiresAt", predicate.ExpiresAt.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
}
writer.WriteString("justification", predicate.Justification);
// metadata (only if non-empty)
if (predicate.Metadata.Count > 0)
{
writer.WriteStartObject("metadata");
foreach (var kvp in predicate.Metadata.OrderBy(k => k.Key, StringComparer.Ordinal))
{
writer.WriteString(kvp.Key, kvp.Value);
}
writer.WriteEndObject();
}
writer.WriteString("operatorId", predicate.OperatorId);
writer.WriteString("predicateType", predicate.PredicateType);
// ruleDigest (optional)
if (predicate.RuleDigest is not null)
{
writer.WriteString("ruleDigest", predicate.RuleDigest);
}
// tool (optional)
if (predicate.Tool is not null)
{
writer.WriteStartObject("tool");
writer.WriteString("name", predicate.Tool.Name);
if (predicate.Tool.Vendor is not null)
{
writer.WriteString("vendor", predicate.Tool.Vendor);
}
writer.WriteString("version", predicate.Tool.Version);
writer.WriteEndObject();
}
// traceHash (optional)
if (predicate.TraceHash is not null)
{
writer.WriteString("traceHash", predicate.TraceHash);
}
writer.WriteString("vulnerabilityId", predicate.VulnerabilityId);
writer.WriteEndObject();
writer.Flush();
return Encoding.UTF8.GetString(stream.ToArray());
}
private static string DecisionToString(VexOverrideDecision decision)
{
return decision switch
{
VexOverrideDecision.NotAffected => "not_affected",
VexOverrideDecision.Mitigated => "mitigated",
VexOverrideDecision.Accepted => "accepted",
VexOverrideDecision.UnderInvestigation => "under_investigation",
_ => throw new ArgumentOutOfRangeException(nameof(decision))
};
}
}

View File

@@ -0,0 +1,438 @@
// -----------------------------------------------------------------------------
// VexOverridePredicateParser.cs
// Sprint: SPRINT_20260112_004_ATTESTOR_vex_override_predicate (ATT-VEX-002)
// Description: Parser for VEX override predicate payloads
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Attestor.StandardPredicates.VexOverride;
/// <summary>
/// Parser for VEX override predicate payloads.
/// </summary>
public sealed class VexOverridePredicateParser : IPredicateParser
{
private readonly ILogger<VexOverridePredicateParser> _logger;
/// <inheritdoc/>
public string PredicateType => VexOverridePredicateTypes.PredicateTypeUri;
/// <summary>
/// Initializes a new instance of the <see cref="VexOverridePredicateParser"/> class.
/// </summary>
public VexOverridePredicateParser(ILogger<VexOverridePredicateParser> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public PredicateParseResult Parse(JsonElement predicatePayload)
{
var errors = new List<ValidationError>();
var warnings = new List<ValidationWarning>();
// Validate required fields
if (!predicatePayload.TryGetProperty("artifactDigest", out var artifactDigestEl) ||
string.IsNullOrWhiteSpace(artifactDigestEl.GetString()))
{
errors.Add(new ValidationError("$.artifactDigest", "Missing required field: artifactDigest", "VEX_MISSING_ARTIFACT_DIGEST"));
}
if (!predicatePayload.TryGetProperty("vulnerabilityId", out var vulnIdEl) ||
string.IsNullOrWhiteSpace(vulnIdEl.GetString()))
{
errors.Add(new ValidationError("$.vulnerabilityId", "Missing required field: vulnerabilityId", "VEX_MISSING_VULN_ID"));
}
if (!predicatePayload.TryGetProperty("decision", out var decisionEl))
{
errors.Add(new ValidationError("$.decision", "Missing required field: decision", "VEX_MISSING_DECISION"));
}
else
{
ValidateDecision(decisionEl, errors);
}
if (!predicatePayload.TryGetProperty("justification", out var justificationEl) ||
string.IsNullOrWhiteSpace(justificationEl.GetString()))
{
errors.Add(new ValidationError("$.justification", "Missing required field: justification", "VEX_MISSING_JUSTIFICATION"));
}
if (!predicatePayload.TryGetProperty("decisionTime", out var decisionTimeEl))
{
errors.Add(new ValidationError("$.decisionTime", "Missing required field: decisionTime", "VEX_MISSING_DECISION_TIME"));
}
else
{
ValidateTimestamp(decisionTimeEl, "$.decisionTime", errors);
}
if (!predicatePayload.TryGetProperty("operatorId", out var operatorIdEl) ||
string.IsNullOrWhiteSpace(operatorIdEl.GetString()))
{
errors.Add(new ValidationError("$.operatorId", "Missing required field: operatorId", "VEX_MISSING_OPERATOR_ID"));
}
// Validate optional fields
if (predicatePayload.TryGetProperty("expiresAt", out var expiresAtEl))
{
ValidateTimestamp(expiresAtEl, "$.expiresAt", errors);
}
if (predicatePayload.TryGetProperty("evidenceRefs", out var evidenceRefsEl))
{
ValidateEvidenceRefs(evidenceRefsEl, errors, warnings);
}
if (predicatePayload.TryGetProperty("tool", out var toolEl))
{
ValidateTool(toolEl, errors);
}
_logger.LogDebug(
"Parsed VEX override predicate with {ErrorCount} errors, {WarningCount} warnings",
errors.Count, warnings.Count);
// Extract metadata
var metadata = new PredicateMetadata
{
PredicateType = PredicateType,
Format = "vex-override",
Version = "1.0",
Properties = ExtractMetadata(predicatePayload)
};
return new PredicateParseResult
{
IsValid = errors.Count == 0,
Metadata = metadata,
Errors = errors,
Warnings = warnings
};
}
/// <inheritdoc/>
public SbomExtractionResult? ExtractSbom(JsonElement predicatePayload)
{
// VEX override is not an SBOM
_logger.LogDebug("VEX override predicate does not contain SBOM content (this is expected)");
return null;
}
/// <summary>
/// Parses a VEX override predicate payload into the typed model.
/// </summary>
public VexOverridePredicate? ParsePredicate(JsonElement predicatePayload)
{
try
{
var artifactDigest = predicatePayload.GetProperty("artifactDigest").GetString()!;
var vulnerabilityId = predicatePayload.GetProperty("vulnerabilityId").GetString()!;
var decision = ParseDecision(predicatePayload.GetProperty("decision"));
var justification = predicatePayload.GetProperty("justification").GetString()!;
var decisionTime = DateTimeOffset.Parse(
predicatePayload.GetProperty("decisionTime").GetString()!,
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind);
var operatorId = predicatePayload.GetProperty("operatorId").GetString()!;
DateTimeOffset? expiresAt = null;
if (predicatePayload.TryGetProperty("expiresAt", out var expiresAtEl) &&
expiresAtEl.ValueKind == JsonValueKind.String)
{
expiresAt = DateTimeOffset.Parse(
expiresAtEl.GetString()!,
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind);
}
var evidenceRefs = ImmutableArray<EvidenceReference>.Empty;
if (predicatePayload.TryGetProperty("evidenceRefs", out var evidenceRefsEl) &&
evidenceRefsEl.ValueKind == JsonValueKind.Array)
{
evidenceRefs = ParseEvidenceRefs(evidenceRefsEl);
}
ToolInfo? tool = null;
if (predicatePayload.TryGetProperty("tool", out var toolEl) &&
toolEl.ValueKind == JsonValueKind.Object)
{
tool = ParseTool(toolEl);
}
string? ruleDigest = null;
if (predicatePayload.TryGetProperty("ruleDigest", out var ruleDigestEl) &&
ruleDigestEl.ValueKind == JsonValueKind.String)
{
ruleDigest = ruleDigestEl.GetString();
}
string? traceHash = null;
if (predicatePayload.TryGetProperty("traceHash", out var traceHashEl) &&
traceHashEl.ValueKind == JsonValueKind.String)
{
traceHash = traceHashEl.GetString();
}
var metadata = ImmutableDictionary<string, string>.Empty;
if (predicatePayload.TryGetProperty("metadata", out var metadataEl) &&
metadataEl.ValueKind == JsonValueKind.Object)
{
metadata = ParseMetadata(metadataEl);
}
return new VexOverridePredicate
{
ArtifactDigest = artifactDigest,
VulnerabilityId = vulnerabilityId,
Decision = decision,
Justification = justification,
DecisionTime = decisionTime,
OperatorId = operatorId,
ExpiresAt = expiresAt,
EvidenceRefs = evidenceRefs,
Tool = tool,
RuleDigest = ruleDigest,
TraceHash = traceHash,
Metadata = metadata
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse VEX override predicate");
return null;
}
}
private void ValidateDecision(JsonElement decisionEl, List<ValidationError> errors)
{
var validDecisions = new[] { "not_affected", "mitigated", "accepted", "under_investigation" };
if (decisionEl.ValueKind == JsonValueKind.String)
{
var decision = decisionEl.GetString();
if (string.IsNullOrWhiteSpace(decision) || !validDecisions.Contains(decision, StringComparer.OrdinalIgnoreCase))
{
errors.Add(new ValidationError(
"$.decision",
$"Invalid decision value. Must be one of: {string.Join(", ", validDecisions)}",
"VEX_INVALID_DECISION"));
}
}
else if (decisionEl.ValueKind == JsonValueKind.Number)
{
var value = decisionEl.GetInt32();
if (value < 1 || value > 4)
{
errors.Add(new ValidationError(
"$.decision",
"Invalid decision value. Numeric values must be 1-4.",
"VEX_INVALID_DECISION"));
}
}
else
{
errors.Add(new ValidationError(
"$.decision",
"Decision must be a string or number",
"VEX_INVALID_DECISION_TYPE"));
}
}
private static void ValidateTimestamp(JsonElement timestampEl, string path, List<ValidationError> errors)
{
if (timestampEl.ValueKind != JsonValueKind.String)
{
errors.Add(new ValidationError(path, "Timestamp must be a string", "VEX_INVALID_TIMESTAMP_TYPE"));
return;
}
var value = timestampEl.GetString();
if (!DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out _))
{
errors.Add(new ValidationError(path, "Invalid ISO 8601 timestamp format", "VEX_INVALID_TIMESTAMP"));
}
}
private static void ValidateEvidenceRefs(
JsonElement evidenceRefsEl,
List<ValidationError> errors,
List<ValidationWarning> warnings)
{
if (evidenceRefsEl.ValueKind != JsonValueKind.Array)
{
errors.Add(new ValidationError("$.evidenceRefs", "evidenceRefs must be an array", "VEX_INVALID_EVIDENCE_REFS"));
return;
}
var index = 0;
foreach (var refEl in evidenceRefsEl.EnumerateArray())
{
var path = $"$.evidenceRefs[{index}]";
if (!refEl.TryGetProperty("type", out var typeEl) ||
string.IsNullOrWhiteSpace(typeEl.GetString()))
{
errors.Add(new ValidationError($"{path}.type", "Missing required field: type", "VEX_MISSING_EVIDENCE_TYPE"));
}
if (!refEl.TryGetProperty("uri", out var uriEl) ||
string.IsNullOrWhiteSpace(uriEl.GetString()))
{
errors.Add(new ValidationError($"{path}.uri", "Missing required field: uri", "VEX_MISSING_EVIDENCE_URI"));
}
index++;
}
if (index == 0)
{
warnings.Add(new ValidationWarning("$.evidenceRefs", "No evidence references provided", "VEX_NO_EVIDENCE"));
}
}
private static void ValidateTool(JsonElement toolEl, List<ValidationError> errors)
{
if (toolEl.ValueKind != JsonValueKind.Object)
{
errors.Add(new ValidationError("$.tool", "tool must be an object", "VEX_INVALID_TOOL"));
return;
}
if (!toolEl.TryGetProperty("name", out var nameEl) ||
string.IsNullOrWhiteSpace(nameEl.GetString()))
{
errors.Add(new ValidationError("$.tool.name", "Missing required field: tool.name", "VEX_MISSING_TOOL_NAME"));
}
if (!toolEl.TryGetProperty("version", out var versionEl) ||
string.IsNullOrWhiteSpace(versionEl.GetString()))
{
errors.Add(new ValidationError("$.tool.version", "Missing required field: tool.version", "VEX_MISSING_TOOL_VERSION"));
}
}
private static VexOverrideDecision ParseDecision(JsonElement decisionEl)
{
if (decisionEl.ValueKind == JsonValueKind.Number)
{
return (VexOverrideDecision)decisionEl.GetInt32();
}
var value = decisionEl.GetString()?.ToLowerInvariant();
return value switch
{
"not_affected" => VexOverrideDecision.NotAffected,
"mitigated" => VexOverrideDecision.Mitigated,
"accepted" => VexOverrideDecision.Accepted,
"under_investigation" => VexOverrideDecision.UnderInvestigation,
_ => throw new ArgumentException($"Invalid decision value: {value}")
};
}
private static ImmutableArray<EvidenceReference> ParseEvidenceRefs(JsonElement evidenceRefsEl)
{
var builder = ImmutableArray.CreateBuilder<EvidenceReference>();
foreach (var refEl in evidenceRefsEl.EnumerateArray())
{
var type = refEl.GetProperty("type").GetString()!;
var uri = refEl.GetProperty("uri").GetString()!;
string? digest = null;
if (refEl.TryGetProperty("digest", out var digestEl) &&
digestEl.ValueKind == JsonValueKind.String)
{
digest = digestEl.GetString();
}
string? description = null;
if (refEl.TryGetProperty("description", out var descEl) &&
descEl.ValueKind == JsonValueKind.String)
{
description = descEl.GetString();
}
builder.Add(new EvidenceReference
{
Type = type,
Uri = uri,
Digest = digest,
Description = description
});
}
return builder.ToImmutable();
}
private static ToolInfo ParseTool(JsonElement toolEl)
{
var name = toolEl.GetProperty("name").GetString()!;
var version = toolEl.GetProperty("version").GetString()!;
string? vendor = null;
if (toolEl.TryGetProperty("vendor", out var vendorEl) &&
vendorEl.ValueKind == JsonValueKind.String)
{
vendor = vendorEl.GetString();
}
return new ToolInfo
{
Name = name,
Version = version,
Vendor = vendor
};
}
private static ImmutableDictionary<string, string> ParseMetadata(JsonElement metadataEl)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>();
foreach (var prop in metadataEl.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
if (prop.Value.ValueKind == JsonValueKind.String)
{
builder[prop.Name] = prop.Value.GetString()!;
}
}
return builder.ToImmutable();
}
private static ImmutableDictionary<string, string> ExtractMetadata(JsonElement predicatePayload)
{
var props = ImmutableDictionary.CreateBuilder<string, string>();
if (predicatePayload.TryGetProperty("vulnerabilityId", out var vulnIdEl) &&
vulnIdEl.ValueKind == JsonValueKind.String)
{
props["vulnerabilityId"] = vulnIdEl.GetString()!;
}
if (predicatePayload.TryGetProperty("decision", out var decisionEl))
{
if (decisionEl.ValueKind == JsonValueKind.String)
{
props["decision"] = decisionEl.GetString()!;
}
else if (decisionEl.ValueKind == JsonValueKind.Number)
{
props["decision"] = ((VexOverrideDecision)decisionEl.GetInt32()).ToString().ToLowerInvariant();
}
}
if (predicatePayload.TryGetProperty("operatorId", out var operatorIdEl) &&
operatorIdEl.ValueKind == JsonValueKind.String)
{
props["operatorId"] = operatorIdEl.GetString()!;
}
return props.ToImmutable();
}
}

View File

@@ -4,6 +4,7 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Spdx3.Model;
using StellaOps.Spdx3.Model.Build;
using Xunit;
@@ -95,7 +96,7 @@ public sealed class BuildAttestationMapperTests
BuildStartTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
BuildEndTime = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero),
ConfigSourceUri = ImmutableArray.Create("https://github.com/stellaops/app"),
ConfigSourceDigest = ImmutableArray.Create(Spdx3Hash.Sha256("abc123")),
ConfigSourceDigest = ImmutableArray.Create(new Spdx3BuildHash { Algorithm = "sha256", HashValue = "abc123" }),
ConfigSourceEntrypoint = ImmutableArray.Create("Dockerfile"),
Environment = ImmutableDictionary<string, string>.Empty.Add("CI", "true"),
Parameter = ImmutableDictionary<string, string>.Empty.Add("target", "release")

View File

@@ -14,7 +14,7 @@ public sealed class BinaryDiffPredicateBuilderTests
public void Build_RequiresSubject()
{
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "1.0.0" });
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.TestTimeProvider);
builder.WithInputs(
new BinaryDiffImageReference { Digest = "sha256:base" },
@@ -30,7 +30,7 @@ public sealed class BinaryDiffPredicateBuilderTests
public void Build_RequiresInputs()
{
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "1.0.0" });
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.TestTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaa");
@@ -44,7 +44,7 @@ public sealed class BinaryDiffPredicateBuilderTests
public void Build_SortsFindingsAndSections()
{
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "1.0.0" });
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.TestTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaa")
.WithInputs(
@@ -106,7 +106,7 @@ public sealed class BinaryDiffPredicateBuilderTests
AnalyzedSections = [".z", ".a"]
});
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.TestTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaa")
.WithInputs(
new BinaryDiffImageReference { Digest = "sha256:base" },
@@ -116,7 +116,7 @@ public sealed class BinaryDiffPredicateBuilderTests
predicate.Metadata.ToolVersion.Should().Be("2.0.0");
predicate.Metadata.ConfigDigest.Should().Be("sha256:cfg");
predicate.Metadata.AnalysisTimestamp.Should().Be(BinaryDiffTestData.FixedTimeProvider.GetUtcNow());
predicate.Metadata.AnalysisTimestamp.Should().Be(BinaryDiffTestData.TestTimeProvider.GetUtcNow());
predicate.Metadata.AnalyzedSections.Should().Equal(".a", ".z");
}
}

View File

@@ -8,7 +8,7 @@ namespace StellaOps.Attestor.StandardPredicates.Tests.BinaryDiff;
internal static class BinaryDiffTestData
{
internal static readonly TimeProvider FixedTimeProvider =
internal static readonly TimeProvider TestTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
internal static BinaryDiffPredicate CreatePredicate()
@@ -20,7 +20,7 @@ internal static class BinaryDiffTestData
AnalyzedSections = [".text", ".rodata", ".data"]
});
var builder = new BinaryDiffPredicateBuilder(options, FixedTimeProvider);
var builder = new BinaryDiffPredicateBuilder(options, TestTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaaaaaa")
.WithInputs(
new BinaryDiffImageReference

View File

@@ -0,0 +1,225 @@
// -----------------------------------------------------------------------------
// VexOverridePredicateBuilderTests.cs
// Sprint: SPRINT_20260112_004_ATTESTOR_vex_override_predicate (ATT-VEX-002)
// Description: Tests for VEX override predicate builder
// -----------------------------------------------------------------------------
using System.Text.Json;
using StellaOps.Attestor.StandardPredicates.VexOverride;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.StandardPredicates.Tests.VexOverride;
public sealed class VexOverridePredicateBuilderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithRequiredFields_CreatesPredicate()
{
var decisionTime = new DateTimeOffset(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
var predicate = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.NotAffected)
.WithJustification("Component is not in use")
.WithDecisionTime(decisionTime)
.WithOperatorId("user@example.com")
.Build();
Assert.Equal("sha256:abc123", predicate.ArtifactDigest);
Assert.Equal("CVE-2024-12345", predicate.VulnerabilityId);
Assert.Equal(VexOverrideDecision.NotAffected, predicate.Decision);
Assert.Equal("Component is not in use", predicate.Justification);
Assert.Equal(decisionTime, predicate.DecisionTime);
Assert.Equal("user@example.com", predicate.OperatorId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_MissingArtifactDigest_Throws()
{
var builder = new VexOverridePredicateBuilder()
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.NotAffected)
.WithJustification("Test")
.WithDecisionTime(DateTimeOffset.UtcNow)
.WithOperatorId("user@example.com");
Assert.Throws<InvalidOperationException>(() => builder.Build());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithEvidenceRefs_AddsToList()
{
var predicate = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.Mitigated)
.WithJustification("Compensating control")
.WithDecisionTime(DateTimeOffset.UtcNow)
.WithOperatorId("user@example.com")
.AddEvidenceRef("document", "https://example.com/doc", "sha256:def456", "Design doc")
.AddEvidenceRef(new EvidenceReference
{
Type = "ticket",
Uri = "https://jira.example.com/PROJ-123"
})
.Build();
Assert.Equal(2, predicate.EvidenceRefs.Length);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithTool_SetsTool()
{
var predicate = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.Accepted)
.WithJustification("Accepted risk")
.WithDecisionTime(DateTimeOffset.UtcNow)
.WithOperatorId("user@example.com")
.WithTool("StellaOps", "1.0.0", "StellaOps Inc")
.Build();
Assert.NotNull(predicate.Tool);
Assert.Equal("StellaOps", predicate.Tool.Name);
Assert.Equal("1.0.0", predicate.Tool.Version);
Assert.Equal("StellaOps Inc", predicate.Tool.Vendor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithMetadata_AddsMetadata()
{
var predicate = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.NotAffected)
.WithJustification("Test")
.WithDecisionTime(DateTimeOffset.UtcNow)
.WithOperatorId("user@example.com")
.WithMetadata("tenant", "acme-corp")
.WithMetadata("environment", "production")
.Build();
Assert.Equal(2, predicate.Metadata.Count);
Assert.Equal("acme-corp", predicate.Metadata["tenant"]);
Assert.Equal("production", predicate.Metadata["environment"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildCanonicalJson_ProducesDeterministicOutput()
{
var decisionTime = new DateTimeOffset(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
var json1 = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.NotAffected)
.WithJustification("Test")
.WithDecisionTime(decisionTime)
.WithOperatorId("user@example.com")
.BuildCanonicalJson();
var json2 = new VexOverridePredicateBuilder()
.WithOperatorId("user@example.com") // Different order
.WithDecisionTime(decisionTime)
.WithJustification("Test")
.WithDecision(VexOverrideDecision.NotAffected)
.WithVulnerabilityId("CVE-2024-12345")
.WithArtifactDigest("sha256:abc123")
.BuildCanonicalJson();
Assert.Equal(json1, json2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildCanonicalJson_HasSortedKeys()
{
var decisionTime = new DateTimeOffset(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
var json = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.NotAffected)
.WithJustification("Test")
.WithDecisionTime(decisionTime)
.WithOperatorId("user@example.com")
.BuildCanonicalJson();
using var document = JsonDocument.Parse(json);
var keys = document.RootElement.EnumerateObject().Select(p => p.Name).ToList();
// Verify keys are alphabetically sorted
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
Assert.Equal(sortedKeys, keys);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildJsonBytes_ReturnsUtf8Bytes()
{
var decisionTime = new DateTimeOffset(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
var bytes = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.NotAffected)
.WithJustification("Test")
.WithDecisionTime(decisionTime)
.WithOperatorId("user@example.com")
.BuildJsonBytes();
Assert.NotEmpty(bytes);
var json = System.Text.Encoding.UTF8.GetString(bytes);
using var document = JsonDocument.Parse(json);
Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithExpiresAt_SetsExpiration()
{
var decisionTime = new DateTimeOffset(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
var expiresAt = new DateTimeOffset(2026, 4, 14, 10, 0, 0, TimeSpan.Zero);
var predicate = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.Accepted)
.WithJustification("Temporary acceptance")
.WithDecisionTime(decisionTime)
.WithOperatorId("user@example.com")
.WithExpiresAt(expiresAt)
.Build();
Assert.Equal(expiresAt, predicate.ExpiresAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithRuleDigestAndTraceHash_SetsValues()
{
var predicate = new VexOverridePredicateBuilder()
.WithArtifactDigest("sha256:abc123")
.WithVulnerabilityId("CVE-2024-12345")
.WithDecision(VexOverrideDecision.NotAffected)
.WithJustification("Test")
.WithDecisionTime(DateTimeOffset.UtcNow)
.WithOperatorId("user@example.com")
.WithRuleDigest("sha256:rule123")
.WithTraceHash("sha256:trace456")
.Build();
Assert.Equal("sha256:rule123", predicate.RuleDigest);
Assert.Equal("sha256:trace456", predicate.TraceHash);
}
}

View File

@@ -0,0 +1,255 @@
// -----------------------------------------------------------------------------
// VexOverridePredicateParserTests.cs
// Sprint: SPRINT_20260112_004_ATTESTOR_vex_override_predicate (ATT-VEX-002)
// Description: Tests for VEX override predicate parsing
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.StandardPredicates.VexOverride;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.StandardPredicates.Tests.VexOverride;
public sealed class VexOverridePredicateParserTests
{
private readonly VexOverridePredicateParser _parser;
public VexOverridePredicateParserTests()
{
_parser = new VexOverridePredicateParser(NullLogger<VexOverridePredicateParser>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PredicateType_ReturnsCorrectUri()
{
Assert.Equal(VexOverridePredicateTypes.PredicateTypeUri, _parser.PredicateType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parse_ValidPredicate_ReturnsValid()
{
var json = """
{
"artifactDigest": "sha256:abc123",
"vulnerabilityId": "CVE-2024-12345",
"decision": "not_affected",
"justification": "Component is not in use",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com"
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parse_MissingArtifactDigest_ReturnsError()
{
var json = """
{
"vulnerabilityId": "CVE-2024-12345",
"decision": "not_affected",
"justification": "Component is not in use",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com"
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Code == "VEX_MISSING_ARTIFACT_DIGEST");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parse_MissingVulnerabilityId_ReturnsError()
{
var json = """
{
"artifactDigest": "sha256:abc123",
"decision": "not_affected",
"justification": "Component is not in use",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com"
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Code == "VEX_MISSING_VULN_ID");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parse_InvalidDecision_ReturnsError()
{
var json = """
{
"artifactDigest": "sha256:abc123",
"vulnerabilityId": "CVE-2024-12345",
"decision": "invalid_decision",
"justification": "Component is not in use",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com"
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Code == "VEX_INVALID_DECISION");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("not_affected", VexOverrideDecision.NotAffected)]
[InlineData("mitigated", VexOverrideDecision.Mitigated)]
[InlineData("accepted", VexOverrideDecision.Accepted)]
[InlineData("under_investigation", VexOverrideDecision.UnderInvestigation)]
public void Parse_AllDecisionValues_Accepted(string decisionValue, VexOverrideDecision expected)
{
var json = $$"""
{
"artifactDigest": "sha256:abc123",
"vulnerabilityId": "CVE-2024-12345",
"decision": "{{decisionValue}}",
"justification": "Test",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com"
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.True(result.IsValid);
var predicate = _parser.ParsePredicate(document.RootElement);
Assert.NotNull(predicate);
Assert.Equal(expected, predicate.Decision);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parse_NumericDecision_Accepted()
{
var json = """
{
"artifactDigest": "sha256:abc123",
"vulnerabilityId": "CVE-2024-12345",
"decision": 1,
"justification": "Test",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com"
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.True(result.IsValid);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parse_WithEvidenceRefs_ParsesCorrectly()
{
var json = """
{
"artifactDigest": "sha256:abc123",
"vulnerabilityId": "CVE-2024-12345",
"decision": "not_affected",
"justification": "Test",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com",
"evidenceRefs": [
{
"type": "document",
"uri": "https://example.com/doc",
"digest": "sha256:def456",
"description": "Design document"
}
]
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.True(result.IsValid);
var predicate = _parser.ParsePredicate(document.RootElement);
Assert.NotNull(predicate);
Assert.Single(predicate.EvidenceRefs);
Assert.Equal("document", predicate.EvidenceRefs[0].Type);
Assert.Equal("https://example.com/doc", predicate.EvidenceRefs[0].Uri);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parse_WithTool_ParsesCorrectly()
{
var json = """
{
"artifactDigest": "sha256:abc123",
"vulnerabilityId": "CVE-2024-12345",
"decision": "mitigated",
"justification": "Compensating control applied",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com",
"tool": {
"name": "StellaOps",
"version": "1.0.0",
"vendor": "StellaOps Inc"
}
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.Parse(document.RootElement);
Assert.True(result.IsValid);
var predicate = _parser.ParsePredicate(document.RootElement);
Assert.NotNull(predicate);
Assert.NotNull(predicate.Tool);
Assert.Equal("StellaOps", predicate.Tool.Name);
Assert.Equal("1.0.0", predicate.Tool.Version);
Assert.Equal("StellaOps Inc", predicate.Tool.Vendor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExtractSbom_ReturnsNull()
{
var json = """
{
"artifactDigest": "sha256:abc123",
"vulnerabilityId": "CVE-2024-12345",
"decision": "not_affected",
"justification": "Test",
"decisionTime": "2026-01-14T10:00:00Z",
"operatorId": "user@example.com"
}
""";
using var document = JsonDocument.Parse(json);
var result = _parser.ExtractSbom(document.RootElement);
Assert.Null(result);
}
}