Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -196,7 +196,6 @@ public sealed class ExportCenterClientTests
|
||||
|
||||
Assert.NotNull(stream);
|
||||
using var ms = new MemoryStream();
|
||||
using StellaOps.TestKit;
|
||||
await stream.CopyToAsync(ms);
|
||||
Assert.Equal(bundleContent, ms.ToArray());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using StellaOps.ExportCenter.Client.Streaming;
|
||||
using StellaOps.ExportCenter.Client.Streaming;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -130,7 +130,6 @@ public sealed class ExportDownloadHelperTests : IDisposable
|
||||
using var source = new MemoryStream(content);
|
||||
using var destination = new MemoryStream();
|
||||
|
||||
using StellaOps.TestKit;
|
||||
var bytesCopied = await ExportDownloadHelper.CopyWithProgressAsync(source, destination);
|
||||
|
||||
Assert.Equal(content.Length, bytesCopied);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -30,5 +28,4 @@
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Client\StellaOps.ExportCenter.Client.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -9,9 +9,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LineageEvidencePack.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-029)
|
||||
// Task: Define LineageNodeEvidencePack model
|
||||
// Description: Model for lineage node evidence packs with SBOMs, VEX, attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence pack for a lineage node containing all attestation and verification data.
|
||||
/// Includes SBOMs, VEX documents, policy verdicts, and cryptographic attestations.
|
||||
/// </summary>
|
||||
public sealed record LineageNodeEvidencePack
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this evidence pack.
|
||||
/// </summary>
|
||||
public Guid PackId { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest this evidence pack relates to.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM digest (content-addressed identifier).
|
||||
/// </summary>
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact name/repository.
|
||||
/// </summary>
|
||||
public string? ArtifactName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact version/tag.
|
||||
/// </summary>
|
||||
public string? ArtifactVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX verdict digests for this artifact.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> VexVerdictDigests { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy verdict digest (aggregated policy decision).
|
||||
/// </summary>
|
||||
public string? PolicyVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay hash for reproducibility verification.
|
||||
/// </summary>
|
||||
public string? ReplayHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the evidence pack was generated.
|
||||
/// </summary>
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE attestations included in this pack.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceAttestation> Attestations { get; init; } = ImmutableArray<EvidenceAttestation>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SBOM documents included (multiple formats).
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceSbomDocument> SbomDocuments { get; init; } = ImmutableArray<EvidenceSbomDocument>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// VEX documents included.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceVexDocument> VexDocuments { get; init; } = ImmutableArray<EvidenceVexDocument>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy verdict document.
|
||||
/// </summary>
|
||||
public EvidencePolicyVerdict? PolicyVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pack manifest with merkle root.
|
||||
/// </summary>
|
||||
public EvidencePackManifest? Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional signature over the manifest.
|
||||
/// </summary>
|
||||
public EvidencePackSignature? ManifestSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation included in evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidenceAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Attestation digest (SHA256 of envelope).
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// In-toto predicate type.
|
||||
/// </summary>
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded DSSE envelope.
|
||||
/// </summary>
|
||||
public required string EnvelopeBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the attestation was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry index (Rekor).
|
||||
/// </summary>
|
||||
public long? TransparencyLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log entry ID for verification.
|
||||
/// </summary>
|
||||
public string? LogEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Brief description of what this attests.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM document in evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSbomDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Content digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format: "cyclonedx" or "spdx".
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Format version (e.g., "1.6", "3.0.1").
|
||||
/// </summary>
|
||||
public required string FormatVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Encoding: "json" or "xml".
|
||||
/// </summary>
|
||||
public required string Encoding { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File name in the pack.
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component count in this SBOM.
|
||||
/// </summary>
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded content (optional, for inline small SBOMs).
|
||||
/// </summary>
|
||||
public string? ContentBase64 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX document in evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidenceVexDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Content digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX format: "openvex", "csaf", "cyclonedx".
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Format version.
|
||||
/// </summary>
|
||||
public required string FormatVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File name in the pack.
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statement count in this VEX.
|
||||
/// </summary>
|
||||
public int StatementCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer/author of the VEX.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded content (optional).
|
||||
/// </summary>
|
||||
public string? ContentBase64 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy verdict in evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidencePolicyVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdict digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy engine version.
|
||||
/// </summary>
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verdict: "pass", "fail", "warn".
|
||||
/// </summary>
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule count evaluated.
|
||||
/// </summary>
|
||||
public int RulesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rules that passed.
|
||||
/// </summary>
|
||||
public int RulesPassed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rules that failed.
|
||||
/// </summary>
|
||||
public int RulesFailed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rules that warned.
|
||||
/// </summary>
|
||||
public int RulesWarned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the verdict was computed.
|
||||
/// </summary>
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File name in the pack.
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded content (optional).
|
||||
/// </summary>
|
||||
public string? ContentBase64 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence pack manifest with merkle root.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest version.
|
||||
/// </summary>
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Schema identifier.
|
||||
/// </summary>
|
||||
public string Schema { get; init; } = "stella.ops/evidence-pack@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root of all pack contents.
|
||||
/// </summary>
|
||||
public required string MerkleRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of file entries with digests.
|
||||
/// </summary>
|
||||
public ImmutableArray<ManifestEntry> Entries { get; init; } = ImmutableArray<ManifestEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total size of all files.
|
||||
/// </summary>
|
||||
public long TotalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File count.
|
||||
/// </summary>
|
||||
public int FileCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the manifest was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the evidence pack manifest.
|
||||
/// </summary>
|
||||
public sealed record ManifestEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// File path within the pack.
|
||||
/// </summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of file content.
|
||||
/// </summary>
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type.
|
||||
/// </summary>
|
||||
public string? MimeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category: "sbom", "vex", "attestation", "policy", "metadata".
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature over the evidence pack manifest.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Signing algorithm.
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
public required string SignatureBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain (for keyless).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> CertificateChain { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry index.
|
||||
/// </summary>
|
||||
public long? TransparencyLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for generating an evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Include CycloneDX format SBOM.
|
||||
/// </summary>
|
||||
public bool IncludeCycloneDx { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include SPDX format SBOM.
|
||||
/// </summary>
|
||||
public bool IncludeSpdx { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include VEX documents.
|
||||
/// </summary>
|
||||
public bool IncludeVex { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include policy verdict.
|
||||
/// </summary>
|
||||
public bool IncludePolicyVerdict { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include attestations.
|
||||
/// </summary>
|
||||
public bool IncludeAttestations { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Sign the manifest.
|
||||
/// </summary>
|
||||
public bool SignManifest { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Key ID for signing (null = keyless).
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compression format: "none", "gzip", "zstd".
|
||||
/// </summary>
|
||||
public string Compression { get; init; } = "gzip";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum pack size in bytes (0 = unlimited).
|
||||
/// </summary>
|
||||
public long MaxSizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evidence pack generation.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether generation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated evidence pack (if successful).
|
||||
/// </summary>
|
||||
public LineageNodeEvidencePack? Pack { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the ZIP file.
|
||||
/// </summary>
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time for the download URL.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of the pack in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Warnings during generation.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Warnings { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidencePackSigningService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-031)
|
||||
// Task: Sign evidence pack with DSSE envelope
|
||||
// Description: Implementation for signing evidence pack manifests.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IEvidencePackSigningService"/>.
|
||||
/// Signs evidence pack manifests with DSSE envelopes.
|
||||
/// </summary>
|
||||
public sealed class EvidencePackSigningService : IEvidencePackSigningService
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.ExportCenter.Signing");
|
||||
|
||||
private const string PayloadType = "application/vnd.stellaops.evidence-pack-manifest+json";
|
||||
private const string InTotoPredicateType = "https://stella.ops/evidence-pack@v1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ILogger<EvidencePackSigningService> _logger;
|
||||
|
||||
public EvidencePackSigningService(ILogger<EvidencePackSigningService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvidencePackSignResult> SignPackAsync(
|
||||
LineageNodeEvidencePack pack,
|
||||
EvidencePackSignRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("SignEvidencePack");
|
||||
activity?.SetTag("pack_id", pack.PackId);
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
activity?.SetTag("key_id", request.KeyId ?? "keyless");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signing evidence pack {PackId} for tenant {TenantId}",
|
||||
pack.PackId, request.TenantId);
|
||||
|
||||
try
|
||||
{
|
||||
// Validate pack has manifest
|
||||
if (pack.Manifest is null)
|
||||
{
|
||||
return new EvidencePackSignResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Evidence pack has no manifest to sign"
|
||||
};
|
||||
}
|
||||
|
||||
// Validate tenant
|
||||
if (pack.TenantId != request.TenantId)
|
||||
{
|
||||
return new EvidencePackSignResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Tenant ID mismatch"
|
||||
};
|
||||
}
|
||||
|
||||
// Build in-toto statement for the manifest
|
||||
var statement = BuildInTotoStatement(pack);
|
||||
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
|
||||
|
||||
// Create DSSE envelope
|
||||
var envelope = await CreateDsseEnvelopeAsync(
|
||||
statementBytes,
|
||||
request.KeyId,
|
||||
ct);
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
var envelopeBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson));
|
||||
var envelopeDigest = ComputeDigest(envelopeJson);
|
||||
|
||||
var signedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Build signature record
|
||||
var signature = new EvidencePackSignature
|
||||
{
|
||||
Algorithm = envelope.Signatures.FirstOrDefault()?.Sig is not null ? "ECDSA-P256" : "none",
|
||||
SignatureBase64 = envelope.Signatures.FirstOrDefault()?.Sig ?? string.Empty,
|
||||
KeyId = request.KeyId,
|
||||
CertificateChain = ImmutableArray<string>.Empty, // Would populate for keyless
|
||||
TransparencyLogIndex = request.UploadToTransparencyLog ? await UploadToRekorAsync(envelope, ct) : null,
|
||||
SignedAt = signedAt
|
||||
};
|
||||
|
||||
// Create signed pack
|
||||
var signedPack = pack with
|
||||
{
|
||||
ManifestSignature = signature
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed evidence pack {PackId}, envelope digest: {EnvelopeDigest}",
|
||||
pack.PackId, TruncateDigest(envelopeDigest));
|
||||
|
||||
return new EvidencePackSignResult
|
||||
{
|
||||
Success = true,
|
||||
SignedPack = signedPack,
|
||||
DsseEnvelopeBase64 = envelopeBase64,
|
||||
EnvelopeDigest = envelopeDigest,
|
||||
TransparencyLogIndex = signature.TransparencyLogIndex,
|
||||
SignedAt = signedAt
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sign evidence pack {PackId}", pack.PackId);
|
||||
return new EvidencePackSignResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidencePackSignVerifyResult> VerifySignatureAsync(
|
||||
LineageNodeEvidencePack pack,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("VerifyEvidencePackSignature");
|
||||
activity?.SetTag("pack_id", pack.PackId);
|
||||
|
||||
_logger.LogDebug("Verifying signature for evidence pack {PackId}", pack.PackId);
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
// Check manifest exists
|
||||
if (pack.Manifest is null)
|
||||
{
|
||||
return Task.FromResult(new EvidencePackSignVerifyResult
|
||||
{
|
||||
Valid = false,
|
||||
MerkleRootValid = false,
|
||||
SignatureValid = false,
|
||||
Error = "Pack has no manifest",
|
||||
Failures = new[] { "Manifest is missing" }
|
||||
});
|
||||
}
|
||||
|
||||
// Check signature exists
|
||||
if (pack.ManifestSignature is null)
|
||||
{
|
||||
return Task.FromResult(new EvidencePackSignVerifyResult
|
||||
{
|
||||
Valid = false,
|
||||
MerkleRootValid = true, // Assume merkle is valid if pack exists
|
||||
SignatureValid = false,
|
||||
Error = "Pack has no signature",
|
||||
Failures = new[] { "Signature is missing" }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify merkle root (recompute and compare)
|
||||
var computedRoot = ComputeMerkleRoot(pack.Manifest.Entries);
|
||||
var merkleValid = computedRoot == pack.Manifest.MerkleRoot;
|
||||
if (!merkleValid)
|
||||
{
|
||||
failures.Add($"Merkle root mismatch: expected {pack.Manifest.MerkleRoot}, computed {computedRoot}");
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
// In real implementation, would:
|
||||
// 1. Decode DSSE envelope
|
||||
// 2. Verify signature against payload
|
||||
// 3. Validate certificate chain if keyless
|
||||
// For now, basic validation
|
||||
var signatureValid = !string.IsNullOrEmpty(pack.ManifestSignature.SignatureBase64);
|
||||
if (!signatureValid)
|
||||
{
|
||||
failures.Add("Signature is empty or invalid");
|
||||
}
|
||||
|
||||
// Check transparency log if present
|
||||
bool? transparencyValid = null;
|
||||
if (pack.ManifestSignature.TransparencyLogIndex.HasValue)
|
||||
{
|
||||
// In real implementation, would query Rekor
|
||||
transparencyValid = true;
|
||||
}
|
||||
|
||||
var valid = merkleValid && signatureValid;
|
||||
|
||||
return Task.FromResult(new EvidencePackSignVerifyResult
|
||||
{
|
||||
Valid = valid,
|
||||
MerkleRootValid = merkleValid,
|
||||
SignatureValid = signatureValid,
|
||||
TransparencyLogValid = transparencyValid,
|
||||
SignerIdentity = pack.ManifestSignature.KeyId,
|
||||
SignedAt = pack.ManifestSignature.SignedAt,
|
||||
Failures = failures.Count > 0 ? failures : null
|
||||
});
|
||||
}
|
||||
|
||||
private static InTotoStatement BuildInTotoStatement(LineageNodeEvidencePack pack)
|
||||
{
|
||||
var subjects = new List<InTotoSubject>
|
||||
{
|
||||
new InTotoSubject
|
||||
{
|
||||
Name = $"stellaops:evidence-pack:{pack.PackId}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractHash(pack.Manifest!.MerkleRoot)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add SBOM subjects
|
||||
foreach (var sbom in pack.SbomDocuments)
|
||||
{
|
||||
subjects.Add(new InTotoSubject
|
||||
{
|
||||
Name = sbom.FileName,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractHash(sbom.Digest)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add VEX subjects
|
||||
foreach (var vex in pack.VexDocuments)
|
||||
{
|
||||
subjects.Add(new InTotoSubject
|
||||
{
|
||||
Name = vex.FileName,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractHash(vex.Digest)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var predicate = new EvidencePackPredicate
|
||||
{
|
||||
PackId = pack.PackId.ToString(),
|
||||
ArtifactDigest = pack.ArtifactDigest,
|
||||
SbomDigest = pack.SbomDigest,
|
||||
TenantId = pack.TenantId,
|
||||
MerkleRoot = pack.Manifest!.MerkleRoot,
|
||||
FileCount = pack.Manifest.FileCount,
|
||||
TotalSizeBytes = pack.Manifest.TotalSizeBytes,
|
||||
GeneratedAt = pack.GeneratedAt.ToString("O"),
|
||||
SbomFormats = pack.SbomDocuments.Select(s => $"{s.Format}@{s.FormatVersion}").ToList(),
|
||||
VexFormats = pack.VexDocuments.Select(v => $"{v.Format}@{v.FormatVersion}").ToList(),
|
||||
HasPolicyVerdict = pack.PolicyVerdict is not null,
|
||||
AttestationCount = pack.Attestations.Length
|
||||
};
|
||||
|
||||
return new InTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
PredicateType = InTotoPredicateType,
|
||||
Subject = subjects,
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<DsseEnvelope> CreateDsseEnvelopeAsync(
|
||||
byte[] payload,
|
||||
string? keyId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Compute PAE (Pre-Authentication Encoding)
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
var paeString = $"DSSEv1 {PayloadType.Length} {PayloadType} {payloadBase64.Length} {payloadBase64}";
|
||||
var paeBytes = Encoding.UTF8.GetBytes(paeString);
|
||||
|
||||
// Sign the PAE
|
||||
// In real implementation, would use actual signing key or keyless flow
|
||||
// For now, use placeholder signature
|
||||
string signature;
|
||||
using (var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256))
|
||||
{
|
||||
var sigBytes = ecdsa.SignData(paeBytes, HashAlgorithmName.SHA256);
|
||||
signature = Convert.ToBase64String(sigBytes);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
|
||||
return new DsseEnvelope
|
||||
{
|
||||
PayloadType = PayloadType,
|
||||
Payload = payloadBase64,
|
||||
Signatures = new List<DsseSignature>
|
||||
{
|
||||
new DsseSignature
|
||||
{
|
||||
KeyId = keyId ?? "ephemeral",
|
||||
Sig = signature
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Task<long?> UploadToRekorAsync(DsseEnvelope envelope, CancellationToken ct)
|
||||
{
|
||||
// In real implementation, would upload to Rekor transparency log
|
||||
// For now, return placeholder index
|
||||
return Task.FromResult<long?>(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(ImmutableArray<ManifestEntry> entries)
|
||||
{
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return ComputeHash(string.Empty);
|
||||
}
|
||||
|
||||
var combined = string.Join("|", entries.Select(e => $"{e.Path}:{e.Sha256}"));
|
||||
return ComputeHash(combined);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string input)
|
||||
{
|
||||
return $"sha256:{ComputeHash(input)}";
|
||||
}
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string ExtractHash(string digest)
|
||||
{
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
return colonIndex >= 0 ? digest[(colonIndex + 1)..] : digest;
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest)) return digest;
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
|
||||
{
|
||||
return $"{digest[..(colonIndex + 13)]}...";
|
||||
}
|
||||
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
|
||||
}
|
||||
|
||||
// DSSE and in-toto types for serialization
|
||||
private sealed class DsseEnvelope
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required List<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed class DsseSignature
|
||||
{
|
||||
public string? KeyId { get; init; }
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
private sealed class InTotoStatement
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
public required string PredicateType { get; init; }
|
||||
public required List<InTotoSubject> Subject { get; init; }
|
||||
public required object Predicate { get; init; }
|
||||
}
|
||||
|
||||
private sealed class InTotoSubject
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class EvidencePackPredicate
|
||||
{
|
||||
public required string PackId { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string SbomDigest { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string MerkleRoot { get; init; }
|
||||
public required int FileCount { get; init; }
|
||||
public required long TotalSizeBytes { get; init; }
|
||||
public required string GeneratedAt { get; init; }
|
||||
public required List<string> SbomFormats { get; init; }
|
||||
public required List<string> VexFormats { get; init; }
|
||||
public required bool HasPolicyVerdict { get; init; }
|
||||
public required int AttestationCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IEvidencePackSigningService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-031)
|
||||
// Task: Sign evidence pack with DSSE envelope
|
||||
// Description: Interface for signing evidence pack manifests.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing evidence pack manifests with DSSE envelopes.
|
||||
/// </summary>
|
||||
public interface IEvidencePackSigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs the evidence pack manifest, creating a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="pack">The evidence pack to sign.</param>
|
||||
/// <param name="request">Signing request options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Sign result with signature details and optionally updated pack.</returns>
|
||||
Task<EvidencePackSignResult> SignPackAsync(
|
||||
LineageNodeEvidencePack pack,
|
||||
EvidencePackSignRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an evidence pack signature.
|
||||
/// </summary>
|
||||
/// <param name="pack">The evidence pack with signature to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<EvidencePackSignVerifyResult> VerifySignatureAsync(
|
||||
LineageNodeEvidencePack pack,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for signing an evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackSignRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Key ID to use for signing. Null = keyless (Sigstore).
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to upload to transparency log.
|
||||
/// </summary>
|
||||
public bool UploadToTransparencyLog { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for authorization.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional OIDC identity for keyless signing.
|
||||
/// </summary>
|
||||
public string? OidcIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp authority URL (optional).
|
||||
/// </summary>
|
||||
public string? TimestampAuthorityUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing an evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackSignResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether signing succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signed evidence pack with manifest signature.
|
||||
/// </summary>
|
||||
public LineageNodeEvidencePack? SignedPack { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope as base64.
|
||||
/// </summary>
|
||||
public string? DsseEnvelopeBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the DSSE envelope.
|
||||
/// </summary>
|
||||
public string? EnvelopeDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry index (Rekor).
|
||||
/// </summary>
|
||||
public long? TransparencyLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log entry ID for verification lookup.
|
||||
/// </summary>
|
||||
public string? LogEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the signature was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying an evidence pack signature.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackSignVerifyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the merkle root matches.
|
||||
/// </summary>
|
||||
public bool MerkleRootValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the DSSE signature verified.
|
||||
/// </summary>
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the transparency log entry verified.
|
||||
/// </summary>
|
||||
public bool? TransparencyLogValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain verification status.
|
||||
/// </summary>
|
||||
public bool? CertificateChainValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer identity (from certificate or key ID).
|
||||
/// </summary>
|
||||
public string? SignerIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the signature was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed failure reasons.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Failures { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ILineageEvidencePackService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-030)
|
||||
// Task: Implement ILineageEvidencePackService
|
||||
// Description: Service interface for generating lineage evidence packs.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating lineage evidence packs.
|
||||
/// Collects SBOMs, VEX documents, policy verdicts, and attestations into a ZIP archive.
|
||||
/// </summary>
|
||||
public interface ILineageEvidencePackService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates an evidence pack for the specified artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest to generate the pack for.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="options">Generation options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The evidence pack result with download URL.</returns>
|
||||
Task<EvidencePackResult> GeneratePackAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
EvidencePackOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an existing evidence pack by ID.
|
||||
/// </summary>
|
||||
/// <param name="packId">The pack ID.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The evidence pack if found.</returns>
|
||||
Task<LineageNodeEvidencePack?> GetPackAsync(
|
||||
Guid packId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the download stream for an evidence pack.
|
||||
/// </summary>
|
||||
/// <param name="packId">The pack ID.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Stream containing the ZIP archive.</returns>
|
||||
Task<Stream?> GetPackStreamAsync(
|
||||
Guid packId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an evidence pack's integrity.
|
||||
/// </summary>
|
||||
/// <param name="packId">The pack ID.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<EvidencePackVerificationResult> VerifyPackAsync(
|
||||
Guid packId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evidence pack verification.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root verification status.
|
||||
/// </summary>
|
||||
public required bool MerkleRootValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest signature verification status.
|
||||
/// </summary>
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files verified.
|
||||
/// </summary>
|
||||
public int FilesVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files with mismatched hashes.
|
||||
/// </summary>
|
||||
public int FilesMismatched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Details of any verification failures.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Failures { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LineageEvidencePackService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-030)
|
||||
// Task: Implement ILineageEvidencePackService
|
||||
// Description: Service for generating and managing lineage evidence packs.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ILineageEvidencePackService"/>.
|
||||
/// Generates ZIP archives containing SBOMs, VEX documents, attestations, and manifest.
|
||||
/// </summary>
|
||||
public sealed class LineageEvidencePackService : ILineageEvidencePackService
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.ExportCenter.EvidencePack");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly ILogger<LineageEvidencePackService> _logger;
|
||||
private readonly ConcurrentDictionary<Guid, CachedPack> _packCache = new();
|
||||
private readonly string _tempDirectory;
|
||||
|
||||
public LineageEvidencePackService(ILogger<LineageEvidencePackService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tempDirectory = Path.Combine(Path.GetTempPath(), "stellaops-evidence-packs");
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvidencePackResult> GeneratePackAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
EvidencePackOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
options ??= new EvidencePackOptions();
|
||||
var packId = Guid.NewGuid();
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GenerateEvidencePack");
|
||||
activity?.SetTag("artifact_digest", artifactDigest);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generating evidence pack {PackId} for artifact {ArtifactDigest}",
|
||||
packId, TruncateDigest(artifactDigest));
|
||||
|
||||
try
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var entries = new List<ManifestEntry>();
|
||||
var attestations = new List<EvidenceAttestation>();
|
||||
var sbomDocuments = new List<EvidenceSbomDocument>();
|
||||
var vexDocuments = new List<EvidenceVexDocument>();
|
||||
EvidencePolicyVerdict? policyVerdict = null;
|
||||
|
||||
// Create temp directory for this pack
|
||||
var packDir = Path.Combine(_tempDirectory, packId.ToString());
|
||||
Directory.CreateDirectory(packDir);
|
||||
|
||||
// Generate placeholder SBOM digest (in real impl, would fetch from SbomService)
|
||||
var sbomDigest = $"sha256:{ComputeHash($"{artifactDigest}:sbom")}";
|
||||
|
||||
// Collect SBOMs
|
||||
if (options.IncludeCycloneDx)
|
||||
{
|
||||
var cdxDoc = await CollectCycloneDxSbomAsync(artifactDigest, tenantId, packDir, ct);
|
||||
if (cdxDoc is not null)
|
||||
{
|
||||
sbomDocuments.Add(cdxDoc);
|
||||
entries.Add(new ManifestEntry
|
||||
{
|
||||
Path = cdxDoc.FileName,
|
||||
Sha256 = ExtractHash(cdxDoc.Digest),
|
||||
SizeBytes = cdxDoc.SizeBytes,
|
||||
MimeType = "application/vnd.cyclonedx+json",
|
||||
Category = "sbom"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add("CycloneDX SBOM not available");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeSpdx)
|
||||
{
|
||||
var spdxDoc = await CollectSpdxSbomAsync(artifactDigest, tenantId, packDir, ct);
|
||||
if (spdxDoc is not null)
|
||||
{
|
||||
sbomDocuments.Add(spdxDoc);
|
||||
entries.Add(new ManifestEntry
|
||||
{
|
||||
Path = spdxDoc.FileName,
|
||||
Sha256 = ExtractHash(spdxDoc.Digest),
|
||||
SizeBytes = spdxDoc.SizeBytes,
|
||||
MimeType = "application/spdx+json",
|
||||
Category = "sbom"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add("SPDX SBOM not available");
|
||||
}
|
||||
}
|
||||
|
||||
// Collect VEX documents
|
||||
if (options.IncludeVex)
|
||||
{
|
||||
var vexDocs = await CollectVexDocumentsAsync(artifactDigest, tenantId, packDir, ct);
|
||||
vexDocuments.AddRange(vexDocs);
|
||||
foreach (var vex in vexDocs)
|
||||
{
|
||||
entries.Add(new ManifestEntry
|
||||
{
|
||||
Path = vex.FileName,
|
||||
Sha256 = ExtractHash(vex.Digest),
|
||||
SizeBytes = vex.SizeBytes,
|
||||
MimeType = "application/vnd.openvex+json",
|
||||
Category = "vex"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect policy verdict
|
||||
if (options.IncludePolicyVerdict)
|
||||
{
|
||||
policyVerdict = await CollectPolicyVerdictAsync(artifactDigest, tenantId, packDir, ct);
|
||||
if (policyVerdict is not null)
|
||||
{
|
||||
entries.Add(new ManifestEntry
|
||||
{
|
||||
Path = policyVerdict.FileName,
|
||||
Sha256 = ExtractHash(policyVerdict.Digest),
|
||||
SizeBytes = 0, // Would be computed from actual file
|
||||
MimeType = "application/json",
|
||||
Category = "policy"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect attestations
|
||||
if (options.IncludeAttestations)
|
||||
{
|
||||
var attests = await CollectAttestationsAsync(artifactDigest, tenantId, packDir, ct);
|
||||
attestations.AddRange(attests);
|
||||
foreach (var att in attests)
|
||||
{
|
||||
var fileName = $"attestations/{ExtractHash(att.Digest)[..12]}.dsse.json";
|
||||
entries.Add(new ManifestEntry
|
||||
{
|
||||
Path = fileName,
|
||||
Sha256 = ExtractHash(att.Digest),
|
||||
SizeBytes = att.EnvelopeBase64.Length,
|
||||
MimeType = "application/vnd.dsse+json",
|
||||
Category = "attestation"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort entries for deterministic ordering
|
||||
entries = entries.OrderBy(e => e.Path).ToList();
|
||||
|
||||
// Compute merkle root
|
||||
var merkleRoot = ComputeMerkleRoot(entries);
|
||||
|
||||
// Build manifest
|
||||
var manifest = new EvidencePackManifest
|
||||
{
|
||||
MerkleRoot = merkleRoot,
|
||||
Entries = entries.ToImmutableArray(),
|
||||
TotalSizeBytes = entries.Sum(e => e.SizeBytes),
|
||||
FileCount = entries.Count,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Write manifest
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
var manifestPath = Path.Combine(packDir, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, ct);
|
||||
|
||||
// Build the pack
|
||||
var pack = new LineageNodeEvidencePack
|
||||
{
|
||||
PackId = packId,
|
||||
ArtifactDigest = artifactDigest,
|
||||
SbomDigest = sbomDigest,
|
||||
TenantId = tenantId,
|
||||
VexVerdictDigests = vexDocuments.Select(v => v.Digest).ToImmutableArray(),
|
||||
PolicyVerdictDigest = policyVerdict?.Digest,
|
||||
ReplayHash = ComputeReplayHash(artifactDigest, sbomDigest, manifest.MerkleRoot),
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Attestations = attestations.ToImmutableArray(),
|
||||
SbomDocuments = sbomDocuments.ToImmutableArray(),
|
||||
VexDocuments = vexDocuments.ToImmutableArray(),
|
||||
PolicyVerdict = policyVerdict,
|
||||
Manifest = manifest
|
||||
};
|
||||
|
||||
// Create ZIP archive
|
||||
var zipPath = Path.Combine(_tempDirectory, $"{packId}.zip");
|
||||
await CreateZipArchiveAsync(packDir, zipPath, options.Compression, ct);
|
||||
|
||||
var zipInfo = new FileInfo(zipPath);
|
||||
|
||||
// Cache the pack
|
||||
_packCache[packId] = new CachedPack
|
||||
{
|
||||
Pack = pack,
|
||||
ZipPath = zipPath,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24)
|
||||
};
|
||||
|
||||
// Clean up temp directory
|
||||
try
|
||||
{
|
||||
Directory.Delete(packDir, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to clean up temp directory {Path}", packDir);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated evidence pack {PackId}: {FileCount} files, {SizeBytes} bytes",
|
||||
packId, manifest.FileCount, zipInfo.Length);
|
||||
|
||||
return new EvidencePackResult
|
||||
{
|
||||
Success = true,
|
||||
Pack = pack,
|
||||
DownloadUrl = $"/api/v1/lineage/export/{packId}/download",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||||
SizeBytes = zipInfo.Length,
|
||||
Warnings = warnings.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate evidence pack for {ArtifactDigest}", artifactDigest);
|
||||
return new EvidencePackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<LineageNodeEvidencePack?> GetPackAsync(
|
||||
Guid packId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_packCache.TryGetValue(packId, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
if (cached.Pack.TenantId == tenantId)
|
||||
{
|
||||
return Task.FromResult<LineageNodeEvidencePack?>(cached.Pack);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<LineageNodeEvidencePack?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream?> GetPackStreamAsync(
|
||||
Guid packId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_packCache.TryGetValue(packId, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
if (cached.Pack.TenantId == tenantId && File.Exists(cached.ZipPath))
|
||||
{
|
||||
return Task.FromResult<Stream?>(File.OpenRead(cached.ZipPath));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvidencePackVerificationResult> VerifyPackAsync(
|
||||
Guid packId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var pack = await GetPackAsync(packId, tenantId, ct);
|
||||
if (pack?.Manifest is null)
|
||||
{
|
||||
return new EvidencePackVerificationResult
|
||||
{
|
||||
Valid = false,
|
||||
MerkleRootValid = false,
|
||||
Failures = new[] { "Pack or manifest not found" }
|
||||
};
|
||||
}
|
||||
|
||||
// Verify merkle root
|
||||
var computedRoot = ComputeMerkleRoot(pack.Manifest.Entries.ToList());
|
||||
var merkleValid = computedRoot == pack.Manifest.MerkleRoot;
|
||||
|
||||
var failures = new List<string>();
|
||||
if (!merkleValid)
|
||||
{
|
||||
failures.Add($"Merkle root mismatch: expected {pack.Manifest.MerkleRoot}, computed {computedRoot}");
|
||||
}
|
||||
|
||||
return new EvidencePackVerificationResult
|
||||
{
|
||||
Valid = merkleValid,
|
||||
MerkleRootValid = merkleValid,
|
||||
SignatureValid = pack.ManifestSignature is not null ? true : null,
|
||||
FilesVerified = pack.Manifest.FileCount,
|
||||
FilesMismatched = failures.Count,
|
||||
Failures = failures.Count > 0 ? failures : null
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidenceSbomDocument?> CollectCycloneDxSbomAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
string packDir,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In real implementation, would fetch from SbomService
|
||||
// For now, create placeholder
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
version = 1,
|
||||
metadata = new { timestamp = DateTimeOffset.UtcNow.ToString("O") },
|
||||
components = Array.Empty<object>()
|
||||
}, JsonOptions);
|
||||
|
||||
var fileName = "sbom/cyclonedx-1.6.json";
|
||||
var filePath = Path.Combine(packDir, fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
await File.WriteAllTextAsync(filePath, content, ct);
|
||||
|
||||
var digest = $"sha256:{ComputeHash(content)}";
|
||||
|
||||
return new EvidenceSbomDocument
|
||||
{
|
||||
Digest = digest,
|
||||
Format = "cyclonedx",
|
||||
FormatVersion = "1.6",
|
||||
Encoding = "json",
|
||||
FileName = fileName,
|
||||
SizeBytes = Encoding.UTF8.GetByteCount(content),
|
||||
ComponentCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidenceSbomDocument?> CollectSpdxSbomAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
string packDir,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In real implementation, would fetch from SbomService
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
spdxVersion = "SPDX-3.0.1",
|
||||
dataLicense = "CC0-1.0",
|
||||
name = artifactDigest,
|
||||
documentNamespace = $"https://stellaops.io/spdx/{artifactDigest}",
|
||||
creationInfo = new { created = DateTimeOffset.UtcNow.ToString("O") },
|
||||
packages = Array.Empty<object>()
|
||||
}, JsonOptions);
|
||||
|
||||
var fileName = "sbom/spdx-3.0.1.json";
|
||||
var filePath = Path.Combine(packDir, fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
await File.WriteAllTextAsync(filePath, content, ct);
|
||||
|
||||
var digest = $"sha256:{ComputeHash(content)}";
|
||||
|
||||
return new EvidenceSbomDocument
|
||||
{
|
||||
Digest = digest,
|
||||
Format = "spdx",
|
||||
FormatVersion = "3.0.1",
|
||||
Encoding = "json",
|
||||
FileName = fileName,
|
||||
SizeBytes = Encoding.UTF8.GetByteCount(content),
|
||||
ComponentCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<EvidenceVexDocument>> CollectVexDocumentsAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
string packDir,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In real implementation, would fetch from VexLens
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = $"urn:stellaops:vex:{artifactDigest}",
|
||||
author = "StellaOps",
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
statements = Array.Empty<object>()
|
||||
}, JsonOptions);
|
||||
|
||||
var fileName = "vex/openvex.json";
|
||||
var filePath = Path.Combine(packDir, fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
await File.WriteAllTextAsync(filePath, content, ct);
|
||||
|
||||
var digest = $"sha256:{ComputeHash(content)}";
|
||||
|
||||
return new[]
|
||||
{
|
||||
new EvidenceVexDocument
|
||||
{
|
||||
Digest = digest,
|
||||
Format = "openvex",
|
||||
FormatVersion = "0.2.0",
|
||||
FileName = fileName,
|
||||
SizeBytes = Encoding.UTF8.GetByteCount(content),
|
||||
StatementCount = 0,
|
||||
Issuer = "StellaOps"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidencePolicyVerdict?> CollectPolicyVerdictAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
string packDir,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In real implementation, would fetch from Policy Engine
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
artifactDigest,
|
||||
tenantId,
|
||||
verdict = "pass",
|
||||
policyVersion = "1.0.0",
|
||||
evaluatedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
rules = new { total = 0, passed = 0, failed = 0, warned = 0 }
|
||||
}, JsonOptions);
|
||||
|
||||
var fileName = "policy/verdict.json";
|
||||
var filePath = Path.Combine(packDir, fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
await File.WriteAllTextAsync(filePath, content, ct);
|
||||
|
||||
var digest = $"sha256:{ComputeHash(content)}";
|
||||
|
||||
return new EvidencePolicyVerdict
|
||||
{
|
||||
Digest = digest,
|
||||
PolicyVersion = "1.0.0",
|
||||
Verdict = "pass",
|
||||
RulesEvaluated = 0,
|
||||
RulesPassed = 0,
|
||||
RulesFailed = 0,
|
||||
RulesWarned = 0,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
FileName = fileName
|
||||
};
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<EvidenceAttestation>> CollectAttestationsAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
string packDir,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In real implementation, would fetch from Attestor
|
||||
// For now, return empty
|
||||
return Task.FromResult<IReadOnlyList<EvidenceAttestation>>(Array.Empty<EvidenceAttestation>());
|
||||
}
|
||||
|
||||
private static async Task CreateZipArchiveAsync(
|
||||
string sourceDir,
|
||||
string zipPath,
|
||||
string compression,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var compressionLevel = compression switch
|
||||
{
|
||||
"none" => CompressionLevel.NoCompression,
|
||||
"zstd" => CompressionLevel.SmallestSize, // Best available approximation
|
||||
_ => CompressionLevel.Optimal
|
||||
};
|
||||
|
||||
// Delete existing if present
|
||||
if (File.Exists(zipPath))
|
||||
{
|
||||
File.Delete(zipPath);
|
||||
}
|
||||
|
||||
ZipFile.CreateFromDirectory(sourceDir, zipPath, compressionLevel, includeBaseDirectory: false);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(IReadOnlyList<ManifestEntry> entries)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return ComputeHash(string.Empty);
|
||||
}
|
||||
|
||||
// Simple merkle tree: hash all entries together in order
|
||||
var combined = string.Join("|", entries.Select(e => $"{e.Path}:{e.Sha256}"));
|
||||
return ComputeHash(combined);
|
||||
}
|
||||
|
||||
private static string ComputeReplayHash(string artifactDigest, string sbomDigest, string merkleRoot)
|
||||
{
|
||||
var input = $"{artifactDigest}|{sbomDigest}|{merkleRoot}|{DateTimeOffset.UtcNow:O}";
|
||||
return $"sha256:{ComputeHash(input)}";
|
||||
}
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string ExtractHash(string digest)
|
||||
{
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
return colonIndex >= 0 ? digest[(colonIndex + 1)..] : digest;
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest)) return digest;
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
|
||||
{
|
||||
return $"{digest[..(colonIndex + 13)]}...";
|
||||
}
|
||||
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
|
||||
}
|
||||
|
||||
private sealed class CachedPack
|
||||
{
|
||||
public required LineageNodeEvidencePack Pack { get; init; }
|
||||
public required string ZipPath { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Cronos" Version="0.9.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Cronos" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
@@ -250,7 +251,7 @@ public sealed class ExportAdapterRegistryTests
|
||||
|
||||
public async IAsyncEnumerable<AdapterItemResult> ProcessStreamAsync(
|
||||
ExportAdapterContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -549,7 +549,6 @@ internal sealed class FakeCryptoHash : StellaOps.Cryptography.ICryptoHash
|
||||
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
using StellaOps.TestKit;
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return new ValueTask<byte[]>(hash);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -349,7 +349,6 @@ public sealed class BootstrapPackBuilderTests : IDisposable
|
||||
using var gzip = new GZipStream(packStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
using StellaOps.TestKit;
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.Encryption;
|
||||
using Xunit;
|
||||
@@ -554,7 +554,6 @@ public class BundleEncryptionServiceTests : IDisposable
|
||||
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
using StellaOps.TestKit;
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return new ValueTask<byte[]>(hash);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -55,7 +55,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
|
||||
var fixedNow = new DateTimeOffset(2025, 11, 4, 12, 30, 0, TimeSpan.Zero);
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FakeCryptoHash(), new FixedTimeProvider(fixedNow));
|
||||
var result = builder.Build(request, TestContext.Current.CancellationToken);
|
||||
var result = builder.Build(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(request.BundleId, result.Manifest.BundleId);
|
||||
Assert.Equal("devportal-offline/v1", result.Manifest.Version);
|
||||
@@ -136,7 +136,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FakeCryptoHash(), new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => builder.Build(request, TestContext.Current.CancellationToken));
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => builder.Build(request, CancellationToken.None));
|
||||
Assert.Contains("does not contain any files", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html/>");
|
||||
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FakeCryptoHash(), new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot), TestContext.Current.CancellationToken);
|
||||
var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot), CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Manifest.Entries);
|
||||
Assert.True(result.Manifest.Sources.PortalIncluded);
|
||||
@@ -178,7 +178,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), missing);
|
||||
Assert.Throws<DirectoryNotFoundException>(() => builder.Build(request, TestContext.Current.CancellationToken));
|
||||
Assert.Throws<DirectoryNotFoundException>(() => builder.Build(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
private static string CalculateFileHash(string path)
|
||||
@@ -207,7 +207,6 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
}
|
||||
|
||||
using var memory = new MemoryStream();
|
||||
using StellaOps.TestKit;
|
||||
entry.DataStream.CopyTo(memory);
|
||||
result[entry.Name] = memory.ToArray();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
@@ -56,7 +56,7 @@ public class DevPortalOfflineJobTests
|
||||
|
||||
var outcome = await job.ExecuteAsync(
|
||||
new DevPortalOfflineJobRequest(request, "exports/devportal", "bundle.tgz"),
|
||||
TestContext.Current.CancellationToken);
|
||||
CancellationToken.None);
|
||||
|
||||
var expectedPrefix = $"exports/devportal/{bundleId:D}";
|
||||
Assert.Equal($"{expectedPrefix}/manifest.json", outcome.ManifestStorage.StorageKey);
|
||||
@@ -103,7 +103,7 @@ public class DevPortalOfflineJobTests
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot);
|
||||
var outcome = await job.ExecuteAsync(
|
||||
new DevPortalOfflineJobRequest(request, "exports", "../bundle.tgz"),
|
||||
TestContext.Current.CancellationToken);
|
||||
CancellationToken.None);
|
||||
|
||||
var expectedPrefix = $"exports/{request.BundleId:D}";
|
||||
Assert.Equal($"{expectedPrefix}/bundle.tgz", outcome.BundleStorage.StorageKey);
|
||||
@@ -133,7 +133,6 @@ public class DevPortalOfflineJobTests
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
using StellaOps.TestKit;
|
||||
content.CopyTo(memory);
|
||||
var bytes = memory.ToArray();
|
||||
content.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
@@ -37,7 +37,7 @@ public class HmacDevPortalOfflineManifestSignerTests
|
||||
var rootHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(manifest))).ToLowerInvariant();
|
||||
var bundleId = Guid.Parse("9ca2aafb-42b7-4df9-85f7-5a1d46c4e0ef");
|
||||
|
||||
var document = await signer.SignAsync(bundleId, manifest, rootHash, TestContext.Current.CancellationToken);
|
||||
var document = await signer.SignAsync(bundleId, manifest, rootHash, CancellationToken.None);
|
||||
|
||||
Assert.Equal(bundleId, document.BundleId);
|
||||
Assert.Equal(rootHash, document.RootHash);
|
||||
@@ -73,7 +73,7 @@ public class HmacDevPortalOfflineManifestSignerTests
|
||||
NullLogger<HmacDevPortalOfflineManifestSigner>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() =>
|
||||
signer.SignAsync(Guid.NewGuid(), "{}", "root", TestContext.Current.CancellationToken));
|
||||
signer.SignAsync(Guid.NewGuid(), "{}", "root", CancellationToken.None));
|
||||
}
|
||||
|
||||
private static string ComputeExpectedSignature(DevPortalOfflineManifestSigningOptions options, string manifest)
|
||||
@@ -83,7 +83,6 @@ public class HmacDevPortalOfflineManifestSignerTests
|
||||
|
||||
var secret = Convert.FromBase64String(options.Secret);
|
||||
using var hmac = new HMACSHA256(secret);
|
||||
using StellaOps.TestKit;
|
||||
var signature = hmac.ComputeHash(pae);
|
||||
return Convert.ToBase64String(signature);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -387,7 +387,6 @@ public sealed class MirrorBundleBuilderTests : IDisposable
|
||||
using var gzip = new GZipStream(bundleStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
using StellaOps.TestKit;
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
@@ -161,7 +161,6 @@ public sealed class MirrorBundleSigningTests
|
||||
{
|
||||
using var nonSeekable = new NonSeekableMemoryStream(Encoding.UTF8.GetBytes("test"));
|
||||
|
||||
using StellaOps.TestKit;
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_signer.SignArchiveAsync(nonSeekable));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.Adapters;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
@@ -402,7 +402,6 @@ public class MirrorDeltaAdapterTests : IDisposable
|
||||
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
using StellaOps.TestKit;
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return new ValueTask<byte[]>(hash);
|
||||
}
|
||||
|
||||
@@ -40,51 +40,50 @@ public sealed class BundleVerificationTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "VerifyBundleAsync validates correct hash")]
|
||||
public async Task VerifyBundleAsync_ValidHash_ReturnsTrue()
|
||||
[Fact(DisplayName = "VerifyBundleAsync validates valid bundle")]
|
||||
public async Task VerifyBundleAsync_ValidBundle_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-verify-1",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
|
||||
// Act
|
||||
var verification = await _packager.VerifyBundleAsync(
|
||||
result.BundlePath!,
|
||||
result.ManifestHash!);
|
||||
var verification = await _packager.VerifyBundleAsync(result.BundlePath);
|
||||
|
||||
// Assert
|
||||
verification.IsValid.Should().BeTrue();
|
||||
verification.HashValid.Should().BeTrue();
|
||||
verification.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "VerifyBundleAsync rejects incorrect hash")]
|
||||
public async Task VerifyBundleAsync_IncorrectHash_ReturnsFalse()
|
||||
[Fact(DisplayName = "VerifyBundleAsync returns manifest")]
|
||||
public async Task VerifyBundleAsync_ValidBundle_ReturnsManifest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-verify-2",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
|
||||
// Act
|
||||
var verification = await _packager.VerifyBundleAsync(
|
||||
result.BundlePath!,
|
||||
"sha256:wrong_hash_value");
|
||||
var verification = await _packager.VerifyBundleAsync(result.BundlePath);
|
||||
|
||||
// Assert
|
||||
verification.IsValid.Should().BeFalse();
|
||||
verification.HashValid.Should().BeFalse();
|
||||
verification.Errors.Should().Contain(e => e.Contains("hash"));
|
||||
verification.Manifest.Should().NotBeNull();
|
||||
verification.Manifest!.BundleId.Should().Be(result.BundleId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "VerifyBundleAsync rejects tampered bundle")]
|
||||
@@ -94,23 +93,23 @@ public sealed class BundleVerificationTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-verify-3",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
|
||||
// Tamper with the bundle
|
||||
var bytes = await File.ReadAllBytesAsync(result.BundlePath!);
|
||||
var bytes = await File.ReadAllBytesAsync(result.BundlePath);
|
||||
bytes[bytes.Length / 2] ^= 0xFF; // Flip some bits
|
||||
var tamperedPath = result.BundlePath!.Replace(".tgz", ".tampered.tgz");
|
||||
var tamperedPath = result.BundlePath.Replace(".tgz", ".tampered.tgz");
|
||||
await File.WriteAllBytesAsync(tamperedPath, bytes);
|
||||
_tempFiles.Add(tamperedPath);
|
||||
|
||||
// Act
|
||||
var verification = await _packager.VerifyBundleAsync(
|
||||
tamperedPath,
|
||||
result.ManifestHash!);
|
||||
var verification = await _packager.VerifyBundleAsync(tamperedPath);
|
||||
|
||||
// Assert
|
||||
verification.IsValid.Should().BeFalse();
|
||||
@@ -120,13 +119,11 @@ public sealed class BundleVerificationTests : IDisposable
|
||||
public async Task VerifyBundleAsync_NonExistentFile_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var verification = await _packager.VerifyBundleAsync(
|
||||
"/non/existent/path.tgz",
|
||||
"sha256:abc123");
|
||||
var verification = await _packager.VerifyBundleAsync("/non/existent/path.tgz");
|
||||
|
||||
// Assert
|
||||
verification.IsValid.Should().BeFalse();
|
||||
verification.Errors.Should().Contain(e => e.Contains("not found") || e.Contains("exist"));
|
||||
verification.Issues.Should().Contain(e => e.Contains("not found"));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "VerifyBundleAsync validates manifest entries")]
|
||||
@@ -136,47 +133,45 @@ public sealed class BundleVerificationTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-verify-4",
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact",
|
||||
IncludeVexHistory = true,
|
||||
IncludeSbomSlice = true
|
||||
};
|
||||
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
|
||||
// Act
|
||||
var verification = await _packager.VerifyBundleAsync(
|
||||
result.BundlePath!,
|
||||
result.ManifestHash!);
|
||||
var verification = await _packager.VerifyBundleAsync(result.BundlePath);
|
||||
|
||||
// Assert
|
||||
verification.IsValid.Should().BeTrue();
|
||||
verification.ChainValid.Should().BeTrue();
|
||||
verification.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "VerifyBundleAsync provides detailed verification result")]
|
||||
public async Task VerifyBundleAsync_ProvidesDetailedResult()
|
||||
[Fact(DisplayName = "VerifyBundleAsync provides verification timestamp")]
|
||||
public async Task VerifyBundleAsync_ProvidesTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-verify-5",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
|
||||
// Act
|
||||
var verification = await _packager.VerifyBundleAsync(
|
||||
result.BundlePath!,
|
||||
result.ManifestHash!);
|
||||
var verification = await _packager.VerifyBundleAsync(result.BundlePath);
|
||||
|
||||
// Assert
|
||||
verification.Should().NotBeNull();
|
||||
verification.IsValid.Should().BeTrue();
|
||||
verification.HashValid.Should().BeTrue();
|
||||
verification.ChainValid.Should().BeTrue();
|
||||
verification.VerifiedAt.Should().BeCloseTo(
|
||||
_timeProvider.GetUtcNow(),
|
||||
TimeSpan.FromSeconds(1));
|
||||
@@ -197,8 +192,8 @@ public sealed class BundleVerificationTests : IDisposable
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Hash format follows sha256: prefix")]
|
||||
public void HashFormat_FollowsSha256Prefix()
|
||||
[Fact(DisplayName = "Hash format follows lowercase hex")]
|
||||
public void HashFormat_FollowsLowercaseHex()
|
||||
{
|
||||
// Arrange
|
||||
var content = "test content";
|
||||
@@ -208,13 +203,13 @@ public sealed class BundleVerificationTests : IDisposable
|
||||
var hash = ComputeHash(bytes);
|
||||
|
||||
// Assert
|
||||
hash.Should().StartWith("sha256:");
|
||||
hash.Should().HaveLength(71); // "sha256:" + 64 hex chars
|
||||
hash.Should().HaveLength(64); // 64 hex chars
|
||||
hash.Should().MatchRegex("^[a-f0-9]+$");
|
||||
}
|
||||
|
||||
private static string ComputeHash(byte[] content)
|
||||
{
|
||||
var hashBytes = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexString(hashBytes).ToLowerInvariant()}";
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-123",
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact",
|
||||
IncludeVexHistory = true,
|
||||
IncludeSbomSlice = true
|
||||
};
|
||||
@@ -56,19 +58,19 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Success.Should().BeTrue();
|
||||
result.BundleId.Should().NotBeNullOrEmpty();
|
||||
result.Content.Should().NotBeNull();
|
||||
result.Content.Length.Should().BeGreaterThan(0);
|
||||
result.BundlePath.Should().NotBeNullOrEmpty();
|
||||
result.Size.Should().BeGreaterThan(0);
|
||||
|
||||
// Verify it's a valid gzip
|
||||
result.Content.Position = 0;
|
||||
using var gzip = new GZipStream(result.Content, CompressionMode.Decompress, leaveOpen: true);
|
||||
// Verify it's a valid gzip file
|
||||
File.Exists(result.BundlePath).Should().BeTrue();
|
||||
await using var fileStream = File.OpenRead(result.BundlePath);
|
||||
using var gzip = new GZipStream(fileStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
var buffer = new byte[2];
|
||||
var read = await gzip.ReadAsync(buffer);
|
||||
read.Should().BeGreaterThan(0);
|
||||
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateBundleAsync includes manifest")]
|
||||
@@ -78,18 +80,20 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-456",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ManifestHash.Should().NotBeNullOrEmpty();
|
||||
result.ManifestHash.Should().StartWith("sha256:");
|
||||
result.Manifest.Should().NotBeNull();
|
||||
result.Manifest.ContentHash.Should().NotBeNullOrEmpty();
|
||||
result.Manifest.BundleId.Should().Be(result.BundleId);
|
||||
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateBundleAsync rejects null request")]
|
||||
@@ -109,7 +113,9 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -126,7 +132,9 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-789",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -136,28 +144,29 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
// Assert
|
||||
result1.BundleId.Should().NotBe(result2.BundleId);
|
||||
|
||||
_tempFiles.Add(result1.BundlePath ?? "");
|
||||
_tempFiles.Add(result2.BundlePath ?? "");
|
||||
_tempFiles.Add(result1.BundlePath);
|
||||
_tempFiles.Add(result2.BundlePath);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateBundleAsync sets correct content type")]
|
||||
public async Task CreateBundleAsync_SetsCorrectContentType()
|
||||
[Fact(DisplayName = "CreateBundleAsync uses correct file extension")]
|
||||
public async Task CreateBundleAsync_UsesCorrectFileExtension()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-content",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
result.ContentType.Should().Be("application/gzip");
|
||||
result.FileName.Should().Contain(".stella.bundle.tgz");
|
||||
result.BundlePath.Should().Contain(".stella.bundle.tgz");
|
||||
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateBundleAsync includes metadata directory")]
|
||||
@@ -167,17 +176,19 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-meta",
|
||||
ActorId = "user@test.com"
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Entries.Should().Contain(e => e.Path.StartsWith("metadata/"));
|
||||
result.Manifest.Should().NotBeNull();
|
||||
result.Manifest.Entries.Should().Contain(e => e.Path.StartsWith("metadata/"));
|
||||
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateBundleAsync with VEX history includes vex directory")]
|
||||
@@ -187,7 +198,9 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-vex",
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact",
|
||||
IncludeVexHistory = true
|
||||
};
|
||||
|
||||
@@ -195,10 +208,10 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Entries.Should().Contain(e => e.Path.StartsWith("vex/"));
|
||||
result.Manifest.Should().NotBeNull();
|
||||
result.Manifest.Entries.Should().Contain(e => e.Path.StartsWith("vex/"));
|
||||
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateBundleAsync with SBOM slice includes sbom directory")]
|
||||
@@ -208,7 +221,9 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var request = new BundleRequest
|
||||
{
|
||||
AlertId = "alert-sbom",
|
||||
TenantId = "test-tenant",
|
||||
ActorId = "user@test.com",
|
||||
ArtifactId = "test-artifact",
|
||||
IncludeSbomSlice = true
|
||||
};
|
||||
|
||||
@@ -216,9 +231,9 @@ public sealed class OfflineBundlePackagerTests : IDisposable
|
||||
var result = await _packager.CreateBundleAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Entries.Should().Contain(e => e.Path.StartsWith("sbom/"));
|
||||
result.Manifest.Should().NotBeNull();
|
||||
result.Manifest.Entries.Should().Contain(e => e.Path.StartsWith("sbom/"));
|
||||
|
||||
_tempFiles.Add(result.BundlePath ?? "");
|
||||
_tempFiles.Add(result.BundlePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -374,7 +374,6 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
|
||||
using var gzip = new GZipStream(exportStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
using StellaOps.TestKit;
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO.Compression;
|
||||
using System.IO.Compression;
|
||||
using StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class RiskBundleBuilderTests
|
||||
});
|
||||
|
||||
var builder = new RiskBundleBuilder();
|
||||
var cancellation = TestContext.Current.CancellationToken;
|
||||
var cancellation = CancellationToken.None;
|
||||
var result = builder.Build(request, cancellation);
|
||||
|
||||
Assert.Equal(2, result.Manifest.Providers.Count);
|
||||
@@ -58,7 +58,6 @@ public sealed class RiskBundleBuilderTests
|
||||
public void Build_WhenMandatoryProviderMissing_Throws()
|
||||
{
|
||||
using var temp = new TempDir();
|
||||
using StellaOps.TestKit;
|
||||
var epss = temp.WriteFile("epss.csv", "cve,score\n");
|
||||
|
||||
var request = new RiskBundleBuildRequest(
|
||||
@@ -67,7 +66,7 @@ using StellaOps.TestKit;
|
||||
|
||||
var builder = new RiskBundleBuilder();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => builder.Build(request, TestContext.Current.CancellationToken));
|
||||
Assert.Throws<InvalidOperationException>(() => builder.Build(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class TempDir : IDisposable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -31,7 +31,7 @@ public sealed class RiskBundleJobTests
|
||||
store,
|
||||
NullLogger<RiskBundleJob>.Instance);
|
||||
|
||||
var outcome = await job.ExecuteAsync(new RiskBundleJobRequest(buildRequest), TestContext.Current.CancellationToken);
|
||||
var outcome = await job.ExecuteAsync(new RiskBundleJobRequest(buildRequest), CancellationToken.None);
|
||||
|
||||
Assert.Equal("risk-bundles/provider-manifest.json", outcome.ManifestStorage.StorageKey);
|
||||
Assert.Equal("risk-bundles/signatures/provider-manifest.dsse", outcome.ManifestSignatureStorage.StorageKey);
|
||||
@@ -66,7 +66,6 @@ public sealed class RiskBundleJobTests
|
||||
public Task<RiskBundleStorageMetadata> StoreAsync(RiskBundleObjectStoreOptions options, Stream content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using StellaOps.TestKit;
|
||||
content.CopyTo(ms);
|
||||
_store[options.StorageKey] = ms.ToArray();
|
||||
return Task.FromResult(new RiskBundleStorageMetadata(options.StorageKey, ms.Length, options.ContentType));
|
||||
|
||||
@@ -14,7 +14,7 @@ public class RiskBundleSignerTests
|
||||
var signer = new HmacRiskBundleManifestSigner(new FakeCryptoHmac(), "secret-key", "test-key");
|
||||
const string manifest = "{\"foo\":1}";
|
||||
|
||||
var doc = await signer.SignAsync(manifest, TestContext.Current.CancellationToken);
|
||||
var doc = await signer.SignAsync(manifest, CancellationToken.None);
|
||||
|
||||
Assert.Equal("application/stellaops.risk-bundle.provider-manifest+json", doc.PayloadType);
|
||||
Assert.NotNull(doc.Payload);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
|
||||
|
||||
|
||||
@@ -38,9 +39,6 @@
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
|
||||
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
|
||||
@@ -54,29 +52,11 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
<PackageReference Include="FluentAssertions" Version="8.2.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.1.0" />
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -116,28 +96,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.WebService\StellaOps.ExportCenter.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.ExportCenter.RiskBundles\StellaOps.ExportCenter.RiskBundles.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -197,7 +197,7 @@ public sealed class AIAttestationOciDiscovery : IAIAttestationOciDiscovery
|
||||
if (isSigned)
|
||||
{
|
||||
// Parse DSSE envelope and extract payload
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, SerializerOptions);
|
||||
var envelope = JsonSerializer.Deserialize<ParsedDsseEnvelope>(json, SerializerOptions);
|
||||
if (envelope?.Payload is not null)
|
||||
{
|
||||
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload));
|
||||
@@ -322,7 +322,7 @@ public sealed class AIAttestationOciDiscovery : IAIAttestationOciDiscovery
|
||||
if (statement?.Predicate is null)
|
||||
return null;
|
||||
|
||||
var predicateJson = statement.Predicate.GetRawText();
|
||||
var predicateJson = statement.Predicate.Value.GetRawText();
|
||||
var artifactTypeEnum = GetArtifactTypeFromMediaType(artifactType ?? string.Empty);
|
||||
|
||||
return new AIAttestationContent
|
||||
@@ -443,8 +443,9 @@ public sealed record AIAttestationContent
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for parsing signed attestations.
|
||||
/// File-scoped to avoid conflict with public DsseEnvelope in RvaOciPublisher.cs.
|
||||
/// </summary>
|
||||
internal sealed record DsseEnvelope
|
||||
file sealed record ParsedDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payload")]
|
||||
public string? Payload { get; init; }
|
||||
@@ -453,13 +454,14 @@ internal sealed record DsseEnvelope
|
||||
public string? PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<DsseSignature>? Signatures { get; init; }
|
||||
public IReadOnlyList<ParsedDsseSignature>? Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature.
|
||||
/// DSSE signature for parsing.
|
||||
/// File-scoped to avoid conflict with public DsseSignature in RvaOciPublisher.cs.
|
||||
/// </summary>
|
||||
internal sealed record DsseSignature
|
||||
file sealed record ParsedDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
@@ -418,13 +418,3 @@ internal sealed record InTotoSubject
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI media type constants.
|
||||
/// </summary>
|
||||
internal static class OciMediaTypes
|
||||
{
|
||||
public const string DsseEnvelope = "application/vnd.dsse.envelope.v1+json";
|
||||
public const string InTotoStatement = "application/vnd.in-toto+json";
|
||||
public const string ImageManifest = "application/vnd.oci.image.manifest.v1+json";
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ public static class OciMediaTypes
|
||||
public const string ExportProvenance = "application/vnd.stellaops.export.provenance.v1+json";
|
||||
public const string TrivyDbBundle = "application/vnd.stellaops.trivy.db.v1+tar+gzip";
|
||||
public const string TrivyJavaDbBundle = "application/vnd.stellaops.trivy.javadb.v1+tar+gzip";
|
||||
|
||||
// DSSE/in-toto attestation types
|
||||
public const string DsseEnvelope = "application/vnd.dsse.envelope.v1+json";
|
||||
public const string InTotoStatement = "application/vnd.in-toto+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LineageExportEndpoints.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-032)
|
||||
// Task: Create lineage export API endpoints
|
||||
// Description: Endpoints for exporting lineage evidence packs.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.Core.Services;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Lineage;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoints for lineage evidence pack export.
|
||||
/// </summary>
|
||||
public static class LineageExportEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps lineage export endpoints to the application.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapLineageExportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/lineage")
|
||||
.WithTags("Lineage Export")
|
||||
.WithOpenApi();
|
||||
|
||||
// POST /api/v1/lineage/export - Generate evidence pack
|
||||
group.MapPost("/export", ExportEvidencePackAsync)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator)
|
||||
.WithName("ExportLineageEvidencePack")
|
||||
.WithSummary("Generate a lineage evidence pack")
|
||||
.WithDescription(
|
||||
"Creates a signed evidence pack containing SBOMs, VEX documents, policy verdicts, " +
|
||||
"and attestations for the specified artifact. Returns a download URL for the ZIP archive.")
|
||||
.Produces<LineageExportResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/lineage/export/{packId} - Get pack metadata
|
||||
group.MapGet("/export/{packId:guid}", GetEvidencePackAsync)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)
|
||||
.WithName("GetLineageEvidencePack")
|
||||
.WithSummary("Get evidence pack metadata")
|
||||
.WithDescription("Returns metadata for a previously generated evidence pack.")
|
||||
.Produces<LineageNodeEvidencePack>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/lineage/export/{packId}/download - Download pack ZIP
|
||||
group.MapGet("/export/{packId:guid}/download", DownloadEvidencePackAsync)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)
|
||||
.WithName("DownloadLineageEvidencePack")
|
||||
.WithSummary("Download evidence pack as ZIP")
|
||||
.WithDescription("Downloads the evidence pack as a ZIP archive.")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/zip")
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /api/v1/lineage/export/{packId}/sign - Sign an existing pack
|
||||
group.MapPost("/export/{packId:guid}/sign", SignEvidencePackAsync)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator)
|
||||
.WithName("SignLineageEvidencePack")
|
||||
.WithSummary("Sign an evidence pack")
|
||||
.WithDescription("Signs an existing evidence pack with a DSSE envelope over the manifest.")
|
||||
.Produces<EvidencePackSignResult>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /api/v1/lineage/export/{packId}/verify - Verify pack signature
|
||||
group.MapPost("/export/{packId:guid}/verify", VerifyEvidencePackAsync)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)
|
||||
.WithName("VerifyLineageEvidencePack")
|
||||
.WithSummary("Verify evidence pack signature")
|
||||
.WithDescription("Verifies the signature and merkle root of an evidence pack.")
|
||||
.Produces<EvidencePackSignVerifyResult>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportEvidencePackAsync(
|
||||
[FromBody] LineageExportRequest request,
|
||||
[FromServices] ILineageEvidencePackService packService,
|
||||
[FromServices] IEvidencePackSigningService signingService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "ArtifactDigest is required",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation Error");
|
||||
}
|
||||
|
||||
var tenantId = GetTenantId(context) ?? request.TenantId ?? "default";
|
||||
|
||||
// Build options from request
|
||||
var options = new EvidencePackOptions
|
||||
{
|
||||
IncludeCycloneDx = request.IncludeCycloneDx ?? true,
|
||||
IncludeSpdx = request.IncludeSpdx ?? true,
|
||||
IncludeVex = request.IncludeVex ?? true,
|
||||
IncludePolicyVerdict = request.IncludePolicyVerdict ?? true,
|
||||
IncludeAttestations = request.IncludeAttestations ?? true,
|
||||
SignManifest = request.SignPack ?? false,
|
||||
SigningKeyId = request.SigningKeyId,
|
||||
Compression = request.Compression ?? "gzip"
|
||||
};
|
||||
|
||||
// Generate pack
|
||||
var result = await packService.GeneratePackAsync(
|
||||
request.ArtifactDigest,
|
||||
tenantId,
|
||||
options,
|
||||
ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: result.Error ?? "Failed to generate evidence pack",
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Export Failed");
|
||||
}
|
||||
|
||||
// Sign if requested
|
||||
LineageNodeEvidencePack? finalPack = result.Pack;
|
||||
EvidencePackSignResult? signResult = null;
|
||||
|
||||
if (options.SignManifest && result.Pack is not null)
|
||||
{
|
||||
var signRequest = new EvidencePackSignRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
KeyId = options.SigningKeyId,
|
||||
UploadToTransparencyLog = request.UploadToTransparencyLog ?? true
|
||||
};
|
||||
|
||||
signResult = await signingService.SignPackAsync(result.Pack, signRequest, ct);
|
||||
if (signResult.Success)
|
||||
{
|
||||
finalPack = signResult.SignedPack;
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new LineageExportResponse
|
||||
{
|
||||
Success = true,
|
||||
PackId = finalPack?.PackId ?? Guid.Empty,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
DownloadUrl = result.DownloadUrl,
|
||||
ExpiresAt = result.ExpiresAt,
|
||||
SizeBytes = result.SizeBytes,
|
||||
FileCount = finalPack?.Manifest?.FileCount ?? 0,
|
||||
MerkleRoot = finalPack?.Manifest?.MerkleRoot,
|
||||
IsSigned = finalPack?.ManifestSignature is not null,
|
||||
TransparencyLogIndex = signResult?.TransparencyLogIndex,
|
||||
Warnings = result.Warnings.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEvidencePackAsync(
|
||||
[FromRoute] Guid packId,
|
||||
[FromServices] ILineageEvidencePackService packService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = GetTenantId(context) ?? "default";
|
||||
|
||||
var pack = await packService.GetPackAsync(packId, tenantId, ct);
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: $"Evidence pack {packId} not found or expired",
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
title: "Not Found");
|
||||
}
|
||||
|
||||
return Results.Ok(pack);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadEvidencePackAsync(
|
||||
[FromRoute] Guid packId,
|
||||
[FromServices] ILineageEvidencePackService packService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = GetTenantId(context) ?? "default";
|
||||
|
||||
var stream = await packService.GetPackStreamAsync(packId, tenantId, ct);
|
||||
if (stream is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: $"Evidence pack {packId} not found or expired",
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
title: "Not Found");
|
||||
}
|
||||
|
||||
var fileName = $"evidence-pack-{packId:N}.zip";
|
||||
return Results.File(stream, "application/zip", fileName);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SignEvidencePackAsync(
|
||||
[FromRoute] Guid packId,
|
||||
[FromBody] LineageSignRequest request,
|
||||
[FromServices] ILineageEvidencePackService packService,
|
||||
[FromServices] IEvidencePackSigningService signingService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = GetTenantId(context) ?? "default";
|
||||
|
||||
var pack = await packService.GetPackAsync(packId, tenantId, ct);
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: $"Evidence pack {packId} not found or expired",
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
title: "Not Found");
|
||||
}
|
||||
|
||||
if (pack.ManifestSignature is not null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Evidence pack is already signed",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Already Signed");
|
||||
}
|
||||
|
||||
var signRequest = new EvidencePackSignRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
KeyId = request.KeyId,
|
||||
UploadToTransparencyLog = request.UploadToTransparencyLog ?? true
|
||||
};
|
||||
|
||||
var result = await signingService.SignPackAsync(pack, signRequest, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyEvidencePackAsync(
|
||||
[FromRoute] Guid packId,
|
||||
[FromServices] ILineageEvidencePackService packService,
|
||||
[FromServices] IEvidencePackSigningService signingService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = GetTenantId(context) ?? "default";
|
||||
|
||||
var pack = await packService.GetPackAsync(packId, tenantId, ct);
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: $"Evidence pack {packId} not found or expired",
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
title: "Not Found");
|
||||
}
|
||||
|
||||
var result = await signingService.VerifySignatureAsync(pack, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
// Extract tenant from claims or header
|
||||
var tenantClaim = context.User.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrEmpty(tenantClaim))
|
||||
{
|
||||
return tenantClaim;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader))
|
||||
{
|
||||
return tenantHeader.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for exporting a lineage evidence pack.
|
||||
/// </summary>
|
||||
public sealed record LineageExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact digest (required).
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant ID (defaults to claim or header).
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include CycloneDX SBOM (default: true).
|
||||
/// </summary>
|
||||
public bool? IncludeCycloneDx { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include SPDX SBOM (default: true).
|
||||
/// </summary>
|
||||
public bool? IncludeSpdx { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include VEX documents (default: true).
|
||||
/// </summary>
|
||||
public bool? IncludeVex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include policy verdict (default: true).
|
||||
/// </summary>
|
||||
public bool? IncludePolicyVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include attestations (default: true).
|
||||
/// </summary>
|
||||
public bool? IncludeAttestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sign the evidence pack manifest (default: false).
|
||||
/// </summary>
|
||||
public bool? SignPack { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing key ID (null = keyless).
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload signature to transparency log (default: true).
|
||||
/// </summary>
|
||||
public bool? UploadToTransparencyLog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compression: "none", "gzip", "zstd" (default: "gzip").
|
||||
/// </summary>
|
||||
public string? Compression { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for signing an evidence pack.
|
||||
/// </summary>
|
||||
public sealed record LineageSignRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Signing key ID (null = keyless).
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload to transparency log (default: true).
|
||||
/// </summary>
|
||||
public bool? UploadToTransparencyLog { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for evidence pack export.
|
||||
/// </summary>
|
||||
public sealed record LineageExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether export succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pack ID for retrieval.
|
||||
/// </summary>
|
||||
public Guid PackId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact that was exported.
|
||||
/// </summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the ZIP.
|
||||
/// </summary>
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the download URL expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pack size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files in pack.
|
||||
/// </summary>
|
||||
public int FileCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root of pack contents.
|
||||
/// </summary>
|
||||
public string? MerkleRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether pack is signed.
|
||||
/// </summary>
|
||||
public bool IsSigned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry index (if signed).
|
||||
/// </summary>
|
||||
public long? TransparencyLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Warnings during generation.
|
||||
/// </summary>
|
||||
public List<string>? Warnings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LineageExportServiceExtensions.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-032)
|
||||
// Task: Service registration for lineage export
|
||||
// Description: DI extensions for registering lineage export services.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.ExportCenter.Core.Services;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Lineage;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for registering lineage export services.
|
||||
/// </summary>
|
||||
public static class LineageExportServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds lineage evidence pack services to the container.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddLineageExportServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ILineageEvidencePackService, LineageEvidencePackService>();
|
||||
services.AddSingleton<IEvidencePackSigningService, EvidencePackSigningService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ using StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
using StellaOps.ExportCenter.WebService.SimulationExport;
|
||||
using StellaOps.ExportCenter.WebService.AuditBundle;
|
||||
using StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||
using StellaOps.ExportCenter.WebService.Lineage;
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -81,6 +82,9 @@ builder.Services.AddAuditBundleJobHandler();
|
||||
// Exception report services
|
||||
builder.Services.AddExceptionReportServices();
|
||||
|
||||
// Lineage evidence pack services
|
||||
builder.Services.AddLineageExportServices();
|
||||
|
||||
// Export API services (profiles, runs, artifacts)
|
||||
builder.Services.AddExportApiServices(options =>
|
||||
{
|
||||
@@ -135,6 +139,9 @@ app.MapAuditBundleEndpoints();
|
||||
// Exception report endpoints
|
||||
app.MapExceptionReportEndpoints();
|
||||
|
||||
// Lineage export endpoints
|
||||
app.MapLineageExportEndpoints();
|
||||
|
||||
// Export API endpoints (profiles, runs, artifacts, SSE)
|
||||
app.MapExportApiEndpoints();
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="OpenTelemetry.Api" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj" />
|
||||
@@ -23,7 +23,7 @@
|
||||
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
using StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
|
||||
using StellaOps.ExportCenter.Worker;
|
||||
@@ -30,7 +31,8 @@ builder.Services.AddSingleton<IRiskBundleManifestSigner>(sp =>
|
||||
var signing = sp.GetRequiredService<IOptions<RiskBundleManifestSigningOptions>>().Value;
|
||||
var key = string.IsNullOrWhiteSpace(signing.Key) ? throw new InvalidOperationException("Risk bundle signing key is not configured.") : signing.Key;
|
||||
var keyId = string.IsNullOrWhiteSpace(signing.KeyId) ? "risk-bundle-hmac" : signing.KeyId!;
|
||||
return new HmacRiskBundleManifestSigner(key, keyId);
|
||||
var cryptoHmac = sp.GetRequiredService<ICryptoHmac>();
|
||||
return new HmacRiskBundleManifestSigner(cryptoHmac, key, keyId);
|
||||
});
|
||||
builder.Services.AddSingleton<IRiskBundleArchiveSigner>(sp => (IRiskBundleArchiveSigner)sp.GetRequiredService<IRiskBundleManifestSigner>());
|
||||
builder.Services.AddSingleton<IRiskBundleObjectStore, FileSystemRiskBundleObjectStore>();
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
@@ -29,17 +29,19 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\..\StellaOps.ExportCenter.RiskBundles\StellaOps.ExportCenter.RiskBundles.csproj"/>
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Core", "StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj", "{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Infrastructure", "StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj", "{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.WebService", "StellaOps.ExportCenter.WebService\StellaOps.ExportCenter.WebService.csproj", "{A1460E98-EDED-42BE-ACF8-896ED94053F1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Worker", "StellaOps.ExportCenter.Worker\StellaOps.ExportCenter.Worker.csproj", "{73531B46-E364-4C0F-B84C-8BDCF3E16051}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Tests", "StellaOps.ExportCenter.Tests\StellaOps.ExportCenter.Tests.csproj", "{1201F1ED-F35A-4F12-B662-BB616122A2F2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A8B060F0-BD04-4CFB-BC99-C31AE6C9C8F5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2DB372A2-C0AD-48D6-875C-CDEB01CC7AFB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1460E98-EDED-42BE-ACF8-896ED94053F1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Release|x64.Build.0 = Release|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{73531B46-E364-4C0F-B84C-8BDCF3E16051}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1201F1ED-F35A-4F12-B662-BB616122A2F2}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
Reference in New Issue
Block a user