sprints work
This commit is contained in:
@@ -0,0 +1,951 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// E2EReproducibilityTestFixture.cs
|
||||
// Sprint: SPRINT_8200_0001_0004_e2e_reproducibility_test
|
||||
// Task: E2E-8200-002 - Create E2EReproducibilityTestFixture with full service composition
|
||||
// Description: Test fixture providing full pipeline composition for E2E reproducibility tests.
|
||||
// Supports: ingest → normalize → diff → decide → attest → bundle → reverify
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Dsse;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace StellaOps.Integration.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture for end-to-end reproducibility tests.
|
||||
/// Provides a fully configured test environment with:
|
||||
/// - PostgreSQL database via Testcontainers
|
||||
/// - Mock advisory feeds
|
||||
/// - Policy engine with test policies
|
||||
/// - Attestor for DSSE envelope creation
|
||||
/// - Full pipeline execution capability
|
||||
/// </summary>
|
||||
public sealed class E2EReproducibilityTestFixture : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer? _postgresContainer;
|
||||
private WebApplicationFactory<Program>? _factory;
|
||||
private ECDsa? _signingKey;
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frozen timestamp used for deterministic tests.
|
||||
/// </summary>
|
||||
public DateTimeOffset FrozenTimestamp { get; } = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the test fixture, starting required services.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_initialized)
|
||||
return;
|
||||
|
||||
// Generate deterministic signing key from fixed seed
|
||||
_signingKey = GenerateDeterministicKey(42);
|
||||
|
||||
// Start PostgreSQL container
|
||||
_postgresContainer = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.WithDatabase("stellaops_e2e_test")
|
||||
.WithUsername("e2e_test_user")
|
||||
.WithPassword("e2e_test_password")
|
||||
.WithPortBinding(5432, true)
|
||||
.Build();
|
||||
|
||||
await _postgresContainer.StartAsync();
|
||||
|
||||
// Create the test web application factory
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:ScannerDb"] = _postgresContainer.GetConnectionString(),
|
||||
["Scanner:Authority:Enabled"] = "false",
|
||||
["Scanner:AllowAnonymous"] = "true",
|
||||
["Scanner:ProofChain:Enabled"] = "true",
|
||||
["Scanner:ProofChain:SigningKeyId"] = "e2e-test-key",
|
||||
["Scanner:ProofChain:AutoSign"] = "true",
|
||||
["Scanner:Determinism:FrozenClock"] = "true",
|
||||
["Scanner:Determinism:FrozenTimestamp"] = FrozenTimestamp.ToString("O"),
|
||||
["Logging:LogLevel:Default"] = "Warning"
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddLogging(logging =>
|
||||
{
|
||||
logging.ClearProviders();
|
||||
logging.AddConsole();
|
||||
logging.SetMinimumLevel(LogLevel.Warning);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP client for the test application.
|
||||
/// </summary>
|
||||
public async Task<HttpClient> CreateClientAsync()
|
||||
{
|
||||
if (!_initialized)
|
||||
{
|
||||
await InitializeAsync();
|
||||
}
|
||||
|
||||
return _factory!.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot of all inputs with computed hashes for verification.
|
||||
/// </summary>
|
||||
public async Task<InputSnapshot> SnapshotInputsAsync(
|
||||
string? sbomFixturePath = null,
|
||||
string? advisoryFeedPath = null,
|
||||
string? policyPackPath = null,
|
||||
string? vexDocumentPath = null)
|
||||
{
|
||||
// Load default fixtures if not specified
|
||||
var sbomContent = sbomFixturePath is not null
|
||||
? await File.ReadAllBytesAsync(sbomFixturePath)
|
||||
: CreateMinimalSbom();
|
||||
|
||||
var advisoryFeed = advisoryFeedPath is not null
|
||||
? await File.ReadAllBytesAsync(advisoryFeedPath)
|
||||
: CreateMockAdvisoryFeed();
|
||||
|
||||
var policyPack = policyPackPath is not null
|
||||
? await File.ReadAllBytesAsync(policyPackPath)
|
||||
: CreateDefaultPolicyPack();
|
||||
|
||||
var vexDocument = vexDocumentPath is not null
|
||||
? await File.ReadAllBytesAsync(vexDocumentPath)
|
||||
: null;
|
||||
|
||||
return new InputSnapshot
|
||||
{
|
||||
Sbom = sbomContent,
|
||||
SbomHash = ComputeHash(sbomContent),
|
||||
AdvisoryFeed = advisoryFeed,
|
||||
AdvisoryFeedHash = ComputeHash(advisoryFeed),
|
||||
PolicyPack = policyPack,
|
||||
PolicyPackHash = ComputeHash(policyPack),
|
||||
VexDocument = vexDocument,
|
||||
VexDocumentHash = vexDocument is not null ? ComputeHash(vexDocument) : null,
|
||||
SnapshotTimestamp = FrozenTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the full pipeline with the given inputs.
|
||||
/// </summary>
|
||||
public async Task<PipelineResult> RunFullPipelineAsync(InputSnapshot inputs)
|
||||
{
|
||||
// Stage 1: Ingest advisories
|
||||
var advisories = await IngestAdvisoriesAsync(inputs.AdvisoryFeed);
|
||||
|
||||
// Stage 2: Normalize advisories
|
||||
var normalized = await NormalizeAdvisoriesAsync(advisories);
|
||||
|
||||
// Stage 3: Diff SBOM against advisories
|
||||
var diff = await ComputeDiffAsync(inputs.Sbom, normalized);
|
||||
|
||||
// Stage 4: Evaluate policy and compute verdict
|
||||
var verdict = await EvaluatePolicyAsync(diff, inputs.PolicyPack, inputs.VexDocument);
|
||||
|
||||
// Stage 5: Create DSSE attestation
|
||||
var envelope = await CreateAttestationAsync(verdict);
|
||||
|
||||
// Stage 6: Package into bundle
|
||||
var bundle = await CreateBundleAsync(envelope, inputs);
|
||||
|
||||
return new PipelineResult
|
||||
{
|
||||
VerdictId = verdict.VerdictId,
|
||||
VerdictHash = ComputeHash(SerializeVerdict(verdict)),
|
||||
EnvelopeHash = ComputeHash(SerializeEnvelope(envelope)),
|
||||
BundleManifest = bundle.Manifest,
|
||||
BundleManifestHash = ComputeHash(bundle.Manifest),
|
||||
ExecutionTimestamp = FrozenTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
#region Stage 1: Ingest
|
||||
|
||||
/// <summary>
|
||||
/// Ingests advisory feed data.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<AdvisoryRecord>> IngestAdvisoriesAsync(byte[] feedData)
|
||||
{
|
||||
// Parse advisory feed (mock implementation for E2E tests)
|
||||
var advisories = ParseAdvisoryFeed(feedData);
|
||||
return Task.FromResult(advisories);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryRecord> ParseAdvisoryFeed(byte[] feedData)
|
||||
{
|
||||
// For E2E tests, parse the mock feed format
|
||||
var json = System.Text.Encoding.UTF8.GetString(feedData);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var advisories = new List<AdvisoryRecord>();
|
||||
foreach (var element in doc.RootElement.GetProperty("advisories").EnumerateArray())
|
||||
{
|
||||
advisories.Add(new AdvisoryRecord
|
||||
{
|
||||
Id = element.GetProperty("id").GetString()!,
|
||||
CveId = element.GetProperty("cveId").GetString(),
|
||||
Severity = element.GetProperty("severity").GetString()!,
|
||||
AffectedPackages = element.GetProperty("affected").EnumerateArray()
|
||||
.Select(a => a.GetString()!)
|
||||
.ToList(),
|
||||
FixedVersions = element.TryGetProperty("fixed", out var fixedProp)
|
||||
? fixedProp.EnumerateArray().Select(f => f.GetString()!).ToList()
|
||||
: []
|
||||
});
|
||||
}
|
||||
|
||||
// Sort for determinism
|
||||
return advisories.OrderBy(a => a.Id, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stage 2: Normalize
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes and deduplicates advisories.
|
||||
/// </summary>
|
||||
public Task<NormalizedAdvisories> NormalizeAdvisoriesAsync(IReadOnlyList<AdvisoryRecord> advisories)
|
||||
{
|
||||
// Deduplicate by CVE ID
|
||||
var uniqueByCve = advisories
|
||||
.GroupBy(a => a.CveId ?? a.Id)
|
||||
.Select(g => g.OrderBy(a => a.Id, StringComparer.Ordinal).First())
|
||||
.OrderBy(a => a.CveId ?? a.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var normalized = new NormalizedAdvisories
|
||||
{
|
||||
Advisories = uniqueByCve,
|
||||
NormalizationTimestamp = FrozenTimestamp,
|
||||
ContentHash = ComputeHash(SerializeAdvisories(uniqueByCve))
|
||||
};
|
||||
|
||||
return Task.FromResult(normalized);
|
||||
}
|
||||
|
||||
private static byte[] SerializeAdvisories(IReadOnlyList<AdvisoryRecord> advisories)
|
||||
{
|
||||
var serializable = advisories.Select(a => new
|
||||
{
|
||||
id = a.Id,
|
||||
cveId = a.CveId,
|
||||
severity = a.Severity,
|
||||
affected = a.AffectedPackages,
|
||||
fixed_ = a.FixedVersions
|
||||
}).ToList();
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(serializable));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stage 3: Diff
|
||||
|
||||
/// <summary>
|
||||
/// Computes diff between SBOM and advisories.
|
||||
/// </summary>
|
||||
public Task<DiffResult> ComputeDiffAsync(byte[] sbomData, NormalizedAdvisories advisories)
|
||||
{
|
||||
// Parse SBOM and find affected components
|
||||
var sbom = ParseSbom(sbomData);
|
||||
var findings = new List<Finding>();
|
||||
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
foreach (var advisory in advisories.Advisories)
|
||||
{
|
||||
if (advisory.AffectedPackages.Any(pkg =>
|
||||
pkg.Equals(component.Purl, StringComparison.OrdinalIgnoreCase) ||
|
||||
pkg.Contains(component.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Id = $"finding:{advisory.CveId ?? advisory.Id}:{component.Purl}",
|
||||
CveId = advisory.CveId ?? advisory.Id,
|
||||
Severity = advisory.Severity,
|
||||
AffectedComponent = component.Purl,
|
||||
ComponentVersion = component.Version,
|
||||
FixedVersions = advisory.FixedVersions
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort findings for determinism
|
||||
var sortedFindings = findings
|
||||
.OrderBy(f => f.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.AffectedComponent, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var diff = new DiffResult
|
||||
{
|
||||
Findings = sortedFindings,
|
||||
SbomDigest = ComputeHash(sbomData),
|
||||
AdvisoryDigest = advisories.ContentHash,
|
||||
DiffTimestamp = FrozenTimestamp
|
||||
};
|
||||
|
||||
return Task.FromResult(diff);
|
||||
}
|
||||
|
||||
private static SbomData ParseSbom(byte[] sbomData)
|
||||
{
|
||||
var json = System.Text.Encoding.UTF8.GetString(sbomData);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var components = new List<SbomComponent>();
|
||||
foreach (var element in doc.RootElement.GetProperty("components").EnumerateArray())
|
||||
{
|
||||
components.Add(new SbomComponent
|
||||
{
|
||||
Name = element.GetProperty("name").GetString()!,
|
||||
Version = element.GetProperty("version").GetString()!,
|
||||
Purl = element.GetProperty("purl").GetString()!
|
||||
});
|
||||
}
|
||||
|
||||
return new SbomData
|
||||
{
|
||||
Components = components.OrderBy(c => c.Purl, StringComparer.Ordinal).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stage 4: Decide
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates policy and computes verdict.
|
||||
/// </summary>
|
||||
public Task<DeltaVerdict> EvaluatePolicyAsync(DiffResult diff, byte[] policyPack, byte[]? vexDocument)
|
||||
{
|
||||
// Parse VEX document if provided for exception handling
|
||||
var exceptions = vexDocument is not null
|
||||
? ParseVexExceptions(vexDocument)
|
||||
: [];
|
||||
|
||||
// Evaluate findings against policy
|
||||
var blockingDrivers = new List<DeltaDriver>();
|
||||
var warningDrivers = new List<DeltaDriver>();
|
||||
var appliedExceptions = new List<string>();
|
||||
|
||||
foreach (var finding in diff.Findings)
|
||||
{
|
||||
// Check if finding is excepted via VEX
|
||||
var exception = exceptions.FirstOrDefault(e =>
|
||||
e.VulnerabilityId.Equals(finding.CveId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exception is not null)
|
||||
{
|
||||
appliedExceptions.Add(exception.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var driver = new DeltaDriver
|
||||
{
|
||||
Type = "new-finding",
|
||||
Severity = MapSeverity(finding.Severity),
|
||||
Description = $"Vulnerability {finding.CveId} found in {finding.AffectedComponent}",
|
||||
CveId = finding.CveId,
|
||||
Purl = finding.AffectedComponent
|
||||
};
|
||||
|
||||
if (IsBlockingSeverity(finding.Severity))
|
||||
{
|
||||
blockingDrivers.Add(driver);
|
||||
}
|
||||
else
|
||||
{
|
||||
warningDrivers.Add(driver);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort drivers for determinism
|
||||
blockingDrivers = [.. blockingDrivers.OrderBy(d => d.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(d => d.Purl, StringComparer.Ordinal)];
|
||||
|
||||
warningDrivers = [.. warningDrivers.OrderBy(d => d.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(d => d.Purl, StringComparer.Ordinal)];
|
||||
|
||||
appliedExceptions = [.. appliedExceptions.Order(StringComparer.Ordinal)];
|
||||
|
||||
// Compute gate level
|
||||
var gateLevel = blockingDrivers.Count > 0 ? DeltaGateLevel.G4 : DeltaGateLevel.G1;
|
||||
|
||||
// Build verdict with content-addressed ID
|
||||
var deltaId = $"delta:sha256:{ComputeHashString(System.Text.Encoding.UTF8.GetBytes(
|
||||
CanonJson.Serialize(new { diff.SbomDigest, diff.AdvisoryDigest })))}";
|
||||
|
||||
var builder = new DeltaVerdictBuilder()
|
||||
.WithGate(gateLevel);
|
||||
|
||||
foreach (var driver in blockingDrivers)
|
||||
{
|
||||
builder.AddBlockingDriver(driver);
|
||||
}
|
||||
|
||||
foreach (var driver in warningDrivers)
|
||||
{
|
||||
builder.AddWarningDriver(driver);
|
||||
}
|
||||
|
||||
foreach (var exception in appliedExceptions)
|
||||
{
|
||||
builder.AddException(exception);
|
||||
}
|
||||
|
||||
var verdict = builder.Build(deltaId);
|
||||
|
||||
return Task.FromResult(verdict);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VexException> ParseVexExceptions(byte[] vexData)
|
||||
{
|
||||
var json = System.Text.Encoding.UTF8.GetString(vexData);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var exceptions = new List<VexException>();
|
||||
if (doc.RootElement.TryGetProperty("statements", out var statements))
|
||||
{
|
||||
foreach (var stmt in statements.EnumerateArray())
|
||||
{
|
||||
if (stmt.GetProperty("status").GetString() == "not_affected")
|
||||
{
|
||||
exceptions.Add(new VexException
|
||||
{
|
||||
Id = stmt.GetProperty("id").GetString()!,
|
||||
VulnerabilityId = stmt.GetProperty("vulnerability").GetString()!,
|
||||
Status = "not_affected",
|
||||
Justification = stmt.TryGetProperty("justification", out var j) ? j.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return exceptions.OrderBy(e => e.VulnerabilityId, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static DeltaDriverSeverity MapSeverity(string severity) => severity.ToUpperInvariant() switch
|
||||
{
|
||||
"CRITICAL" => DeltaDriverSeverity.Critical,
|
||||
"HIGH" => DeltaDriverSeverity.High,
|
||||
"MEDIUM" => DeltaDriverSeverity.Medium,
|
||||
"LOW" => DeltaDriverSeverity.Low,
|
||||
_ => DeltaDriverSeverity.Unknown
|
||||
};
|
||||
|
||||
private static bool IsBlockingSeverity(string severity) =>
|
||||
severity.Equals("CRITICAL", StringComparison.OrdinalIgnoreCase) ||
|
||||
severity.Equals("HIGH", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stage 5: Attest
|
||||
|
||||
/// <summary>
|
||||
/// Creates DSSE attestation for the verdict.
|
||||
/// </summary>
|
||||
public Task<DsseEnvelopeData> CreateAttestationAsync(DeltaVerdict verdict)
|
||||
{
|
||||
// Serialize verdict to canonical JSON
|
||||
var payload = SerializeVerdict(verdict);
|
||||
|
||||
// Sign using deterministic key
|
||||
var signature = SignPayload(payload);
|
||||
|
||||
var envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.verdict+json",
|
||||
Payload = payload,
|
||||
Signatures =
|
||||
[
|
||||
new DsseSignatureData
|
||||
{
|
||||
KeyId = "e2e-test-key",
|
||||
Signature = signature
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return Task.FromResult(envelope);
|
||||
}
|
||||
|
||||
private byte[] SignPayload(byte[] payload)
|
||||
{
|
||||
// Create PAE (Pre-Authentication Encoding) as per DSSE spec
|
||||
var payloadType = "application/vnd.stellaops.verdict+json"u8.ToArray();
|
||||
var pae = CreatePae(payloadType, payload);
|
||||
|
||||
// Sign with ECDSA P-256
|
||||
return _signingKey!.SignData(pae, HashAlgorithmName.SHA256);
|
||||
}
|
||||
|
||||
private static byte[] CreatePae(byte[] payloadType, byte[] payload)
|
||||
{
|
||||
// PAE(type, payload) = "DSSEv1" || SP || LEN(type) || SP || type || SP || LEN(payload) || SP || payload
|
||||
var parts = new List<byte>();
|
||||
parts.AddRange("DSSEv1 "u8.ToArray());
|
||||
parts.AddRange(System.Text.Encoding.UTF8.GetBytes(payloadType.Length.ToString()));
|
||||
parts.Add((byte)' ');
|
||||
parts.AddRange(payloadType);
|
||||
parts.Add((byte)' ');
|
||||
parts.AddRange(System.Text.Encoding.UTF8.GetBytes(payload.Length.ToString()));
|
||||
parts.Add((byte)' ');
|
||||
parts.AddRange(payload);
|
||||
return [.. parts];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stage 6: Bundle
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle containing the attestation and all artifacts.
|
||||
/// </summary>
|
||||
public Task<BundleResult> CreateBundleAsync(DsseEnvelopeData envelope, InputSnapshot inputs)
|
||||
{
|
||||
// Create manifest with all artifact hashes
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "1.0",
|
||||
CreatedAt = FrozenTimestamp,
|
||||
Artifacts = new Dictionary<string, string>
|
||||
{
|
||||
["sbom"] = inputs.SbomHash,
|
||||
["advisory-feed"] = inputs.AdvisoryFeedHash,
|
||||
["policy-pack"] = inputs.PolicyPackHash,
|
||||
["envelope"] = ComputeHashString(SerializeEnvelope(envelope))
|
||||
}
|
||||
};
|
||||
|
||||
if (inputs.VexDocumentHash is not null)
|
||||
{
|
||||
manifest.Artifacts["vex-document"] = inputs.VexDocumentHash;
|
||||
}
|
||||
|
||||
// Serialize manifest deterministically
|
||||
var manifestBytes = System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(manifest));
|
||||
|
||||
var bundle = new BundleResult
|
||||
{
|
||||
Manifest = manifestBytes,
|
||||
Envelope = envelope,
|
||||
ManifestHash = ComputeHash(manifestBytes)
|
||||
};
|
||||
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Helpers
|
||||
|
||||
private static byte[] SerializeVerdict(DeltaVerdict verdict)
|
||||
{
|
||||
return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(verdict));
|
||||
}
|
||||
|
||||
private static byte[] SerializeEnvelope(DsseEnvelopeData envelope)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
payloadType = envelope.PayloadType,
|
||||
payload = Convert.ToBase64String(envelope.Payload),
|
||||
signatures = envelope.Signatures.Select(s => new
|
||||
{
|
||||
keyid = s.KeyId,
|
||||
sig = Convert.ToBase64String(s.Signature)
|
||||
}).ToArray()
|
||||
};
|
||||
return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(obj));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hashing Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 hash of data and returns as hex string.
|
||||
/// </summary>
|
||||
public static string ComputeHash(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 hash of data and returns hex string without prefix.
|
||||
/// </summary>
|
||||
public static string ComputeHashString(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Data Factories
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal SBOM for testing.
|
||||
/// </summary>
|
||||
public static byte[] CreateMinimalSbom()
|
||||
{
|
||||
var sbom = new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.5",
|
||||
version = 1,
|
||||
components = new[]
|
||||
{
|
||||
new { name = "lodash", version = "4.17.20", purl = "pkg:npm/lodash@4.17.20" },
|
||||
new { name = "axios", version = "0.21.0", purl = "pkg:npm/axios@0.21.0" },
|
||||
new { name = "moment", version = "2.29.0", purl = "pkg:npm/moment@2.29.0" }
|
||||
}
|
||||
};
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(sbom));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock advisory feed for testing.
|
||||
/// </summary>
|
||||
public static byte[] CreateMockAdvisoryFeed()
|
||||
{
|
||||
var feed = new
|
||||
{
|
||||
advisories = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "GHSA-2024-0001",
|
||||
cveId = "CVE-2024-0001",
|
||||
severity = "CRITICAL",
|
||||
affected = new[] { "pkg:npm/lodash@4.17.20" },
|
||||
@fixed = new[] { "pkg:npm/lodash@4.17.21" }
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "GHSA-2024-0002",
|
||||
cveId = "CVE-2024-0002",
|
||||
severity = "HIGH",
|
||||
affected = new[] { "pkg:npm/axios@0.21.0" },
|
||||
@fixed = new[] { "pkg:npm/axios@0.21.1" }
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "GHSA-2024-0003",
|
||||
cveId = "CVE-2024-0003",
|
||||
severity = "LOW",
|
||||
affected = new[] { "pkg:npm/moment@2.29.0" },
|
||||
@fixed = new[] { "pkg:npm/moment@2.29.4" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(feed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default policy pack for testing.
|
||||
/// </summary>
|
||||
public static byte[] CreateDefaultPolicyPack()
|
||||
{
|
||||
var policy = new
|
||||
{
|
||||
version = "1.0",
|
||||
rules = new[]
|
||||
{
|
||||
new { severity = "CRITICAL", action = "block" },
|
||||
new { severity = "HIGH", action = "block" },
|
||||
new { severity = "MEDIUM", action = "warn" },
|
||||
new { severity = "LOW", action = "warn" }
|
||||
}
|
||||
};
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(policy));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VEX document with exceptions.
|
||||
/// </summary>
|
||||
public static byte[] CreateVexDocumentWithExceptions(params string[] exceptedCveIds)
|
||||
{
|
||||
var statements = exceptedCveIds.Select((cve, i) => new
|
||||
{
|
||||
id = $"vex-exception-{i + 1:D3}",
|
||||
vulnerability = cve,
|
||||
status = "not_affected",
|
||||
justification = "vulnerable_code_not_in_execute_path"
|
||||
}).ToArray();
|
||||
|
||||
var vex = new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.test/vex/test-001",
|
||||
statements
|
||||
};
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(vex));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Key Generation
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic ECDSA key from a seed.
|
||||
/// </summary>
|
||||
private static ECDsa GenerateDeterministicKey(int seed)
|
||||
{
|
||||
// Use a deterministic RNG seeded from the input
|
||||
var rng = new DeterministicRng(seed);
|
||||
var keyBytes = new byte[32];
|
||||
rng.GetBytes(keyBytes);
|
||||
|
||||
// Create ECDSA key from the deterministic bytes
|
||||
var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
// Import deterministic private key
|
||||
var parameters = new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
D = keyBytes,
|
||||
Q = default // Will be computed from D
|
||||
};
|
||||
|
||||
// Compute public key from private key
|
||||
var tempKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
tempKey.ImportParameters(new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
D = keyBytes
|
||||
});
|
||||
var exported = tempKey.ExportParameters(true);
|
||||
parameters.Q = exported.Q;
|
||||
|
||||
ecdsa.ImportParameters(parameters);
|
||||
return ecdsa;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the public key for verification.
|
||||
/// </summary>
|
||||
public byte[] GetPublicKey()
|
||||
{
|
||||
return _signingKey!.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of the test fixture resources.
|
||||
/// </summary>
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_signingKey?.Dispose();
|
||||
_factory?.Dispose();
|
||||
|
||||
if (_postgresContainer is not null)
|
||||
{
|
||||
await _postgresContainer.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic random number generator for key generation.
|
||||
/// </summary>
|
||||
internal sealed class DeterministicRng(int seed)
|
||||
{
|
||||
private readonly Random _random = new(seed);
|
||||
|
||||
public void GetBytes(byte[] data)
|
||||
{
|
||||
_random.NextBytes(data);
|
||||
}
|
||||
}
|
||||
|
||||
#region Data Transfer Objects
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of all pipeline inputs with hashes.
|
||||
/// </summary>
|
||||
public sealed class InputSnapshot
|
||||
{
|
||||
public required byte[] Sbom { get; init; }
|
||||
public required string SbomHash { get; init; }
|
||||
public required byte[] AdvisoryFeed { get; init; }
|
||||
public required string AdvisoryFeedHash { get; init; }
|
||||
public required byte[] PolicyPack { get; init; }
|
||||
public required string PolicyPackHash { get; init; }
|
||||
public byte[]? VexDocument { get; init; }
|
||||
public string? VexDocumentHash { get; init; }
|
||||
public DateTimeOffset SnapshotTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of full pipeline execution.
|
||||
/// </summary>
|
||||
public sealed class PipelineResult
|
||||
{
|
||||
public required string VerdictId { get; init; }
|
||||
public required string VerdictHash { get; init; }
|
||||
public required string EnvelopeHash { get; init; }
|
||||
public required byte[] BundleManifest { get; init; }
|
||||
public required string BundleManifestHash { get; init; }
|
||||
public DateTimeOffset ExecutionTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory record from ingestion.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required IReadOnlyList<string> AffectedPackages { get; init; }
|
||||
public IReadOnlyList<string> FixedVersions { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized advisories after deduplication.
|
||||
/// </summary>
|
||||
public sealed class NormalizedAdvisories
|
||||
{
|
||||
public required IReadOnlyList<AdvisoryRecord> Advisories { get; init; }
|
||||
public DateTimeOffset NormalizationTimestamp { get; init; }
|
||||
public required string ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component data.
|
||||
/// </summary>
|
||||
public sealed class SbomComponent
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed SBOM data.
|
||||
/// </summary>
|
||||
public sealed class SbomData
|
||||
{
|
||||
public required IReadOnlyList<SbomComponent> Components { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Security finding from diff.
|
||||
/// </summary>
|
||||
public sealed class Finding
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string AffectedComponent { get; init; }
|
||||
public required string ComponentVersion { get; init; }
|
||||
public IReadOnlyList<string> FixedVersions { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of diffing SBOM against advisories.
|
||||
/// </summary>
|
||||
public sealed class DiffResult
|
||||
{
|
||||
public required IReadOnlyList<Finding> Findings { get; init; }
|
||||
public required string SbomDigest { get; init; }
|
||||
public required string AdvisoryDigest { get; init; }
|
||||
public DateTimeOffset DiffTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX exception from VEX document.
|
||||
/// </summary>
|
||||
public sealed class VexException
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope data.
|
||||
/// </summary>
|
||||
public sealed class DsseEnvelopeData
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required byte[] Payload { get; init; }
|
||||
public required IReadOnlyList<DsseSignatureData> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature data.
|
||||
/// </summary>
|
||||
public sealed class DsseSignatureData
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required byte[] Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle manifest structure.
|
||||
/// </summary>
|
||||
public sealed class BundleManifest
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public required Dictionary<string, string> Artifacts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle creation.
|
||||
/// </summary>
|
||||
public sealed class BundleResult
|
||||
{
|
||||
public required byte[] Manifest { get; init; }
|
||||
public required DsseEnvelopeData Envelope { get; init; }
|
||||
public required string ManifestHash { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for Program class detection.
|
||||
/// The actual Program class is from Scanner.WebService.
|
||||
/// </summary>
|
||||
#pragma warning disable CA1050 // Declare types in namespaces
|
||||
public partial class Program { }
|
||||
#pragma warning restore CA1050
|
||||
Reference in New Issue
Block a user