new advisories work and features gaps work
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user