Repair release investigation workspace contracts

This commit is contained in:
master
2026-03-09 23:19:42 +02:00
parent 3ecafc49a3
commit 359fafa9da
20 changed files with 1806 additions and 284 deletions

View File

@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.SbomService.Tests;
public sealed class ChangeTraceCompatibilityEndpointsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private const string TenantId = "github.com/acme/change-trace";
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public ChangeTraceCompatibilityEndpointsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Build_endpoint_returns_deterministic_change_trace_for_uploaded_artifacts()
{
var client = CreateAuthenticatedClient();
var firstUpload = await UploadAsync(client, "1.0.0");
var secondUpload = await UploadAsync(client, "1.1.0");
var response = await client.PostAsJsonAsync("/api/change-traces/build", new ChangeTraceBuildRequest
{
TenantId = TenantId,
FromDigest = firstUpload.Digest,
ToDigest = secondUpload.Digest,
IncludeByteDiff = false,
});
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<ChangeTraceDocument>();
payload.Should().NotBeNull();
payload!.TraceId.Should().NotBeNullOrWhiteSpace();
payload.Subject.FromDigest.Should().Be(firstUpload.Digest);
payload.Subject.ToDigest.Should().Be(secondUpload.Digest);
payload.Deltas.Should().ContainSingle();
payload.Deltas[0].ChangeType.Should().BeOneOf("upgraded", "patched");
payload.Summary.ChangedPackages.Should().Be(1);
payload.Commitment.Should().NotBeNull();
payload.Commitment!.Sha256.Should().StartWith("sha256:");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Get_endpoint_rehydrates_trace_from_trace_id()
{
var client = CreateAuthenticatedClient();
var firstUpload = await UploadAsync(client, "2.0.0");
var secondUpload = await UploadAsync(client, "2.0.1");
var buildResponse = await client.PostAsJsonAsync("/api/change-traces/build", new ChangeTraceBuildRequest
{
TenantId = TenantId,
FromDigest = firstUpload.Digest,
ToDigest = secondUpload.Digest,
IncludeByteDiff = false,
});
buildResponse.EnsureSuccessStatusCode();
var builtTrace = await buildResponse.Content.ReadFromJsonAsync<ChangeTraceDocument>();
builtTrace.Should().NotBeNull();
var getResponse = await client.GetAsync($"/api/change-traces/{Uri.EscapeDataString(builtTrace!.TraceId)}");
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var rehydratedTrace = await getResponse.Content.ReadFromJsonAsync<ChangeTraceDocument>();
rehydratedTrace.Should().NotBeNull();
rehydratedTrace!.TraceId.Should().Be(builtTrace.TraceId);
rehydratedTrace.Subject.FromDigest.Should().Be(firstUpload.Digest);
rehydratedTrace.Subject.ToDigest.Should().Be(secondUpload.Digest);
rehydratedTrace.Summary.ChangedPackages.Should().Be(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Get_endpoint_rejects_invalid_trace_id()
{
var client = CreateAuthenticatedClient();
var response = await client.GetAsync("/api/change-traces/not-a-valid-trace-id");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
payload.GetProperty("error").GetString().Should().Be("invalid traceId");
}
private async Task<SbomUploadResponse> UploadAsync(HttpClient client, string version)
{
var response = await client.PostAsJsonAsync(
"/sbom/upload",
new SbomUploadRequest
{
ArtifactRef = "acme/change-trace:demo",
Sbom = JsonDocument.Parse($$"""
{
"spdxVersion": "SPDX-2.3",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "sample",
"dataLicense": "CC0-1.0",
"packages": [
{
"SPDXID": "SPDXRef-Package-lodash",
"name": "lodash",
"versionInfo": "{{version}}",
"licenseDeclared": "MIT",
"externalRefs": [
{
"referenceType": "purl",
"referenceLocator": "pkg:npm/lodash@{{version}}",
"referenceCategory": "PACKAGE-MANAGER"
}
]
}
]
}
""").RootElement.Clone(),
Source = new SbomUploadSource
{
Tool = "syft",
Version = "1.0.0",
CiContext = new SbomUploadCiContext
{
BuildId = $"build-{version}",
Repository = TenantId,
},
},
});
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SbomUploadResponse>();
payload.Should().NotBeNull();
return payload!;
}
private HttpClient CreateAuthenticatedClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId);
client.DefaultRequestHeaders.Add("X-User-Id", "change-trace-test");
return client;
}
}

