save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -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
};
}
}

View File

@@ -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.

View File

@@ -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; } = [];
}

View File

@@ -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. |

View File

@@ -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)
{