save progress
This commit is contained in:
@@ -108,7 +108,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
|
||||
var index = new ArtifactIndex();
|
||||
|
||||
// Step 2: Evidence collection (SBOM + attestations). VEX parsing is not yet implemented.
|
||||
// Step 2: Evidence collection (SBOM + attestations).
|
||||
await _sbomCollector.CollectAsync(Path.Combine(inputDirectory, "sboms"), index, ct).ConfigureAwait(false);
|
||||
|
||||
var attestationOptions = new AttestationCollectionOptions
|
||||
@@ -127,11 +127,15 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Step 4: Lattice merge (currently no VEX ingestion; returns empty).
|
||||
var mergedStatements = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
// Step 4: VEX ingestion + lattice merge.
|
||||
var (mergedStatements, conflictCount) = await MergeVexStatementsAsync(index, options, ct).ConfigureAwait(false);
|
||||
|
||||
// Step 5: Graph emission.
|
||||
var graph = BuildGraph(index, mergedStatements, generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
|
||||
var graph = BuildGraph(
|
||||
index,
|
||||
mergedStatements,
|
||||
conflictCount,
|
||||
generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
|
||||
await _serializer.WriteAsync(graph, outputDirectory, ct).ConfigureAwait(false);
|
||||
|
||||
if (options.SignOutput)
|
||||
@@ -156,6 +160,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
private static EvidenceGraph BuildGraph(
|
||||
ArtifactIndex index,
|
||||
IReadOnlyDictionary<string, VexStatement> mergedStatements,
|
||||
int conflictCount,
|
||||
DateTimeOffset generatedAtUtc)
|
||||
{
|
||||
var nodes = new List<EvidenceNode>();
|
||||
@@ -233,9 +238,148 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
SbomCount = sbomCount,
|
||||
AttestationCount = attestationCount,
|
||||
VexStatementCount = mergedStatements.Count,
|
||||
ConflictCount = 0,
|
||||
ConflictCount = conflictCount,
|
||||
ReconciliationDurationMs = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<(Dictionary<string, VexStatement> Statements, int ConflictCount)> MergeVexStatementsAsync(
|
||||
ArtifactIndex index,
|
||||
ReconciliationOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var lattice = new SourcePrecedenceLattice(options.Lattice);
|
||||
var statementsByKey = new Dictionary<string, List<VexStatement>>(StringComparer.Ordinal);
|
||||
var documentCache = new Dictionary<string, OpenVexDocument>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (digest, entry) in index.GetAll())
|
||||
{
|
||||
foreach (var vexRef in entry.VexDocuments)
|
||||
{
|
||||
if (!documentCache.TryGetValue(vexRef.FilePath, out var document))
|
||||
{
|
||||
var loaded = await TryLoadOpenVexDocumentAsync(vexRef.FilePath, ct).ConfigureAwait(false);
|
||||
if (loaded is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
documentCache[vexRef.FilePath] = loaded;
|
||||
document = loaded;
|
||||
}
|
||||
|
||||
var source = ResolveSourcePrecedence(document.Author, options.Lattice);
|
||||
var documentRef = document.DocumentId ?? vexRef.FilePath;
|
||||
|
||||
foreach (var statement in document.Statements)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statement.VulnerabilityId) || string.IsNullOrWhiteSpace(statement.Status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"{digest}:{statement.VulnerabilityId}";
|
||||
if (!statementsByKey.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<VexStatement>();
|
||||
statementsByKey[key] = list;
|
||||
}
|
||||
|
||||
list.Add(new VexStatement
|
||||
{
|
||||
VulnerabilityId = statement.VulnerabilityId!,
|
||||
ProductId = digest,
|
||||
Status = MapStatus(statement.Status),
|
||||
Source = source,
|
||||
Justification = statement.Justification,
|
||||
ActionStatement = statement.ActionStatement,
|
||||
Timestamp = statement.Timestamp ?? document.Timestamp,
|
||||
DocumentRef = documentRef
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var merged = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
var conflictCount = 0;
|
||||
|
||||
foreach (var (key, statements) in statementsByKey)
|
||||
{
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var winner = lattice.Merge(statements);
|
||||
if (statements.Count > 1 &&
|
||||
statements.Any(s => !ReferenceEquals(s, winner) && lattice.ResolveConflict(winner, s).HasConflict))
|
||||
{
|
||||
conflictCount++;
|
||||
}
|
||||
|
||||
merged[key] = winner;
|
||||
}
|
||||
|
||||
return (merged, conflictCount);
|
||||
}
|
||||
|
||||
private static async Task<OpenVexDocument?> TryLoadOpenVexDocumentAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var parser = new DsseAttestationParser();
|
||||
var parseResult = await parser.ParseAsync(stream, ct).ConfigureAwait(false);
|
||||
if (parseResult.IsSuccess && !string.IsNullOrWhiteSpace(parseResult.Statement?.PredicateJson))
|
||||
{
|
||||
if (OpenVexParser.TryParse(parseResult.Statement.PredicateJson, out var document))
|
||||
{
|
||||
return document;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback below.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath, ct).ConfigureAwait(false);
|
||||
return OpenVexParser.TryParse(json, out var document) ? document : null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SourcePrecedence ResolveSourcePrecedence(string? source, LatticeConfiguration config)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(source) && config.SourceMappings.TryGetValue(source, out var mapped))
|
||||
{
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return SourcePrecedence.Unknown;
|
||||
}
|
||||
|
||||
private static VexStatus MapStatus(string status)
|
||||
{
|
||||
var normalized = status.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@ public sealed record InTotoSubject
|
||||
/// <summary>
|
||||
/// Subject digests (algorithm -> hash).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalized SHA-256 digest if available.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
|
||||
internal static class OpenVexParser
|
||||
{
|
||||
public static bool TryParse(string json, out OpenVexDocument document)
|
||||
{
|
||||
document = new OpenVexDocument();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var parsed = JsonDocument.Parse(
|
||||
json,
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var root = parsed.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var documentId = GetString(root, "@id");
|
||||
var author = GetString(root, "author");
|
||||
var timestamp = TryParseTimestamp(root, "timestamp");
|
||||
|
||||
var statements = new List<OpenVexStatement>();
|
||||
if (root.TryGetProperty("statements", out var statementsProp) &&
|
||||
statementsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var element in statementsProp.EnumerateArray())
|
||||
{
|
||||
if (TryParseStatement(element, timestamp, out var statement))
|
||||
{
|
||||
statements.Add(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document = new OpenVexDocument
|
||||
{
|
||||
DocumentId = documentId,
|
||||
Author = author,
|
||||
Timestamp = timestamp,
|
||||
Statements = statements
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseStatement(
|
||||
JsonElement element,
|
||||
DateTimeOffset? defaultTimestamp,
|
||||
out OpenVexStatement statement)
|
||||
{
|
||||
statement = new OpenVexStatement();
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var vulnerabilityId = ResolveVulnerabilityId(element);
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var status = GetString(element, "status");
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var justification = GetString(element, "justification");
|
||||
var actionStatement = GetString(element, "action_statement");
|
||||
|
||||
var timestamp = TryParseTimestamp(element, "timestamp")
|
||||
?? TryParseTimestamp(element, "action_statement_timestamp")
|
||||
?? defaultTimestamp;
|
||||
|
||||
var products = new List<string>();
|
||||
if (element.TryGetProperty("products", out var productsProp) &&
|
||||
productsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var product in productsProp.EnumerateArray())
|
||||
{
|
||||
if (product.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var productId = GetString(product, "@id") ?? GetString(product, "id");
|
||||
if (!string.IsNullOrWhiteSpace(productId))
|
||||
{
|
||||
products.Add(productId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
ActionStatement = actionStatement,
|
||||
Timestamp = timestamp,
|
||||
Products = products
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ResolveVulnerabilityId(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("vulnerability", out var vulnerabilityProp) ||
|
||||
vulnerabilityProp.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetString(vulnerabilityProp, "@id")
|
||||
?? GetString(vulnerabilityProp, "id")
|
||||
?? GetString(vulnerabilityProp, "name");
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryParseTimestamp(JsonElement element, string propertyName)
|
||||
{
|
||||
var value = GetString(element, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var timestamp)
|
||||
? timestamp
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenVexDocument
|
||||
{
|
||||
public string? DocumentId { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<OpenVexStatement> Statements { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record OpenVexStatement
|
||||
{
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<string> Products { get; init; } = [];
|
||||
}
|
||||
@@ -7,5 +7,5 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0026-M | DONE | Maintainability audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-T | DONE | Test coverage audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-A | DOING | Pending approval for changes. |
|
||||
| AUDIT-0026-A | DONE | Applied VEX merge, monotonicity guard, and DSSE PAE alignment. |
|
||||
| VAL-SMOKE-001 | DONE | Resolved DSSE signer ambiguity; smoke build now proceeds. |
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -508,7 +509,18 @@ public static class RekorOfflineReceiptVerifier
|
||||
|
||||
private static bool LooksLikeDashSignature(string trimmedLine)
|
||||
{
|
||||
return trimmedLine.Length > 0 && trimmedLine[0] == '\u2014';
|
||||
if (trimmedLine.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = trimmedLine[0];
|
||||
if (first == '-')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return CharUnicodeInfo.GetUnicodeCategory(first) == UnicodeCategory.DashPunctuation;
|
||||
}
|
||||
private static bool TryDecodeBase64(string token, out byte[] bytes)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user