View File

@@ -1079,6 +1079,103 @@ app.MapGet("/api/v1/lineage/compare", async Task<IResult> (
.RequireAuthorization(SbomPolicies.Read)
.RequireTenant();
app.MapPost("/api/change-traces/build", async Task<IResult> (
[FromBody] ChangeTraceBuildRequest request,
[FromServices] ILineageCompareService compareService,
CancellationToken cancellationToken) =>
{
var tenantId = request.TenantId?.Trim();
var fromDigest = request.FromDigest?.Trim();
var toDigest = request.ToDigest?.Trim();
if (string.IsNullOrWhiteSpace(fromDigest))
{
fromDigest = request.FromScanId?.Trim();
}
if (string.IsNullOrWhiteSpace(toDigest))
{
toDigest = request.ToScanId?.Trim();
}
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "tenantId is required" });
}
if (string.IsNullOrWhiteSpace(fromDigest) || string.IsNullOrWhiteSpace(toDigest))
{
return Results.BadRequest(new { error = "fromDigest and toDigest are required" });
}
var compareResult = await compareService.CompareAsync(
fromDigest,
toDigest,
tenantId,
new LineageCompareOptions
{
IncludeSbomDiff = true,
IncludeVexDeltas = true,
IncludeReachabilityDeltas = true,
IncludeAttestations = true,
IncludeReplayHashes = true,
},
cancellationToken);
if (compareResult is null)
{
return Results.NotFound(new { error = "comparison data not found for the specified artifacts" });
}
return Results.Ok(ChangeTraceCompatibilityService.BuildDocument(
compareResult,
tenantId,
request.IncludeByteDiff));
})
.WithName("BuildChangeTrace")
.WithDescription("Builds a deterministic change trace document for the supplied tenant and artifact digest pair. Accepts modern fromDigest/toDigest fields and legacy fromScanId/toScanId compatibility fields.")
.RequireAuthorization(SbomPolicies.Read)
.RequireTenant();
app.MapGet("/api/change-traces/{traceId}", async Task<IResult> (
[FromRoute] string traceId,
[FromServices] ILineageCompareService compareService,
CancellationToken cancellationToken) =>
{
if (!ChangeTraceCompatibilityService.TryDecodeTraceId(traceId, out var lookup))
{
return Results.BadRequest(new { error = "invalid traceId" });
}
var compareResult = await compareService.CompareAsync(
lookup.FromDigest,
lookup.ToDigest,
lookup.TenantId,
new LineageCompareOptions
{
IncludeSbomDiff = true,
IncludeVexDeltas = true,
IncludeReachabilityDeltas = true,
IncludeAttestations = true,
IncludeReplayHashes = true,
},
cancellationToken);
if (compareResult is null)
{
return Results.NotFound(new { error = "comparison data not found for the traceId" });
}
return Results.Ok(ChangeTraceCompatibilityService.BuildDocument(
compareResult,
lookup.TenantId,
lookup.IncludeByteDiff));
})
.WithName("GetChangeTrace")
.WithDescription("Reconstructs a deterministic change trace document from a shareable traceId without requiring persisted trace blobs.")
.RequireAuthorization(SbomPolicies.Read)
.RequireTenant();
// -----------------------------------------------------------------------------
// Replay Verification API (LIN-BE-033)
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii

View File

