doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceBundleImporter.cs
|
||||
// Sprint: SPRINT_20260118_029_Evidence_bundle_export_import
|
||||
// Task: TASK-029-002 - Implement EvidenceBundleImporter
|
||||
// Description: Imports and validates evidence bundles with full verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Imports and validates evidence bundles.
|
||||
/// Verifies checksums, DSSE signatures, and Rekor proofs.
|
||||
/// </summary>
|
||||
public interface IEvidenceBundleImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Imports a bundle from a stream.
|
||||
/// </summary>
|
||||
Task<BundleImportResult> ImportAsync(
|
||||
Stream bundleStream,
|
||||
BundleImportOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a bundle without importing.
|
||||
/// </summary>
|
||||
Task<BundleValidationResult> ValidateAsync(
|
||||
Stream bundleStream,
|
||||
BundleValidationOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Imports a bundle from a file path.
|
||||
/// </summary>
|
||||
Task<BundleImportResult> ImportFromFileAsync(
|
||||
string filePath,
|
||||
BundleImportOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default evidence bundle importer implementation.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleImporter : IEvidenceBundleImporter
|
||||
{
|
||||
private readonly IDsseVerifier? _dsseVerifier;
|
||||
private readonly IRekorVerifier? _rekorVerifier;
|
||||
private readonly IArtifactStore? _artifactStore;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new evidence bundle importer.
|
||||
/// </summary>
|
||||
public EvidenceBundleImporter(
|
||||
IDsseVerifier? dsseVerifier = null,
|
||||
IRekorVerifier? rekorVerifier = null,
|
||||
IArtifactStore? artifactStore = null)
|
||||
{
|
||||
_dsseVerifier = dsseVerifier;
|
||||
_rekorVerifier = rekorVerifier;
|
||||
_artifactStore = artifactStore;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleImportResult> ImportAsync(
|
||||
Stream bundleStream,
|
||||
BundleImportOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// First validate
|
||||
var validation = await ValidateAsync(bundleStream, new BundleValidationOptions
|
||||
{
|
||||
VerifyChecksums = options.VerifyChecksums,
|
||||
VerifyDsseSignatures = options.VerifyDsseSignatures,
|
||||
VerifyRekorProofs = options.VerifyRekorProofs
|
||||
}, ct);
|
||||
|
||||
if (!validation.IsValid && !options.AllowInvalidBundles)
|
||||
{
|
||||
return BundleImportResult.Failed(
|
||||
"Bundle validation failed",
|
||||
validation.Errors);
|
||||
}
|
||||
|
||||
// Reset stream position
|
||||
bundleStream.Position = 0;
|
||||
|
||||
// Extract and store artifacts
|
||||
var artifacts = await ExtractArtifactsAsync(bundleStream, ct);
|
||||
|
||||
if (_artifactStore != null && options.StoreArtifacts)
|
||||
{
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await StoreArtifactAsync(artifact, options, ct);
|
||||
}
|
||||
}
|
||||
|
||||
return BundleImportResult.Success(
|
||||
validation.Manifest,
|
||||
artifacts.Count,
|
||||
validation.Warnings);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleValidationResult> ValidateAsync(
|
||||
Stream bundleStream,
|
||||
BundleValidationOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
using var archive = new ZipArchive(bundleStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
|
||||
// 1. Read and parse manifest
|
||||
var manifest = await ReadManifestAsync(archive, errors, ct);
|
||||
if (manifest == null)
|
||||
{
|
||||
return BundleValidationResult.Invalid(errors, warnings);
|
||||
}
|
||||
|
||||
// 2. Verify checksums
|
||||
if (options.VerifyChecksums)
|
||||
{
|
||||
await VerifyChecksumsAsync(archive, manifest, errors, warnings);
|
||||
}
|
||||
|
||||
// 3. Verify DSSE signatures
|
||||
if (options.VerifyDsseSignatures && _dsseVerifier != null)
|
||||
{
|
||||
await VerifyDsseSignaturesAsync(archive, errors, warnings, ct);
|
||||
}
|
||||
|
||||
// 4. Verify Rekor proofs
|
||||
if (options.VerifyRekorProofs && _rekorVerifier != null)
|
||||
{
|
||||
await VerifyRekorProofsAsync(archive, errors, warnings, ct);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return BundleValidationResult.Invalid(errors, warnings, manifest);
|
||||
}
|
||||
|
||||
return BundleValidationResult.Valid(manifest, warnings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Bundle extraction failed: {ex.Message}");
|
||||
return BundleValidationResult.Invalid(errors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleImportResult> ImportFromFileAsync(
|
||||
string filePath,
|
||||
BundleImportOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var fileStream = File.OpenRead(filePath);
|
||||
return await ImportAsync(fileStream, options, ct);
|
||||
}
|
||||
|
||||
private static async Task<BundleManifest?> ReadManifestAsync(
|
||||
ZipArchive archive,
|
||||
List<string> errors,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var manifestEntry = archive.GetEntry("manifest.json");
|
||||
if (manifestEntry == null)
|
||||
{
|
||||
errors.Add("Missing manifest.json");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = manifestEntry.Open();
|
||||
return await JsonSerializer.DeserializeAsync<BundleManifest>(stream, cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid manifest.json: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task VerifyChecksumsAsync(
|
||||
ZipArchive archive,
|
||||
BundleManifest manifest,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var entry = archive.GetEntry(file.Path);
|
||||
if (entry == null)
|
||||
{
|
||||
errors.Add($"Missing file: {file.Path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
await using var stream = entry.Open();
|
||||
using var sha256 = SHA256.Create();
|
||||
|
||||
var hashBytes = await sha256.ComputeHashAsync(stream);
|
||||
var computedHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
|
||||
if (!string.Equals(computedHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Checksum mismatch for {file.Path}: expected {file.Sha256}, got {computedHash}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task VerifyDsseSignaturesAsync(
|
||||
ZipArchive archive,
|
||||
List<string> errors,
|
||||
List<string> warnings,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var dsseEntries = archive.Entries
|
||||
.Where(e => e.FullName.StartsWith("dsse/") && e.FullName.EndsWith(".json"))
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in dsseEntries)
|
||||
{
|
||||
await using var stream = entry.Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = await reader.ReadToEndAsync(ct);
|
||||
|
||||
var result = await _dsseVerifier!.VerifyAsync(json, ct);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add($"DSSE verification failed for {entry.FullName}: {result.Error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task VerifyRekorProofsAsync(
|
||||
ZipArchive archive,
|
||||
List<string> errors,
|
||||
List<string> warnings,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entriesFile = archive.GetEntry("rekor/entries.json");
|
||||
if (entriesFile == null)
|
||||
{
|
||||
warnings.Add("No Rekor entries found in bundle");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var stream = entriesFile.Open();
|
||||
var entries = await JsonSerializer.DeserializeAsync<List<RekorEntry>>(stream, cancellationToken: ct);
|
||||
|
||||
if (entries == null)
|
||||
{
|
||||
warnings.Add("Empty Rekor entries file");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var result = await _rekorVerifier!.VerifyAsync(entry, ct);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add($"Rekor verification failed for log index {entry.LogIndex}: {result.Error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ExtractedArtifact>> ExtractArtifactsAsync(
|
||||
Stream bundleStream,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var artifacts = new List<ExtractedArtifact>();
|
||||
|
||||
using var archive = new ZipArchive(bundleStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.FullName.EndsWith("/"))
|
||||
continue; // Skip directories
|
||||
|
||||
await using var stream = entry.Open();
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms, ct);
|
||||
|
||||
artifacts.Add(new ExtractedArtifact
|
||||
{
|
||||
Path = entry.FullName,
|
||||
Content = ms.ToArray(),
|
||||
Size = ms.Length
|
||||
});
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private async Task StoreArtifactAsync(
|
||||
ExtractedArtifact artifact,
|
||||
BundleImportOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Implementation would store to IArtifactStore
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportOptions
|
||||
{
|
||||
/// <summary>Whether to verify checksums.</summary>
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to verify DSSE signatures.</summary>
|
||||
public bool VerifyDsseSignatures { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to verify Rekor proofs.</summary>
|
||||
public bool VerifyRekorProofs { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to store artifacts to artifact store.</summary>
|
||||
public bool StoreArtifacts { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to allow importing invalid bundles.</summary>
|
||||
public bool AllowInvalidBundles { get; init; } = false;
|
||||
|
||||
/// <summary>Tenant ID for storage.</summary>
|
||||
public Guid TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle validation.
|
||||
/// </summary>
|
||||
public sealed record BundleValidationOptions
|
||||
{
|
||||
/// <summary>Whether to verify checksums.</summary>
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to verify DSSE signatures.</summary>
|
||||
public bool VerifyDsseSignatures { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to verify Rekor proofs.</summary>
|
||||
public bool VerifyRekorProofs { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportResult
|
||||
{
|
||||
/// <summary>Whether import succeeded.</summary>
|
||||
public required bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>Bundle manifest.</summary>
|
||||
public BundleManifest? Manifest { get; init; }
|
||||
|
||||
/// <summary>Number of artifacts imported.</summary>
|
||||
public int ArtifactCount { get; init; }
|
||||
|
||||
/// <summary>Import errors.</summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>Import warnings.</summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>Creates a success result.</summary>
|
||||
public static BundleImportResult Success(BundleManifest? manifest, int artifactCount, IReadOnlyList<string>? warnings = null)
|
||||
{
|
||||
return new BundleImportResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Manifest = manifest,
|
||||
ArtifactCount = artifactCount,
|
||||
Warnings = warnings ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Creates a failure result.</summary>
|
||||
public static BundleImportResult Failed(string error, IReadOnlyList<string>? errors = null)
|
||||
{
|
||||
var allErrors = new List<string> { error };
|
||||
if (errors != null)
|
||||
allErrors.AddRange(errors);
|
||||
|
||||
return new BundleImportResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Errors = allErrors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle validation.
|
||||
/// </summary>
|
||||
public sealed record BundleValidationResult
|
||||
{
|
||||
/// <summary>Whether validation passed.</summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>Bundle manifest.</summary>
|
||||
public BundleManifest? Manifest { get; init; }
|
||||
|
||||
/// <summary>Validation errors.</summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>Validation warnings.</summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>Creates a valid result.</summary>
|
||||
public static BundleValidationResult Valid(BundleManifest manifest, IReadOnlyList<string>? warnings = null)
|
||||
{
|
||||
return new BundleValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Manifest = manifest,
|
||||
Warnings = warnings ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Creates an invalid result.</summary>
|
||||
public static BundleValidationResult Invalid(
|
||||
IReadOnlyList<string> errors,
|
||||
IReadOnlyList<string>? warnings = null,
|
||||
BundleManifest? manifest = null)
|
||||
{
|
||||
return new BundleValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Manifest = manifest,
|
||||
Errors = errors,
|
||||
Warnings = warnings ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle manifest.
|
||||
/// </summary>
|
||||
public sealed record BundleManifest
|
||||
{
|
||||
/// <summary>Bundle ID.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Bundle version.</summary>
|
||||
public int Version { get; init; } = 1;
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Export source.</summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>Files in bundle.</summary>
|
||||
public IReadOnlyList<BundleFileEntry> Files { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File entry in bundle manifest.
|
||||
/// </summary>
|
||||
public sealed record BundleFileEntry
|
||||
{
|
||||
/// <summary>File path within bundle.</summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash.</summary>
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>File size in bytes.</summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>Content type.</summary>
|
||||
public string? ContentType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracted artifact from bundle.
|
||||
/// </summary>
|
||||
internal sealed record ExtractedArtifact
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required byte[] Content { get; init; }
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry from bundle.
|
||||
/// </summary>
|
||||
public sealed record RekorEntry
|
||||
{
|
||||
/// <summary>Log index.</summary>
|
||||
public long LogIndex { get; init; }
|
||||
|
||||
/// <summary>Entry UUID.</summary>
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
/// <summary>Entry body (base64).</summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>Integrated time.</summary>
|
||||
public DateTimeOffset IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
// Verification interfaces (to be implemented by existing services)
|
||||
|
||||
/// <summary>
|
||||
/// DSSE verifier interface.
|
||||
/// </summary>
|
||||
public interface IDsseVerifier
|
||||
{
|
||||
/// <summary>Verifies a DSSE envelope.</summary>
|
||||
Task<VerificationResult> VerifyAsync(string json, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor verifier interface.
|
||||
/// </summary>
|
||||
public interface IRekorVerifier
|
||||
{
|
||||
/// <summary>Verifies a Rekor entry.</summary>
|
||||
Task<VerificationResult> VerifyAsync(RekorEntry entry, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification result.
|
||||
/// </summary>
|
||||
public sealed record VerificationResult
|
||||
{
|
||||
/// <summary>Whether verification passed.</summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>Error message if invalid.</summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact store interface for bundle storage.
|
||||
/// </summary>
|
||||
public interface IArtifactStore
|
||||
{
|
||||
// Defined in StellaOps.Artifact.Core
|
||||
}
|
||||
Reference in New Issue
Block a user