new advisories work and features gaps work

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

View File

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

View File

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

View File

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

View File

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