952 lines
30 KiB
C#
952 lines
30 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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
|