finish off sprint advisories and sprints
This commit is contained in:
@@ -84,6 +84,17 @@ public static class BundleVerifyCommand
|
||||
Description = "Path to signer certificate PEM (optional; embedded in report metadata)"
|
||||
};
|
||||
|
||||
// Sprint 040-06: Replay blob fetch options
|
||||
var replayOption = new Option<bool>("--replay")
|
||||
{
|
||||
Description = "Verify binary content by fetching/reading large blobs referenced in attestations"
|
||||
};
|
||||
|
||||
var blobSourceOption = new Option<string?>("--blob-source")
|
||||
{
|
||||
Description = "Override blob source (registry URL or local directory path)"
|
||||
};
|
||||
|
||||
var command = new Command("verify", "Verify offline evidence bundle with full cryptographic verification")
|
||||
{
|
||||
bundleOption,
|
||||
@@ -94,6 +105,8 @@ public static class BundleVerifyCommand
|
||||
strictOption,
|
||||
signerOption,
|
||||
signerCertOption,
|
||||
replayOption,
|
||||
blobSourceOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
@@ -107,6 +120,8 @@ public static class BundleVerifyCommand
|
||||
var strict = parseResult.GetValue(strictOption);
|
||||
var signer = parseResult.GetValue(signerOption);
|
||||
var signerCert = parseResult.GetValue(signerCertOption);
|
||||
var replay = parseResult.GetValue(replayOption);
|
||||
var blobSource = parseResult.GetValue(blobSourceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleVerifyBundleAsync(
|
||||
@@ -119,6 +134,8 @@ public static class BundleVerifyCommand
|
||||
strict,
|
||||
signer,
|
||||
signerCert,
|
||||
replay,
|
||||
blobSource,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
@@ -136,6 +153,8 @@ public static class BundleVerifyCommand
|
||||
bool strict,
|
||||
string? signerKeyPath,
|
||||
string? signerCertPath,
|
||||
bool replay,
|
||||
string? blobSource,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -223,6 +242,17 @@ public static class BundleVerifyCommand
|
||||
Console.WriteLine($"Step 5: Payload Types {(payloadsPassed ? "✓" : "⚠")}");
|
||||
}
|
||||
|
||||
// Step 7 (040-06): Replay blob verification
|
||||
if (replay)
|
||||
{
|
||||
var replayPassed = await VerifyBlobReplayAsync(
|
||||
bundleDir, manifest, blobSource, offline, result, verbose, ct);
|
||||
if (outputFormat != "json")
|
||||
{
|
||||
Console.WriteLine($"Step 6: Blob Replay {(replayPassed ? "✓" : "✗")}");
|
||||
}
|
||||
}
|
||||
|
||||
return await FinalizeResultAsync(
|
||||
result,
|
||||
manifest,
|
||||
@@ -353,10 +383,29 @@ public static class BundleVerifyCommand
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var dsseFiles = new[] { "sbom.statement.dsse.json", "vex.statement.dsse.json" };
|
||||
// Well-known DSSE files in the bundle root
|
||||
var rootDsseFiles = new[] { "sbom.statement.dsse.json", "vex.statement.dsse.json" };
|
||||
|
||||
// Discover additional DSSE files in subdirectories (function-maps, verification)
|
||||
var additionalDsseFiles = new List<string>();
|
||||
var searchDirs = new[] { "function-maps", "verification" };
|
||||
foreach (var subDir in searchDirs)
|
||||
{
|
||||
var dirPath = Path.Combine(bundleDir, subDir);
|
||||
if (Directory.Exists(dirPath))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(dirPath, "*.dsse.json"))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(bundleDir, file).Replace('\\', '/');
|
||||
additionalDsseFiles.Add(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var allDsseFiles = rootDsseFiles.Concat(additionalDsseFiles).ToList();
|
||||
var verified = 0;
|
||||
|
||||
foreach (var dsseFile in dsseFiles)
|
||||
foreach (var dsseFile in allDsseFiles)
|
||||
{
|
||||
var filePath = Path.Combine(bundleDir, dsseFile);
|
||||
if (!File.Exists(filePath))
|
||||
@@ -491,6 +540,290 @@ public static class BundleVerifyCommand
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sprint 040-06: Verify large blobs referenced in attestations.
|
||||
/// For full bundles, reads blobs from the blobs/ directory.
|
||||
/// For light bundles, fetches blobs from registry or --blob-source.
|
||||
/// </summary>
|
||||
private static async Task<bool> VerifyBlobReplayAsync(
|
||||
string bundleDir,
|
||||
BundleManifestDto? manifest,
|
||||
string? blobSource,
|
||||
bool offline,
|
||||
VerificationResult result,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var exportMode = manifest?.ExportMode ?? "light";
|
||||
var isFullBundle = string.Equals(exportMode, "full", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Collect all largeBlob references from DSSE attestation payloads
|
||||
var blobRefs = await ExtractLargeBlobRefsAsync(bundleDir, verbose, ct);
|
||||
|
||||
if (blobRefs.Count == 0)
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("blob-replay", true,
|
||||
"No large blob references found in attestations"));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Found {blobRefs.Count} large blob reference(s) to verify");
|
||||
}
|
||||
|
||||
var allPassed = true;
|
||||
var verified = 0;
|
||||
|
||||
foreach (var blobRef in blobRefs)
|
||||
{
|
||||
byte[]? blobContent = null;
|
||||
|
||||
if (isFullBundle)
|
||||
{
|
||||
// Full bundle: blobs are embedded in blobs/ directory
|
||||
var blobPath = Path.Combine(bundleDir, "blobs", blobRef.Digest.Replace(":", "-"));
|
||||
if (!File.Exists(blobPath))
|
||||
{
|
||||
// Try alternate naming: sha256/<hash>
|
||||
var parts = blobRef.Digest.Split(':');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
blobPath = Path.Combine(bundleDir, "blobs", parts[0], parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(blobPath))
|
||||
{
|
||||
blobContent = await File.ReadAllBytesAsync(blobPath, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("blob-replay", false,
|
||||
$"Missing embedded blob: {blobRef.Digest}") { Severity = "error" });
|
||||
allPassed = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Light bundle: must fetch from registry or blob-source
|
||||
if (offline)
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("blob-replay", false,
|
||||
$"Cannot fetch blob {blobRef.Digest} in offline mode (light bundle)")
|
||||
{ Severity = "error" });
|
||||
allPassed = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
blobContent = await FetchBlobAsync(blobRef.Digest, blobSource, verbose, ct);
|
||||
|
||||
if (blobContent is null)
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("blob-replay", false,
|
||||
$"Failed to fetch blob: {blobRef.Digest}") { Severity = "error" });
|
||||
allPassed = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify digest
|
||||
var actualDigest = ComputeBlobDigest(blobContent, blobRef.Digest);
|
||||
if (!string.Equals(actualDigest, blobRef.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("blob-replay", false,
|
||||
$"Digest mismatch for blob: expected {blobRef.Digest}, got {actualDigest}")
|
||||
{ Severity = "error" });
|
||||
allPassed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
verified++;
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Blob verified: {blobRef.Digest} ({blobContent.Length} bytes)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allPassed)
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("blob-replay", true,
|
||||
$"All {verified} large blob(s) verified successfully"));
|
||||
}
|
||||
|
||||
return allPassed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts largeBlobs[] references from DSSE attestation payloads in the bundle.
|
||||
/// </summary>
|
||||
private static async Task<List<LargeBlobRef>> ExtractLargeBlobRefsAsync(
|
||||
string bundleDir, bool verbose, CancellationToken ct)
|
||||
{
|
||||
var refs = new List<LargeBlobRef>();
|
||||
var attestationsDir = Path.Combine(bundleDir, "attestations");
|
||||
|
||||
if (!Directory.Exists(attestationsDir))
|
||||
{
|
||||
// Also check for DSSE envelopes directly in the bundle root
|
||||
attestationsDir = bundleDir;
|
||||
}
|
||||
|
||||
var dsseFiles = Directory.Exists(attestationsDir)
|
||||
? Directory.GetFiles(attestationsDir, "*.dsse.json", SearchOption.AllDirectories)
|
||||
.Concat(Directory.GetFiles(attestationsDir, "*.intoto.json", SearchOption.AllDirectories))
|
||||
.ToArray()
|
||||
: [];
|
||||
|
||||
foreach (var dsseFile in dsseFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(dsseFile, ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract payload from DSSE envelope
|
||||
if (!root.TryGetProperty("payload", out var payloadProp))
|
||||
continue;
|
||||
|
||||
var payloadB64 = payloadProp.GetString();
|
||||
if (string.IsNullOrEmpty(payloadB64))
|
||||
continue;
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(payloadB64);
|
||||
using var payloadDoc = JsonDocument.Parse(payloadBytes);
|
||||
var payload = payloadDoc.RootElement;
|
||||
|
||||
// Look for largeBlobs in the predicate
|
||||
if (!payload.TryGetProperty("predicate", out var predicate))
|
||||
continue;
|
||||
|
||||
if (!predicate.TryGetProperty("largeBlobs", out var largeBlobs))
|
||||
continue;
|
||||
|
||||
if (largeBlobs.ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
|
||||
foreach (var blob in largeBlobs.EnumerateArray())
|
||||
{
|
||||
var digest = blob.TryGetProperty("digest", out var d) ? d.GetString() : null;
|
||||
var kind = blob.TryGetProperty("kind", out var k) ? k.GetString() : null;
|
||||
var sizeBytes = blob.TryGetProperty("sizeBytes", out var s) ? s.GetInt64() : 0L;
|
||||
|
||||
if (!string.IsNullOrEmpty(digest))
|
||||
{
|
||||
refs.Add(new LargeBlobRef(digest, kind, sizeBytes));
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Found blob ref: {digest} ({kind ?? "unknown"}, {sizeBytes} bytes)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Warning: Failed to parse {Path.GetFileName(dsseFile)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a blob by digest from registry or local blob-source.
|
||||
/// </summary>
|
||||
private static async Task<byte[]?> FetchBlobAsync(
|
||||
string digest, string? blobSource, bool verbose, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(blobSource) && Directory.Exists(blobSource))
|
||||
{
|
||||
// Local directory: look for blob by digest
|
||||
var localPath = Path.Combine(blobSource, digest.Replace(":", "-"));
|
||||
if (File.Exists(localPath))
|
||||
return await File.ReadAllBytesAsync(localPath, ct);
|
||||
|
||||
// Try sha256/<hash> structure
|
||||
var parts = digest.Split(':');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
localPath = Path.Combine(blobSource, parts[0], parts[1]);
|
||||
if (File.Exists(localPath))
|
||||
return await File.ReadAllBytesAsync(localPath, ct);
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Blob not found in local source: {digest}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(blobSource))
|
||||
{
|
||||
// Registry URL: fetch via OCI blob API
|
||||
// TODO: Implement OCI registry blob fetch when IOciRegistryClient is available
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Fetching blob from registry: {blobSource}/blobs/{digest}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
|
||||
var url = $"{blobSource.TrimEnd('/')}/v2/_blobs/{digest}";
|
||||
var response = await http.GetAsync(url, ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsByteArrayAsync(ct);
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Registry returned: {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Fetch error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// No blob source specified - cannot fetch
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the digest of blob content using the algorithm specified in the expected digest.
|
||||
/// </summary>
|
||||
private static string ComputeBlobDigest(byte[] content, string expectedDigest)
|
||||
{
|
||||
var algorithm = expectedDigest.Split(':')[0].ToLowerInvariant();
|
||||
var hash = algorithm switch
|
||||
{
|
||||
"sha256" => SHA256.HashData(content),
|
||||
"sha384" => SHA384.HashData(content),
|
||||
"sha512" => SHA512.HashData(content),
|
||||
_ => SHA256.HashData(content)
|
||||
};
|
||||
return $"{algorithm}:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a large blob in a DSSE attestation predicate.
|
||||
/// </summary>
|
||||
private sealed record LargeBlobRef(string Digest, string? Kind, long SizeBytes);
|
||||
|
||||
private static async Task<int> FinalizeResultAsync(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
@@ -1002,6 +1335,10 @@ public static class BundleVerifyCommand
|
||||
|
||||
[JsonPropertyName("verify")]
|
||||
public VerifySectionDto? Verify { get; set; }
|
||||
|
||||
/// <summary>Sprint 040-06: Export mode (light or full) for blob replay verification.</summary>
|
||||
[JsonPropertyName("exportMode")]
|
||||
public string? ExportMode { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleSubjectDto
|
||||
|
||||
Reference in New Issue
Block a user