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