@@ -0,0 +1,512 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.SbomService.Services;
internal static class ChangeTraceCompatibilityService
{
public static string EncodeTraceId(string tenantId, string fromDigest, string toDigest, bool includeByteDiff)
{
var payload = JsonSerializer.Serialize(new ChangeTraceLookup(
TenantId: tenantId,
FromDigest: fromDigest,
ToDigest: toDigest,
IncludeByteDiff: includeByteDiff));
return Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
}
public static bool TryDecodeTraceId(string traceId, out ChangeTraceLookup lookup)
{
lookup = default!;
try
{
var json = Encoding.UTF8.GetString(Base64UrlDecode(traceId));
var decoded = JsonSerializer.Deserialize<ChangeTraceLookup>(json);
if (decoded is null
|| string.IsNullOrWhiteSpace(decoded.TenantId)
|| string.IsNullOrWhiteSpace(decoded.FromDigest)
|| string.IsNullOrWhiteSpace(decoded.ToDigest))
{
return false;
}
lookup = decoded;
return true;
}
catch
{
return false;
}
}
public static ChangeTraceDocument BuildDocument(
LineageCompareResponse compare,
string tenantId,
bool includeByteDiff)
{
var traceId = EncodeTraceId(tenantId, compare.FromDigest, compare.ToDigest, includeByteDiff);
var riskTrend = (compare.Summary.RiskTrend ?? "unchanged").Trim().ToLowerInvariant();
var deltas = BuildPackageDeltas(compare, riskTrend);
var subject = new ChangeTraceSubjectDocument
{
Type = "artifact.compare",
ImageRef = compare.ToArtifact?.Name
?? compare.FromArtifact?.Name
?? compare.ToDigest,
Digest = compare.ToDigest,
FromDigest = compare.FromDigest,
ToDigest = compare.ToDigest,
FromScanId = compare.FromDigest,
ToScanId = compare.ToDigest,
};
var basis = new ChangeTraceBasisDocument
{
AnalyzedAt = compare.ComputedAt,
Policies = new[]
{
"lineage.compare",
"release-investigation.workspace",
},
DiffMethods = includeByteDiff ? new[] { "pkg", "byte" } : new[] { "pkg" },
EngineVersion = "sbomservice-lineage-compare",
EngineDigest = compare.ReplayHashes?.ToReplayHash ?? compare.ReplayHashes?.FromReplayHash,
};
var summary = new ChangeTraceSummaryDocument
{
ChangedPackages = deltas.Count,
PackagesAdded = deltas.Count(delta => string.Equals(delta.ChangeType, "added", StringComparison.Ordinal)),
PackagesRemoved = deltas.Count(delta => string.Equals(delta.ChangeType, "removed", StringComparison.Ordinal)),
ChangedSymbols = 0,
ChangedBytes = 0,
RiskDelta = riskTrend switch
{
"improved" => -1,
"degraded" => 1,
_ => 0,
},
Verdict = riskTrend switch
{
"improved" => "risk_down",
"degraded" => "risk_up",
_ => "neutral",
},
};
var document = new ChangeTraceDocument
{
TraceId = traceId,
Schema = "stella.change-trace/v1",
Subject = subject,
Basis = basis,
Deltas = deltas,
Summary = summary,
};
document.Commitment = new ChangeTraceCommitmentDocument
{
Algorithm = "sha256",
Sha256 = ComputeCommitmentHash(document),
};
return document;
}
private static IReadOnlyList<ChangeTracePackageDeltaDocument> BuildPackageDeltas(
LineageCompareResponse compare,
string riskTrend)
{
var deltas = new List<ChangeTracePackageDeltaDocument>();
var sbomDiff = compare.SbomDiff;
if (sbomDiff is null)
{
return deltas;
}
deltas.AddRange(sbomDiff.Added.Select((change, index) =>
BuildPackageDelta(change, null, change.Version, "added", riskTrend, change.Vulnerabilities?.Count ?? 0, 0, index)));
deltas.AddRange(sbomDiff.Removed.Select((change, index) =>
BuildPackageDelta(change, change.Version, null, "removed", riskTrend, 0, change.Vulnerabilities?.Count ?? 0, index)));
deltas.AddRange(sbomDiff.Modified.Select((change, index) =>
BuildModifiedPackageDelta(change, riskTrend, index)));
return deltas;
}
private static ChangeTracePackageDeltaDocument BuildPackageDelta(
LineageComponentChange change,
string? fromVersion,
string? toVersion,
string changeType,
string riskTrend,
int introducedVulns,
int resolvedVulns,
int index)
{
return new ChangeTracePackageDeltaDocument
{
Purl = change.Purl,
FromVersion = fromVersion,
ToVersion = toVersion,
ChangeType = changeType,
Symbols = Array.Empty<ChangeTraceSymbolDeltaDocument>(),
Bytes = Array.Empty<ChangeTraceByteDeltaDocument>(),
TrustDelta = BuildTrustDelta(
riskTrend,
introducedVulns,
resolvedVulns,
changeType,
fromVersion,
toVersion,
index),
};
}
private static ChangeTracePackageDeltaDocument BuildModifiedPackageDelta(
LineageComponentModification change,
string riskTrend,
int index)
{
var introducedCount = change.IntroducedVulnerabilities?.Count ?? 0;
var resolvedCount = change.FixedVulnerabilities?.Count ?? 0;
return new ChangeTracePackageDeltaDocument
{
Purl = change.Purl,
FromVersion = change.FromVersion,
ToVersion = change.ToVersion,
ChangeType = ResolveModifiedChangeType(change),
Symbols = Array.Empty<ChangeTraceSymbolDeltaDocument>(),
Bytes = Array.Empty<ChangeTraceByteDeltaDocument>(),
TrustDelta = BuildTrustDelta(
riskTrend,
introducedCount,
resolvedCount,
"modified",
change.FromVersion,
change.ToVersion,
index,
change.UpgradeType,
change.FixedVulnerabilities,
change.IntroducedVulnerabilities),
};
}
private static ChangeTraceTrustDeltaDocument BuildTrustDelta(
string riskTrend,
int introducedCount,
int resolvedCount,
string changeType,
string? fromVersion,
string? toVersion,
int index,
string? upgradeType = null,
IReadOnlyList<string>? fixedVulnerabilities = null,
IReadOnlyList<string>? introducedVulnerabilities = null)
{
var score = introducedCount - resolvedCount;
var afterScore = Math.Clamp(50 + (resolvedCount - introducedCount), 0, 100);
var proofSteps = new List<string>
{
$"delta[{index}] compared {fromVersion ?? "none"} -> {toVersion ?? "none"} ({changeType})",
};
if (!string.IsNullOrWhiteSpace(upgradeType))
{
proofSteps.Add($"version change classified as {upgradeType}");
}
foreach (var vulnerability in fixedVulnerabilities ?? Array.Empty<string>())
{
proofSteps.Add($"resolved {vulnerability}");
}
foreach (var vulnerability in introducedVulnerabilities ?? Array.Empty<string>())
{
proofSteps.Add($"introduced {vulnerability}");
}
proofSteps.Add(riskTrend switch
{
"improved" => "comparison verdict risk_down",
"degraded" => "comparison verdict risk_up",
_ => "comparison verdict neutral",
});
return new ChangeTraceTrustDeltaDocument
{
Score = score,
BeforeScore = 50,
AfterScore = afterScore,
ReachabilityImpact = riskTrend switch
{
"improved" => "reduced",
"degraded" => "increased",
_ => "unchanged",
},
ExploitabilityImpact = riskTrend switch
{
"improved" => "down",
"degraded" => "up",
_ => "unchanged",
},
ProofSteps = proofSteps,
};
}
private static string ResolveModifiedChangeType(LineageComponentModification change)
{
if (string.Equals(change.FromVersion, change.ToVersion, StringComparison.Ordinal))
{
return "rebuilt";
}
if (string.Equals(change.UpgradeType, "patch", StringComparison.OrdinalIgnoreCase))
{
return "patched";
}
if (LooksLikeDowngrade(change.FromVersion, change.ToVersion))
{
return "downgraded";
}
return "upgraded";
}
private static bool LooksLikeDowngrade(string fromVersion, string toVersion)
{
var fromParts = fromVersion.Split('.');
var toParts = toVersion.Split('.');
var length = Math.Max(fromParts.Length, toParts.Length);
for (var index = 0; index < length; index++)
{
var fromPart = index < fromParts.Length && int.TryParse(fromParts[index], out var fromValue)
? fromValue
: 0;
var toPart = index < toParts.Length && int.TryParse(toParts[index], out var toValue)
? toValue
: 0;
if (toPart < fromPart)
{
return true;
}
if (toPart > fromPart)
{
return false;
}
}
return false;
}
private static string ComputeCommitmentHash(ChangeTraceDocument document)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
document.TraceId,
document.Schema,
document.Subject,
document.Basis,
document.Deltas,
document.Summary,
});
var hash = SHA256.HashData(payload);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string Base64UrlEncode(byte[] value)
{
return Convert.ToBase64String(value)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string value)
{
var normalized = value
.Replace('-', '+')
.Replace('_', '/');
switch (normalized.Length % 4)
{
case 2:
normalized += "==";
break;
case 3:
normalized += "=";
break;
}
return Convert.FromBase64String(normalized);
}
}
public sealed record ChangeTraceBuildRequest
{
public string? TenantId { get; init; }
public string? FromDigest { get; init; }
public string? ToDigest { get; init; }
public string? FromScanId { get; init; }
public string? ToScanId { get; init; }
public bool IncludeByteDiff { get; init; }
}
public sealed record ChangeTraceLookup(
string TenantId,
string FromDigest,
string ToDigest,
bool IncludeByteDiff);
public sealed record ChangeTraceDocument
{
public required string TraceId { get; init; }
public required string Schema { get; init; }
public required ChangeTraceSubjectDocument Subject { get; init; }
public required ChangeTraceBasisDocument Basis { get; init; }
public required IReadOnlyList<ChangeTracePackageDeltaDocument> Deltas { get; init; }
public required ChangeTraceSummaryDocument Summary { get; init; }
public ChangeTraceCommitmentDocument? Commitment { get; set; }
}
public sealed record ChangeTraceSubjectDocument
{
public required string Type { get; init; }
public required string ImageRef { get; init; }
public required string Digest { get; init; }
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
public string? FromScanId { get; init; }
public string? ToScanId { get; init; }
}
public sealed record ChangeTraceBasisDocument
{
public required DateTimeOffset AnalyzedAt { get; init; }
public required IReadOnlyList<string> Policies { get; init; }
public required IReadOnlyList<string> DiffMethods { get; init; }
public required string EngineVersion { get; init; }
public string? EngineDigest { get; init; }
}
public sealed record ChangeTracePackageDeltaDocument
{
public required string Purl { get; init; }
public string? FromVersion { get; init; }
public string? ToVersion { get; init; }
public required string ChangeType { get; init; }
public required IReadOnlyList<ChangeTraceSymbolDeltaDocument> Symbols { get; init; }
public required IReadOnlyList<ChangeTraceByteDeltaDocument> Bytes { get; init; }
public required ChangeTraceTrustDeltaDocument TrustDelta { get; init; }
}
public sealed record ChangeTraceSymbolDeltaDocument
{
public required string SymbolName { get; init; }
public required string ChangeType { get; init; }
public int SizeDelta { get; init; }
public string? FromHash { get; init; }
public string? ToHash { get; init; }
public double Confidence { get; init; }
public string? MatchMethod { get; init; }
public string? Explanation { get; init; }
}
public sealed record ChangeTraceByteDeltaDocument
{
public int Offset { get; init; }
public int Size { get; init; }
public required string FromHash { get; init; }
public required string ToHash { get; init; }
public string? Section { get; init; }
public string? Context { get; init; }
}
public sealed record ChangeTraceTrustDeltaDocument
{
public int Score { get; init; }
public int BeforeScore { get; init; }
public int AfterScore { get; init; }
public required string ReachabilityImpact { get; init; }
public required string ExploitabilityImpact { get; init; }
public required IReadOnlyList<string> ProofSteps { get; init; }
}
public sealed record ChangeTraceSummaryDocument
{
public int ChangedPackages { get; init; }
public int PackagesAdded { get; init; }
public int PackagesRemoved { get; init; }
public int ChangedSymbols { get; init; }
public int ChangedBytes { get; init; }
public int RiskDelta { get; init; }
public required string Verdict { get; init; }
}
public sealed record ChangeTraceCommitmentDocument
{
public required string Sha256 { get; init; }
public required string Algorithm { get; init; }
}