compose and authority fixes. finish sprints.
This commit is contained in:
@@ -293,8 +293,6 @@ builder.Services.AddSingleton(pluginRegistrationSummary);
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration, configurationSection: null);
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// The Authority validates its own tokens for admin endpoints. Configure the JWKS
|
||||
// backchannel to accept the Authority's self-signed certificate (self-referential).
|
||||
@@ -357,7 +355,8 @@ builder.Services.AddOpenIddict()
|
||||
var aspNetCoreBuilder = options.UseAspNetCore()
|
||||
.EnableAuthorizationEndpointPassthrough();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
if (builder.Environment.IsDevelopment()
|
||||
|| string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_DISABLE_TRANSPORT_SECURITY"), "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
aspNetCoreBuilder.DisableTransportSecurityRequirement();
|
||||
}
|
||||
@@ -441,6 +440,11 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
|
||||
options.DisableRollingRefreshTokens = false;
|
||||
});
|
||||
|
||||
// Register StellaOpsBearer JWT authentication AFTER OpenIddict to ensure the scheme
|
||||
// is not overwritten by OpenIddict's authentication provider registration.
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration, configurationSection: null);
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("authority");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("authority");
|
||||
|
||||
@@ -78,15 +78,7 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
var hasHttpsBinding = app.Urls.Any(url => url.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
|
||||
if (hasHttpsBinding)
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.Logger.LogInformation("Skipping HTTPS redirection because no HTTPS binding is configured.");
|
||||
}
|
||||
// HTTPS redirection removed — the gateway handles TLS termination.
|
||||
app.UseResolutionRateLimiting();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -5,6 +5,9 @@ using StellaOps.BinaryIndex.Cache;
|
||||
using StellaOps.BinaryIndex.Contracts.Resolution;
|
||||
using StellaOps.BinaryIndex.Core.Resolution;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.BinaryIndex.WebService.Services;
|
||||
|
||||
@@ -19,6 +22,14 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
private readonly ResolutionServiceOptions _serviceOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CachedResolutionService> _logger;
|
||||
private static readonly JsonSerializerOptions HybridDigestJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private const string HybridSchemaVersion = "1.0.0";
|
||||
private const string HybridNormalizationRecipeId = "stellaops-resolution-cache-v1";
|
||||
|
||||
public CachedResolutionService(
|
||||
IResolutionService inner,
|
||||
@@ -132,7 +143,7 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
|
||||
private VulnResolutionResponse FromCached(VulnResolutionRequest request, CachedResolution cached)
|
||||
{
|
||||
var evidence = BuildEvidence(cached);
|
||||
var evidence = BuildEvidence(request, cached);
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
@@ -161,20 +172,152 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
};
|
||||
}
|
||||
|
||||
private static ResolutionEvidence? BuildEvidence(CachedResolution cached)
|
||||
private static ResolutionEvidence? BuildEvidence(VulnResolutionRequest request, CachedResolution cached)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cached.MatchType) && cached.Confidence <= 0m)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ResolutionEvidence
|
||||
var matchType = string.IsNullOrWhiteSpace(cached.MatchType)
|
||||
? ResolutionMatchTypes.Unknown
|
||||
: cached.MatchType;
|
||||
var evidence = new ResolutionEvidence
|
||||
{
|
||||
MatchType = string.IsNullOrWhiteSpace(cached.MatchType)
|
||||
? ResolutionMatchTypes.Unknown
|
||||
: cached.MatchType,
|
||||
Confidence = cached.Confidence
|
||||
MatchType = matchType,
|
||||
Confidence = cached.Confidence,
|
||||
FixConfidence = cached.Confidence
|
||||
};
|
||||
|
||||
return evidence with
|
||||
{
|
||||
HybridDiff = BuildHybridDiffEvidence(request, matchType, cached.Confidence, cached.Status)
|
||||
};
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence BuildHybridDiffEvidence(
|
||||
VulnResolutionRequest request,
|
||||
string matchType,
|
||||
decimal confidence,
|
||||
ResolutionStatus status)
|
||||
{
|
||||
var anchor = !string.IsNullOrWhiteSpace(request.CveId)
|
||||
? $"cve:{request.CveId}"
|
||||
: $"pkg:{request.Package}";
|
||||
var identity = request.BuildId
|
||||
?? request.Hashes?.FileSha256
|
||||
?? request.Hashes?.TextSha256
|
||||
?? request.Hashes?.Blake3
|
||||
?? "unknown";
|
||||
|
||||
var semanticEditScript = new SemanticEditScriptArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
SourceTreeDigest = ComputeDigestString($"cache-source|{request.Package}|{identity}|{matchType}"),
|
||||
Edits =
|
||||
[
|
||||
new SemanticEditRecord
|
||||
{
|
||||
StableId = ComputeDigestString($"cache-edit|{request.Package}|{anchor}|{status}"),
|
||||
EditType = "update",
|
||||
NodeKind = "method",
|
||||
NodePath = anchor,
|
||||
Anchor = anchor
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var changeType = status switch
|
||||
{
|
||||
ResolutionStatus.NotAffected => "removed",
|
||||
_ => "modified"
|
||||
};
|
||||
var preSize = confidence > 0m ? 1L : 0L;
|
||||
var postSize = status switch
|
||||
{
|
||||
ResolutionStatus.Vulnerable => preSize,
|
||||
ResolutionStatus.NotAffected => 0L,
|
||||
_ => preSize + 1L
|
||||
};
|
||||
|
||||
var deltaRef = ComputeDigestString($"cache-delta|{request.Package}|{anchor}|{preSize}|{postSize}|{status}");
|
||||
var preHash = ComputeDigestString($"cache-pre|{request.Package}|{anchor}|{preSize}");
|
||||
var postHash = ComputeDigestString($"cache-post|{request.Package}|{anchor}|{postSize}");
|
||||
|
||||
var symbolPatchPlan = new SymbolPatchPlanArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildIdBefore = $"baseline:{identity}",
|
||||
BuildIdAfter = identity,
|
||||
EditsDigest = ComputeDigest(semanticEditScript),
|
||||
SymbolMapDigestBefore = ComputeDigestString($"cache-symbol-map|old|{identity}|{anchor}"),
|
||||
SymbolMapDigestAfter = ComputeDigestString($"cache-symbol-map|new|{identity}|{anchor}"),
|
||||
Changes =
|
||||
[
|
||||
new SymbolPatchChange
|
||||
{
|
||||
Symbol = anchor,
|
||||
ChangeType = changeType,
|
||||
AstAnchors = [anchor],
|
||||
PreHash = preHash,
|
||||
PostHash = postHash,
|
||||
DeltaRef = deltaRef
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var patchManifest = new PatchManifestArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildId = identity,
|
||||
NormalizationRecipeId = HybridNormalizationRecipeId,
|
||||
TotalDeltaBytes = Math.Abs(postSize - preSize),
|
||||
Patches =
|
||||
[
|
||||
new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = anchor,
|
||||
AddressRange = "0x0-0x0",
|
||||
DeltaDigest = deltaRef,
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = preSize,
|
||||
Hash = preHash
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = postSize,
|
||||
Hash = postHash
|
||||
},
|
||||
DeltaSizeBytes = Math.Abs(postSize - preSize)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScriptDigest = ComputeDigest(semanticEditScript),
|
||||
OldSymbolMapDigest = ComputeDigestString($"cache-symbol-map|old-digest|{identity}|{anchor}"),
|
||||
NewSymbolMapDigest = ComputeDigestString($"cache-symbol-map|new-digest|{identity}|{anchor}"),
|
||||
SymbolPatchPlanDigest = ComputeDigest(symbolPatchPlan),
|
||||
PatchManifestDigest = ComputeDigest(patchManifest),
|
||||
SemanticEditScript = semanticEditScript,
|
||||
SymbolPatchPlan = symbolPatchPlan,
|
||||
PatchManifest = patchManifest
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest<T>(T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, HybridDigestJsonOptions);
|
||||
return ComputeDigestString(json);
|
||||
}
|
||||
|
||||
private static string ComputeDigestString(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private TimeSpan GetCacheTtl(ResolutionStatus status)
|
||||
@@ -188,3 +331,4 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,3 +30,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0129-T | DONE | Test coverage audit for StellaOps.BinaryIndex.WebService; revalidated 2026-01-06. |
|
||||
| AUDIT-0129-A | TODO | Revalidated 2026-01-06; open findings pending apply. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | SPRINT_20260216_001: cache wrapper now projects deterministic fallback hybridDiff evidence for cached responses consumed by Web UI. |
|
||||
|
||||
|
||||
@@ -169,6 +169,222 @@ public sealed record ResolutionEvidence
|
||||
|
||||
/// <summary>Detection method (security_feed, changelog, patch_header).</summary>
|
||||
public string? FixMethod { get; init; }
|
||||
|
||||
/// <summary>Confidence score for fix determination (0.0-1.0).</summary>
|
||||
public decimal? FixConfidence { get; init; }
|
||||
|
||||
/// <summary>Function-level change details when available.</summary>
|
||||
public IReadOnlyList<FunctionChangeInfo>? ChangedFunctions { get; init; }
|
||||
|
||||
/// <summary>Hybrid source-symbol-binary diff evidence chain.</summary>
|
||||
public HybridDiffEvidence? HybridDiff { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a function that changed between versions.
|
||||
/// </summary>
|
||||
public sealed record FunctionChangeInfo
|
||||
{
|
||||
/// <summary>Function name or symbol identifier.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Type of change (Modified, Added, Removed, SignatureChanged).</summary>
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>Similarity score between pre/post variants when available.</summary>
|
||||
public decimal? Similarity { get; init; }
|
||||
|
||||
/// <summary>Offset of the vulnerable function bytes.</summary>
|
||||
public long? VulnerableOffset { get; init; }
|
||||
|
||||
/// <summary>Offset of the patched function bytes.</summary>
|
||||
public long? PatchedOffset { get; init; }
|
||||
|
||||
/// <summary>Vulnerable function size in bytes.</summary>
|
||||
public long? VulnerableSize { get; init; }
|
||||
|
||||
/// <summary>Patched function size in bytes.</summary>
|
||||
public long? PatchedSize { get; init; }
|
||||
|
||||
/// <summary>Optional vulnerable disassembly excerpt.</summary>
|
||||
public IReadOnlyList<string>? VulnerableDisasm { get; init; }
|
||||
|
||||
/// <summary>Optional patched disassembly excerpt.</summary>
|
||||
public IReadOnlyList<string>? PatchedDisasm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid evidence bundle linking semantic edits, symbol patch plan, and patch manifest.
|
||||
/// </summary>
|
||||
public sealed record HybridDiffEvidence
|
||||
{
|
||||
/// <summary>Digest of semantic edit script artifact.</summary>
|
||||
public string? SemanticEditScriptDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of old symbol map artifact.</summary>
|
||||
public string? OldSymbolMapDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of new symbol map artifact.</summary>
|
||||
public string? NewSymbolMapDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of symbol patch plan artifact.</summary>
|
||||
public string? SymbolPatchPlanDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of patch manifest artifact.</summary>
|
||||
public string? PatchManifestDigest { get; init; }
|
||||
|
||||
/// <summary>Semantic edit script artifact.</summary>
|
||||
public SemanticEditScriptArtifact? SemanticEditScript { get; init; }
|
||||
|
||||
/// <summary>Symbol patch plan artifact.</summary>
|
||||
public SymbolPatchPlanArtifact? SymbolPatchPlan { get; init; }
|
||||
|
||||
/// <summary>Patch manifest artifact.</summary>
|
||||
public PatchManifestArtifact? PatchManifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic edit script artifact.
|
||||
/// </summary>
|
||||
public sealed record SemanticEditScriptArtifact
|
||||
{
|
||||
/// <summary>Artifact schema version.</summary>
|
||||
public string? SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Source tree digest.</summary>
|
||||
public string? SourceTreeDigest { get; init; }
|
||||
|
||||
/// <summary>Deterministic semantic edit records.</summary>
|
||||
public IReadOnlyList<SemanticEditRecord>? Edits { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single semantic edit entry.
|
||||
/// </summary>
|
||||
public sealed record SemanticEditRecord
|
||||
{
|
||||
/// <summary>Stable edit identifier.</summary>
|
||||
public string? StableId { get; init; }
|
||||
|
||||
/// <summary>Edit type (add/remove/move/update/rename).</summary>
|
||||
public string? EditType { get; init; }
|
||||
|
||||
/// <summary>Node kind (file/class/method/field/import/statement).</summary>
|
||||
public string? NodeKind { get; init; }
|
||||
|
||||
/// <summary>Deterministic node path.</summary>
|
||||
public string? NodePath { get; init; }
|
||||
|
||||
/// <summary>Symbol anchor.</summary>
|
||||
public string? Anchor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol patch plan artifact.
|
||||
/// </summary>
|
||||
public sealed record SymbolPatchPlanArtifact
|
||||
{
|
||||
/// <summary>Artifact schema version.</summary>
|
||||
public string? SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Build identifier before patch.</summary>
|
||||
public string? BuildIdBefore { get; init; }
|
||||
|
||||
/// <summary>Build identifier after patch.</summary>
|
||||
public string? BuildIdAfter { get; init; }
|
||||
|
||||
/// <summary>Semantic edit script digest link.</summary>
|
||||
public string? EditsDigest { get; init; }
|
||||
|
||||
/// <summary>Old symbol map digest link.</summary>
|
||||
public string? SymbolMapDigestBefore { get; init; }
|
||||
|
||||
/// <summary>New symbol map digest link.</summary>
|
||||
public string? SymbolMapDigestAfter { get; init; }
|
||||
|
||||
/// <summary>Ordered symbol-level patch changes.</summary>
|
||||
public IReadOnlyList<SymbolPatchChange>? Changes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single symbol patch plan entry.
|
||||
/// </summary>
|
||||
public sealed record SymbolPatchChange
|
||||
{
|
||||
/// <summary>Symbol name.</summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>Change type (added/removed/modified/moved).</summary>
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>Linked source anchors.</summary>
|
||||
public IReadOnlyList<string>? AstAnchors { get; init; }
|
||||
|
||||
/// <summary>Pre-change hash digest.</summary>
|
||||
public string? PreHash { get; init; }
|
||||
|
||||
/// <summary>Post-change hash digest.</summary>
|
||||
public string? PostHash { get; init; }
|
||||
|
||||
/// <summary>Reference to delta payload digest.</summary>
|
||||
public string? DeltaRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch manifest artifact.
|
||||
/// </summary>
|
||||
public sealed record PatchManifestArtifact
|
||||
{
|
||||
/// <summary>Artifact schema version.</summary>
|
||||
public string? SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Build identifier for patched binary.</summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>Normalization recipe identifier.</summary>
|
||||
public string? NormalizationRecipeId { get; init; }
|
||||
|
||||
/// <summary>Total absolute delta bytes across patches.</summary>
|
||||
public long? TotalDeltaBytes { get; init; }
|
||||
|
||||
/// <summary>Ordered per-symbol patch entries.</summary>
|
||||
public IReadOnlyList<SymbolPatchArtifact>? Patches { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-symbol patch artifact entry.
|
||||
/// </summary>
|
||||
public sealed record SymbolPatchArtifact
|
||||
{
|
||||
/// <summary>Symbol name.</summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>Address range in canonical hex format.</summary>
|
||||
public string? AddressRange { get; init; }
|
||||
|
||||
/// <summary>Digest of the patch payload for this symbol.</summary>
|
||||
public string? DeltaDigest { get; init; }
|
||||
|
||||
/// <summary>Pre-patch size/hash tuple.</summary>
|
||||
public PatchSizeHash? Pre { get; init; }
|
||||
|
||||
/// <summary>Post-patch size/hash tuple.</summary>
|
||||
public PatchSizeHash? Post { get; init; }
|
||||
|
||||
/// <summary>Absolute delta bytes for this symbol.</summary>
|
||||
public long? DeltaSizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Size/hash tuple used by patch manifest entries.
|
||||
/// </summary>
|
||||
public sealed record PatchSizeHash
|
||||
{
|
||||
/// <summary>Size in bytes.</summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>Digest string.</summary>
|
||||
public required string Hash { get; init; }
|
||||
}
|
||||
|
||||
public static class ResolutionMatchTypes
|
||||
@@ -246,3 +462,4 @@ public sealed record BatchVulnResolutionResponse
|
||||
/// <summary>Processing time in milliseconds.</summary>
|
||||
public long ProcessingTimeMs { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0115-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0115-A | DONE | Applied contract fixes + tests; revalidated 2026-01-06. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | SPRINT_20260216_001: extended ResolutionEvidence contracts with changedFunctions/hybridDiff projection for UI evidence drawer parity. |
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ using StellaOps.BinaryIndex.Contracts.Resolution;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Core.Resolution;
|
||||
|
||||
@@ -80,6 +83,14 @@ public sealed class ResolutionService : IResolutionService
|
||||
private readonly ResolutionServiceOptions _options;
|
||||
private readonly ILogger<ResolutionService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly JsonSerializerOptions HybridDigestJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private const string HybridSchemaVersion = "1.0.0";
|
||||
private const string HybridNormalizationRecipeId = "stellaops-resolution-v1";
|
||||
|
||||
public ResolutionService(
|
||||
IBinaryVulnerabilityService vulnerabilityService,
|
||||
@@ -214,6 +225,10 @@ public sealed class ResolutionService : IResolutionService
|
||||
ct);
|
||||
|
||||
var (status, evidence) = MapFixStatusToResolution(fixStatus);
|
||||
if (evidence is not null)
|
||||
{
|
||||
evidence = EnrichEvidenceWithHybrid(request, evidence, status);
|
||||
}
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
@@ -253,13 +268,6 @@ public sealed class ResolutionService : IResolutionService
|
||||
// Find the most severe/relevant match
|
||||
var primaryMatch = matches.OrderByDescending(m => m.Confidence).First();
|
||||
|
||||
var evidence = new ResolutionEvidence
|
||||
{
|
||||
MatchType = MapMatchType(primaryMatch.Method),
|
||||
Confidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matches.Select(m => m.CveId).ToList()
|
||||
};
|
||||
|
||||
// Map to resolution status
|
||||
var status = primaryMatch.Method switch
|
||||
{
|
||||
@@ -269,6 +277,29 @@ public sealed class ResolutionService : IResolutionService
|
||||
_ => ResolutionStatus.Unknown
|
||||
};
|
||||
|
||||
var matchedIds = matches
|
||||
.Select(m => m.CveId)
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var changedFunctions = BuildChangedFunctions(matches);
|
||||
|
||||
var evidence = EnrichEvidenceWithHybrid(
|
||||
request,
|
||||
new ResolutionEvidence
|
||||
{
|
||||
MatchType = MapMatchType(primaryMatch.Method),
|
||||
Confidence = primaryMatch.Confidence,
|
||||
FixConfidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matchedIds,
|
||||
ChangedFunctions = changedFunctions,
|
||||
FunctionDiffSummary = BuildFunctionDiffSummary(changedFunctions),
|
||||
SourcePackage = ExtractSourcePackage(primaryMatch.VulnerablePurl)
|
||||
?? ExtractSourcePackage(request.Package)
|
||||
},
|
||||
status);
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
Package = request.Package,
|
||||
@@ -310,17 +341,33 @@ public sealed class ResolutionService : IResolutionService
|
||||
|
||||
var primaryMatch = matches.OrderByDescending(m => m.Confidence).First();
|
||||
|
||||
var evidence = new ResolutionEvidence
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.Fingerprint,
|
||||
Confidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matches.Select(m => m.CveId).ToList()
|
||||
};
|
||||
|
||||
var status = primaryMatch.Confidence >= _options.MinConfidenceThreshold
|
||||
? ResolutionStatus.Fixed
|
||||
: ResolutionStatus.Unknown;
|
||||
|
||||
var matchedIds = matches
|
||||
.Select(m => m.CveId)
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var changedFunctions = BuildChangedFunctions(matches);
|
||||
|
||||
var evidence = EnrichEvidenceWithHybrid(
|
||||
request,
|
||||
new ResolutionEvidence
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.Fingerprint,
|
||||
Confidence = primaryMatch.Confidence,
|
||||
FixConfidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matchedIds,
|
||||
ChangedFunctions = changedFunctions,
|
||||
FunctionDiffSummary = BuildFunctionDiffSummary(changedFunctions),
|
||||
SourcePackage = ExtractSourcePackage(primaryMatch.VulnerablePurl)
|
||||
?? ExtractSourcePackage(request.Package)
|
||||
},
|
||||
status);
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
Package = request.Package,
|
||||
@@ -374,12 +421,284 @@ public sealed class ResolutionService : IResolutionService
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.FixStatus,
|
||||
Confidence = fixStatus.Confidence,
|
||||
FixMethod = MapFixMethod(fixStatus.Method)
|
||||
FixMethod = MapFixMethod(fixStatus.Method),
|
||||
FixConfidence = fixStatus.Confidence
|
||||
};
|
||||
|
||||
return (status, evidence);
|
||||
}
|
||||
|
||||
private static ResolutionEvidence EnrichEvidenceWithHybrid(
|
||||
VulnResolutionRequest request,
|
||||
ResolutionEvidence evidence,
|
||||
ResolutionStatus status)
|
||||
{
|
||||
var changedFunctions = evidence.ChangedFunctions?
|
||||
.OrderBy(static f => f.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var matchedIds = evidence.MatchedFingerprintIds?
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var normalizedEvidence = evidence with
|
||||
{
|
||||
MatchedFingerprintIds = matchedIds is { Count: > 0 } ? matchedIds : null,
|
||||
ChangedFunctions = changedFunctions is { Count: > 0 } ? changedFunctions : null,
|
||||
FunctionDiffSummary = string.IsNullOrWhiteSpace(evidence.FunctionDiffSummary)
|
||||
? BuildFunctionDiffSummary(changedFunctions)
|
||||
: evidence.FunctionDiffSummary,
|
||||
FixConfidence = evidence.FixConfidence ?? evidence.Confidence
|
||||
};
|
||||
|
||||
return normalizedEvidence with
|
||||
{
|
||||
HybridDiff = BuildHybridDiffEvidence(request, normalizedEvidence, status)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FunctionChangeInfo>? BuildChangedFunctions(IEnumerable<BinaryVulnMatch> matches)
|
||||
{
|
||||
var changed = matches
|
||||
.Select(m => new
|
||||
{
|
||||
Symbol = m.Evidence?.MatchedFunction,
|
||||
Similarity = m.Evidence?.Similarity
|
||||
})
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v.Symbol))
|
||||
.GroupBy(static v => v.Symbol!, StringComparer.Ordinal)
|
||||
.OrderBy(static g => g.Key, StringComparer.Ordinal)
|
||||
.Select(g =>
|
||||
{
|
||||
var similarities = g
|
||||
.Select(v => v.Similarity)
|
||||
.Where(static v => v.HasValue)
|
||||
.Select(static v => v!.Value)
|
||||
.ToList();
|
||||
|
||||
return new FunctionChangeInfo
|
||||
{
|
||||
Name = g.Key,
|
||||
ChangeType = "Modified",
|
||||
Similarity = similarities.Count > 0 ? similarities.Max() : null
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return changed.Count > 0 ? changed : null;
|
||||
}
|
||||
|
||||
private static string? BuildFunctionDiffSummary(IReadOnlyList<FunctionChangeInfo>? changedFunctions)
|
||||
{
|
||||
if (changedFunctions is null || changedFunctions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var preview = string.Join(", ", changedFunctions.Take(3).Select(static f => f.Name));
|
||||
var suffix = changedFunctions.Count > 3 ? ", ..." : string.Empty;
|
||||
return $"{changedFunctions.Count} function changes: {preview}{suffix}";
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence BuildHybridDiffEvidence(
|
||||
VulnResolutionRequest request,
|
||||
ResolutionEvidence evidence,
|
||||
ResolutionStatus status)
|
||||
{
|
||||
var changeSet = evidence.ChangedFunctions ?? [];
|
||||
var anchors = changeSet
|
||||
.Select(static f => f.Name)
|
||||
.Where(static name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (anchors.Count == 0 && evidence.MatchedFingerprintIds is { Count: > 0 })
|
||||
{
|
||||
anchors = evidence.MatchedFingerprintIds
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(static id => $"cve:{id}")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (anchors.Count == 0 && !string.IsNullOrWhiteSpace(request.CveId))
|
||||
{
|
||||
anchors.Add($"cve:{request.CveId}");
|
||||
}
|
||||
|
||||
if (anchors.Count == 0)
|
||||
{
|
||||
anchors.Add($"pkg:{request.Package}");
|
||||
}
|
||||
|
||||
var identity = request.BuildId
|
||||
?? request.Hashes?.FileSha256
|
||||
?? request.Hashes?.TextSha256
|
||||
?? request.Hashes?.Blake3
|
||||
?? "unknown";
|
||||
var buildIdAfter = identity;
|
||||
var buildIdBefore = $"baseline:{identity}";
|
||||
|
||||
var semanticEdits = anchors
|
||||
.Select(anchor => new SemanticEditRecord
|
||||
{
|
||||
StableId = ComputeDigestString($"semantic|{request.Package}|{anchor}|{evidence.MatchType}|{status}"),
|
||||
EditType = "update",
|
||||
NodeKind = "method",
|
||||
NodePath = anchor,
|
||||
Anchor = anchor
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var semanticEditScript = new SemanticEditScriptArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
SourceTreeDigest = ComputeDigestString($"source-tree|{request.Package}|{identity}|{evidence.MatchType}"),
|
||||
Edits = semanticEdits
|
||||
};
|
||||
|
||||
var changedByName = changeSet
|
||||
.ToDictionary(static f => f.Name, StringComparer.Ordinal);
|
||||
|
||||
var symbolPatchChanges = anchors
|
||||
.Select((anchor, index) =>
|
||||
{
|
||||
var symbol = anchor;
|
||||
changedByName.TryGetValue(symbol, out var functionChange);
|
||||
|
||||
var preSize = Math.Max(0L, functionChange?.VulnerableSize ?? 0L);
|
||||
var postSize = Math.Max(0L, functionChange?.PatchedSize ?? preSize);
|
||||
if (postSize == 0 && preSize == 0 && string.Equals(evidence.MatchType, ResolutionMatchTypes.Fingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
postSize = 1;
|
||||
}
|
||||
|
||||
var deltaRef = ComputeDigestString($"delta|{request.Package}|{symbol}|{preSize}|{postSize}|{index}");
|
||||
return new
|
||||
{
|
||||
Symbol = symbol,
|
||||
ChangeType = NormalizeChangeType(functionChange?.ChangeType),
|
||||
AstAnchors = new[] { symbol },
|
||||
PreHash = ComputeDigestString($"pre|{request.Package}|{symbol}|{preSize}"),
|
||||
PostHash = ComputeDigestString($"post|{request.Package}|{symbol}|{postSize}"),
|
||||
DeltaRef = deltaRef,
|
||||
PreSize = preSize,
|
||||
PostSize = postSize,
|
||||
Start = Math.Max(0L, functionChange?.PatchedOffset ?? functionChange?.VulnerableOffset ?? (index * 32L))
|
||||
};
|
||||
})
|
||||
.OrderBy(static c => c.Symbol, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var symbolPatchPlan = new SymbolPatchPlanArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildIdBefore = buildIdBefore,
|
||||
BuildIdAfter = buildIdAfter,
|
||||
EditsDigest = ComputeDigest(semanticEditScript),
|
||||
SymbolMapDigestBefore = ComputeDigestString($"symbol-map|old|{buildIdBefore}|{string.Join('|', anchors)}"),
|
||||
SymbolMapDigestAfter = ComputeDigestString($"symbol-map|new|{buildIdAfter}|{string.Join('|', anchors)}"),
|
||||
Changes = symbolPatchChanges
|
||||
.Select(c => new SymbolPatchChange
|
||||
{
|
||||
Symbol = c.Symbol,
|
||||
ChangeType = c.ChangeType,
|
||||
AstAnchors = c.AstAnchors,
|
||||
PreHash = c.PreHash,
|
||||
PostHash = c.PostHash,
|
||||
DeltaRef = c.DeltaRef
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var patches = symbolPatchChanges
|
||||
.Select(c =>
|
||||
{
|
||||
var end = c.Start + Math.Max(0L, Math.Max(c.PreSize, c.PostSize)) - 1L;
|
||||
var addressEnd = end < c.Start ? c.Start : end;
|
||||
var deltaBytes = Math.Abs(c.PostSize - c.PreSize);
|
||||
|
||||
return new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = c.Symbol,
|
||||
AddressRange = $"0x{c.Start:X}-{addressEnd:X}",
|
||||
DeltaDigest = c.DeltaRef,
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = c.PreSize,
|
||||
Hash = c.PreHash
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = c.PostSize,
|
||||
Hash = c.PostHash
|
||||
},
|
||||
DeltaSizeBytes = deltaBytes
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var patchManifest = new PatchManifestArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildId = buildIdAfter,
|
||||
NormalizationRecipeId = HybridNormalizationRecipeId,
|
||||
TotalDeltaBytes = patches.Sum(static p => p.DeltaSizeBytes ?? 0L),
|
||||
Patches = patches
|
||||
};
|
||||
|
||||
var semanticDigest = ComputeDigest(semanticEditScript);
|
||||
var oldSymbolMapDigest = ComputeDigestString($"symbol-map|old|{buildIdBefore}|{string.Join('|', anchors)}|{request.Package}");
|
||||
var newSymbolMapDigest = ComputeDigestString($"symbol-map|new|{buildIdAfter}|{string.Join('|', anchors)}|{request.Package}");
|
||||
var patchPlanDigest = ComputeDigest(symbolPatchPlan);
|
||||
var patchManifestDigest = ComputeDigest(patchManifest);
|
||||
|
||||
return new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScriptDigest = semanticDigest,
|
||||
OldSymbolMapDigest = oldSymbolMapDigest,
|
||||
NewSymbolMapDigest = newSymbolMapDigest,
|
||||
SymbolPatchPlanDigest = patchPlanDigest,
|
||||
PatchManifestDigest = patchManifestDigest,
|
||||
SemanticEditScript = semanticEditScript,
|
||||
SymbolPatchPlan = symbolPatchPlan,
|
||||
PatchManifest = patchManifest
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeChangeType(string? changeType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(changeType))
|
||||
{
|
||||
return "modified";
|
||||
}
|
||||
|
||||
return changeType.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"modified" => "modified",
|
||||
"added" => "added",
|
||||
"removed" => "removed",
|
||||
"signaturechanged" => "modified",
|
||||
_ => "modified"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest<T>(T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, HybridDigestJsonOptions);
|
||||
return ComputeDigestString(json);
|
||||
}
|
||||
|
||||
private static string ComputeDigestString(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string? ExtractDistro(string? distroRelease)
|
||||
{
|
||||
if (string.IsNullOrEmpty(distroRelease))
|
||||
@@ -462,3 +781,4 @@ public sealed class ResolutionService : IResolutionService
|
||||
|| !string.IsNullOrWhiteSpace(request.Hashes?.Blake3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0116-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0116-A | DONE | Applied core fixes + tests; revalidated 2026-01-06. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | SPRINT_20260216_001: ResolutionService now emits deterministic hybrid diff evidence (live lookups + specific CVE flow) with targeted core tests. |
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ public sealed record DeltaSigPredicate
|
||||
[JsonPropertyName("largeBlobs")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<LargeBlobReference>? LargeBlobs { get; init; }
|
||||
/// <summary>
|
||||
/// Optional hybrid diff evidence bundle linking source edits, symbol maps,
|
||||
/// and normalized patch manifests.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hybridDiff")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public HybridDiffEvidence? HybridDiff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the old binary subject.
|
||||
@@ -489,3 +496,4 @@ public sealed record LargeBlobReference
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -23,6 +24,7 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
private readonly IDeltaSignatureMatcher _signatureMatcher;
|
||||
private readonly ILogger<DeltaSigService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IHybridDiffComposer _hybridDiffComposer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeltaSigService"/> class.
|
||||
@@ -31,12 +33,14 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
IDeltaSignatureGenerator signatureGenerator,
|
||||
IDeltaSignatureMatcher signatureMatcher,
|
||||
ILogger<DeltaSigService> logger,
|
||||
IHybridDiffComposer? hybridDiffComposer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_signatureGenerator = signatureGenerator ?? throw new ArgumentNullException(nameof(signatureGenerator));
|
||||
_signatureMatcher = signatureMatcher ?? throw new ArgumentNullException(nameof(signatureMatcher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hybridDiffComposer = hybridDiffComposer ?? new HybridDiffComposer();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -77,8 +81,28 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
// 2. Compare signatures to find deltas
|
||||
var comparison = await _signatureMatcher.CompareSignaturesAsync(oldSignature, newSignature, ct);
|
||||
|
||||
// 3. Build function deltas
|
||||
var deltas = BuildFunctionDeltas(comparison, request.IncludeIrDiff, request.ComputeSemanticSimilarity);
|
||||
// 3. Resolve symbol maps and build function deltas using real boundaries when available
|
||||
var oldSymbolMap = ResolveSymbolMap(
|
||||
role: "old",
|
||||
providedMap: request.OldSymbolMap,
|
||||
manifest: request.OldSymbolManifest,
|
||||
signature: oldSignature,
|
||||
binary: request.OldBinary);
|
||||
|
||||
var newSymbolMap = ResolveSymbolMap(
|
||||
role: "new",
|
||||
providedMap: request.NewSymbolMap,
|
||||
manifest: request.NewSymbolManifest,
|
||||
signature: newSignature,
|
||||
binary: request.NewBinary);
|
||||
|
||||
var deltas = BuildFunctionDeltas(
|
||||
comparison,
|
||||
oldSignature,
|
||||
newSignature,
|
||||
oldSymbolMap,
|
||||
newSymbolMap,
|
||||
request.ComputeSemanticSimilarity);
|
||||
|
||||
// 4. Filter by patterns if specified
|
||||
if (request.FunctionPatterns?.Count > 0 || request.ExcludePatterns?.Count > 0)
|
||||
@@ -106,7 +130,19 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
largeBlobs = BuildLargeBlobReferences(request.OldBinary, request.NewBinary);
|
||||
}
|
||||
|
||||
// 8. Build predicate
|
||||
// 8. Compose hybrid diff evidence bundle if requested
|
||||
HybridDiffEvidence? hybridDiff = null;
|
||||
if (request.IncludeHybridDiffEvidence)
|
||||
{
|
||||
hybridDiff = _hybridDiffComposer.Compose(
|
||||
request.SourceDiffs,
|
||||
oldSymbolMap,
|
||||
newSymbolMap,
|
||||
deltas,
|
||||
oldSignature.Normalization.RecipeId);
|
||||
}
|
||||
|
||||
// 9. Build predicate
|
||||
var predicate = new DeltaSigPredicate
|
||||
{
|
||||
Subject = new[]
|
||||
@@ -156,15 +192,17 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
},
|
||||
Metadata = request.Metadata,
|
||||
SbomDigest = request.SbomDigest,
|
||||
LargeBlobs = largeBlobs
|
||||
LargeBlobs = largeBlobs,
|
||||
HybridDiff = hybridDiff
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated delta-sig with {DeltaCount} changes: {Added} added, {Removed} removed, {Modified} modified",
|
||||
"Generated delta-sig with {DeltaCount} changes: {Added} added, {Removed} removed, {Modified} modified, hybrid={Hybrid}",
|
||||
deltas.Count,
|
||||
summary.FunctionsAdded,
|
||||
summary.FunctionsRemoved,
|
||||
summary.FunctionsModified);
|
||||
summary.FunctionsModified,
|
||||
hybridDiff is not null);
|
||||
|
||||
return predicate;
|
||||
}
|
||||
@@ -177,8 +215,6 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
ArgumentNullException.ThrowIfNull(newBinary);
|
||||
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
@@ -220,6 +256,14 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
signatureRequest,
|
||||
ct);
|
||||
|
||||
var hybridValidationError = ValidateHybridEvidence(predicate, signature, actualDigest);
|
||||
if (hybridValidationError is not null)
|
||||
{
|
||||
return DeltaSigVerificationResult.Failure(
|
||||
DeltaSigVerificationStatus.HybridEvidenceMismatch,
|
||||
hybridValidationError);
|
||||
}
|
||||
|
||||
// 3. Verify each declared function
|
||||
var failures = new List<FunctionVerificationFailure>();
|
||||
var undeclaredChanges = new List<UndeclaredChange>();
|
||||
@@ -320,8 +364,26 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
Stream newBinary,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// For now, delegate to single-binary verification
|
||||
// Full implementation would verify both binaries match their respective subjects
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
ArgumentNullException.ThrowIfNull(oldBinary);
|
||||
ArgumentNullException.ThrowIfNull(newBinary);
|
||||
|
||||
var oldSubject = predicate.OldBinary;
|
||||
if (oldSubject is null)
|
||||
{
|
||||
return DeltaSigVerificationResult.Failure(
|
||||
DeltaSigVerificationStatus.InvalidPredicate,
|
||||
"Predicate missing 'old' binary subject");
|
||||
}
|
||||
|
||||
var oldDigest = await ComputeDigestAsync(oldBinary, ct);
|
||||
if (!DigestsMatch(oldSubject.Digest, oldDigest))
|
||||
{
|
||||
return DeltaSigVerificationResult.Failure(
|
||||
DeltaSigVerificationStatus.DigestMismatch,
|
||||
$"Old binary digest mismatch: expected {FormatDigest(oldSubject.Digest)}, got {FormatDigest(oldDigest)}");
|
||||
}
|
||||
|
||||
return await VerifyAsync(predicate, newBinary, ct);
|
||||
}
|
||||
|
||||
@@ -384,6 +446,65 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
$"Diff algorithm '{predicate.Tooling.DiffAlgorithm}' does not match required '{options.RequiredDiffAlgorithm}'");
|
||||
}
|
||||
|
||||
var changedSymbols = (predicate.HybridDiff?.SymbolPatchPlan.Changes
|
||||
.Select(c => c.Symbol)
|
||||
?? predicate.Delta.Select(d => d.FunctionId))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (options.RequireHybridEvidence && predicate.HybridDiff is null)
|
||||
{
|
||||
violations.Add("Hybrid diff evidence is required but predicate.hybridDiff is missing");
|
||||
}
|
||||
|
||||
if (options.RequireAstAnchors)
|
||||
{
|
||||
if (predicate.HybridDiff is null)
|
||||
{
|
||||
violations.Add("AST anchors are required but hybrid diff evidence is missing");
|
||||
}
|
||||
else
|
||||
{
|
||||
var symbolsWithoutAnchors = predicate.HybridDiff.SymbolPatchPlan.Changes
|
||||
.Where(c => c.AstAnchors.Count == 0)
|
||||
.Select(c => c.Symbol)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (symbolsWithoutAnchors.Count > 0)
|
||||
{
|
||||
violations.Add($"{symbolsWithoutAnchors.Count} symbols missing AST anchors: {string.Join(", ", symbolsWithoutAnchors)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.MaxPatchManifestDeltaBytes is { } maxPatchBytes)
|
||||
{
|
||||
if (predicate.HybridDiff is null)
|
||||
{
|
||||
violations.Add("Patch manifest byte budget was configured but hybrid diff evidence is missing");
|
||||
}
|
||||
else if (predicate.HybridDiff.PatchManifest.TotalDeltaBytes > maxPatchBytes)
|
||||
{
|
||||
violations.Add(
|
||||
$"Patch manifest changed {predicate.HybridDiff.PatchManifest.TotalDeltaBytes} bytes; max allowed is {maxPatchBytes}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var symbol in changedSymbols)
|
||||
{
|
||||
if (MatchesAnyPrefix(symbol, options.DeniedSymbolPrefixes))
|
||||
{
|
||||
violations.Add($"Denied symbol prefix matched changed symbol '{symbol}'");
|
||||
}
|
||||
|
||||
if (MatchesAnyPrefix(symbol, options.ProtectedSymbolPrefixes))
|
||||
{
|
||||
violations.Add($"Protected symbol '{symbol}' must remain unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["functionsModified"] = predicate.Summary.FunctionsModified,
|
||||
@@ -392,15 +513,30 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
["totalBytesChanged"] = predicate.Summary.TotalBytesChanged,
|
||||
["minSemanticSimilarity"] = predicate.Summary.MinSemanticSimilarity,
|
||||
["lifter"] = predicate.Tooling.Lifter,
|
||||
["diffAlgorithm"] = predicate.Tooling.DiffAlgorithm
|
||||
["diffAlgorithm"] = predicate.Tooling.DiffAlgorithm,
|
||||
["hasHybridDiff"] = predicate.HybridDiff is not null,
|
||||
["changedSymbolCount"] = changedSymbols.Count
|
||||
};
|
||||
|
||||
if (violations.Count == 0)
|
||||
if (predicate.HybridDiff is not null)
|
||||
{
|
||||
details["patchManifestBuildId"] = predicate.HybridDiff.PatchManifest.BuildId;
|
||||
details["patchManifestTotalDeltaBytes"] = predicate.HybridDiff.PatchManifest.TotalDeltaBytes;
|
||||
details["symbolPatchChanges"] = predicate.HybridDiff.SymbolPatchPlan.Changes.Count;
|
||||
details["semanticEditCount"] = predicate.HybridDiff.SemanticEditScript.Edits.Count;
|
||||
}
|
||||
|
||||
var orderedViolations = violations
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (orderedViolations.Count == 0)
|
||||
{
|
||||
return DeltaSigPolicyResult.Pass(details);
|
||||
}
|
||||
|
||||
return DeltaSigPolicyResult.Fail(violations, details);
|
||||
return DeltaSigPolicyResult.Fail(orderedViolations, details);
|
||||
}
|
||||
|
||||
private static DeltaSignatureRequest CreateSignatureRequest(DeltaSigRequest request, string state)
|
||||
@@ -430,42 +566,82 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
};
|
||||
}
|
||||
|
||||
private List<FunctionDelta> BuildFunctionDeltas(
|
||||
private static List<FunctionDelta> BuildFunctionDeltas(
|
||||
DeltaComparisonResult comparison,
|
||||
bool includeIrDiff,
|
||||
DeltaSignature oldSignature,
|
||||
DeltaSignature newSignature,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
bool includeSemanticSimilarity)
|
||||
{
|
||||
var deltas = new List<FunctionDelta>();
|
||||
|
||||
foreach (var result in comparison.SymbolResults)
|
||||
{
|
||||
if (result.ChangeType == SymbolChangeType.Unchanged)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var oldSignatureByName = oldSignature.Symbols
|
||||
.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
var newSignatureByName = newSignature.Symbols
|
||||
.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
|
||||
var delta = new FunctionDelta
|
||||
var oldSymbolIndex = BuildSymbolIndex(oldSymbolMap);
|
||||
var newSymbolIndex = BuildSymbolIndex(newSymbolMap);
|
||||
|
||||
foreach (var result in comparison.SymbolResults
|
||||
.Where(r => r.ChangeType != SymbolChangeType.Unchanged)
|
||||
.OrderBy(r => r.SymbolName, StringComparer.Ordinal))
|
||||
{
|
||||
oldSignatureByName.TryGetValue(result.SymbolName, out var oldSymbolSignature);
|
||||
newSignatureByName.TryGetValue(result.SymbolName, out var newSymbolSignature);
|
||||
oldSymbolIndex.TryGetValue(result.SymbolName, out var oldMapEntry);
|
||||
newSymbolIndex.TryGetValue(result.SymbolName, out var newMapEntry);
|
||||
|
||||
var changeType = result.ChangeType switch
|
||||
{
|
||||
FunctionId = result.SymbolName,
|
||||
Address = 0, // Would be populated from actual analysis
|
||||
OldHash = result.FromHash,
|
||||
NewHash = result.ToHash,
|
||||
OldSize = result.ChangeType == SymbolChangeType.Added ? 0 : result.ChunksTotal * 2048L,
|
||||
NewSize = result.ChangeType == SymbolChangeType.Removed ? 0 : (result.ChunksTotal + result.SizeDelta / 2048) * 2048L,
|
||||
DiffLen = result.SizeDelta != 0 ? Math.Abs(result.SizeDelta) : null,
|
||||
ChangeType = result.ChangeType switch
|
||||
{
|
||||
SymbolChangeType.Added => "added",
|
||||
SymbolChangeType.Removed => "removed",
|
||||
SymbolChangeType.Modified or SymbolChangeType.Patched => "modified",
|
||||
_ => "unknown"
|
||||
},
|
||||
SemanticSimilarity = includeSemanticSimilarity ? result.Confidence : null,
|
||||
OldBlockCount = result.CfgBlockDelta.HasValue ? (int?)Math.Max(0, 10 - result.CfgBlockDelta.Value) : null,
|
||||
NewBlockCount = result.CfgBlockDelta.HasValue ? (int?)10 : null
|
||||
SymbolChangeType.Added => "added",
|
||||
SymbolChangeType.Removed => "removed",
|
||||
SymbolChangeType.Modified or SymbolChangeType.Patched => "modified",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
deltas.Add(delta);
|
||||
var oldSize = changeType == "added"
|
||||
? 0
|
||||
: ResolveSymbolSize(oldSymbolSignature, oldMapEntry, result, usePositiveSizeDelta: false);
|
||||
|
||||
var newSize = changeType == "removed"
|
||||
? 0
|
||||
: ResolveSymbolSize(newSymbolSignature, newMapEntry, result, usePositiveSizeDelta: true);
|
||||
|
||||
var oldHash = changeType == "added"
|
||||
? null
|
||||
: result.FromHash ?? oldSymbolSignature?.HashHex;
|
||||
|
||||
var newHash = changeType == "removed"
|
||||
? null
|
||||
: result.ToHash ?? newSymbolSignature?.HashHex;
|
||||
|
||||
var diffLen = ResolveDiffLength(result.SizeDelta, oldSize, newSize, oldHash, newHash);
|
||||
var address = checked((long)(newMapEntry?.AddressStart ?? oldMapEntry?.AddressStart ?? 0UL));
|
||||
var section = newMapEntry?.Section
|
||||
?? oldMapEntry?.Section
|
||||
?? newSymbolSignature?.Scope
|
||||
?? oldSymbolSignature?.Scope
|
||||
?? ".text";
|
||||
|
||||
deltas.Add(new FunctionDelta
|
||||
{
|
||||
FunctionId = result.SymbolName,
|
||||
Address = address,
|
||||
OldHash = oldHash,
|
||||
NewHash = newHash,
|
||||
OldSize = oldSize,
|
||||
NewSize = newSize,
|
||||
DiffLen = diffLen,
|
||||
ChangeType = changeType,
|
||||
SemanticSimilarity = includeSemanticSimilarity && result.Confidence > 0
|
||||
? result.Confidence
|
||||
: null,
|
||||
Section = section,
|
||||
OldBlockCount = oldSymbolSignature?.CfgBbCount,
|
||||
NewBlockCount = newSymbolSignature?.CfgBbCount
|
||||
});
|
||||
}
|
||||
|
||||
return deltas;
|
||||
@@ -525,6 +701,331 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
};
|
||||
}
|
||||
|
||||
private SymbolMap ResolveSymbolMap(
|
||||
string role,
|
||||
SymbolMap? providedMap,
|
||||
SymbolManifest? manifest,
|
||||
DeltaSignature signature,
|
||||
BinaryReference binary)
|
||||
{
|
||||
if (providedMap is not null)
|
||||
{
|
||||
return providedMap.BinaryDigest is null
|
||||
? providedMap with { BinaryDigest = GetDigestWithPrefix(binary.Digest) }
|
||||
: providedMap;
|
||||
}
|
||||
|
||||
if (manifest is not null)
|
||||
{
|
||||
return _hybridDiffComposer.BuildSymbolMap(manifest, GetDigestWithPrefix(binary.Digest));
|
||||
}
|
||||
|
||||
return _hybridDiffComposer.BuildFallbackSymbolMap(signature, binary, role);
|
||||
}
|
||||
|
||||
private string? ValidateHybridEvidence(
|
||||
DeltaSigPredicate predicate,
|
||||
DeltaSignature signature,
|
||||
IReadOnlyDictionary<string, string> actualDigest)
|
||||
{
|
||||
var hybrid = predicate.HybridDiff;
|
||||
if (hybrid is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scriptDigest = _hybridDiffComposer.ComputeDigest(hybrid.SemanticEditScript);
|
||||
if (!string.Equals(scriptDigest, hybrid.SemanticEditScriptDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid semantic_edit_script digest mismatch";
|
||||
}
|
||||
|
||||
var oldMapDigest = _hybridDiffComposer.ComputeDigest(hybrid.OldSymbolMap);
|
||||
if (!string.Equals(oldMapDigest, hybrid.OldSymbolMapDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid old symbol_map digest mismatch";
|
||||
}
|
||||
|
||||
var newMapDigest = _hybridDiffComposer.ComputeDigest(hybrid.NewSymbolMap);
|
||||
if (!string.Equals(newMapDigest, hybrid.NewSymbolMapDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid new symbol_map digest mismatch";
|
||||
}
|
||||
|
||||
var patchPlanDigest = _hybridDiffComposer.ComputeDigest(hybrid.SymbolPatchPlan);
|
||||
if (!string.Equals(patchPlanDigest, hybrid.SymbolPatchPlanDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid symbol_patch_plan digest mismatch";
|
||||
}
|
||||
|
||||
var patchManifestDigest = _hybridDiffComposer.ComputeDigest(hybrid.PatchManifest);
|
||||
if (!string.Equals(patchManifestDigest, hybrid.PatchManifestDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid patch_manifest digest mismatch";
|
||||
}
|
||||
|
||||
if (!string.Equals(hybrid.SymbolPatchPlan.EditsDigest, hybrid.SemanticEditScriptDigest, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.SymbolPatchPlan.SymbolMapDigestBefore, hybrid.OldSymbolMapDigest, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.SymbolPatchPlan.SymbolMapDigestAfter, hybrid.NewSymbolMapDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid symbol_patch_plan linkage digests are inconsistent";
|
||||
}
|
||||
|
||||
if (!string.Equals(hybrid.SymbolPatchPlan.BuildIdBefore, hybrid.OldSymbolMap.BuildId, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.SymbolPatchPlan.BuildIdAfter, hybrid.NewSymbolMap.BuildId, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.PatchManifest.BuildId, hybrid.NewSymbolMap.BuildId, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid build-id linkage mismatch across symbol maps and manifests";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hybrid.NewSymbolMap.BinaryDigest))
|
||||
{
|
||||
var expectedDigest = ParseDigestString(hybrid.NewSymbolMap.BinaryDigest!);
|
||||
if (!DigestsMatch(expectedDigest, actualDigest))
|
||||
{
|
||||
return "Hybrid new symbol map binary digest does not match verified binary digest";
|
||||
}
|
||||
}
|
||||
|
||||
var patchBySymbol = hybrid.PatchManifest.Patches
|
||||
.ToDictionary(p => p.Symbol, StringComparer.Ordinal);
|
||||
var newSymbolIndex = BuildSymbolIndex(hybrid.NewSymbolMap);
|
||||
var oldSymbolIndex = BuildSymbolIndex(hybrid.OldSymbolMap);
|
||||
var signatureIndex = BuildSignatureIndex(signature);
|
||||
|
||||
foreach (var change in hybrid.SymbolPatchPlan.Changes.OrderBy(c => c.Symbol, StringComparer.Ordinal))
|
||||
{
|
||||
if (!patchBySymbol.TryGetValue(change.Symbol, out var patch))
|
||||
{
|
||||
return $"Hybrid patch manifest missing symbol '{change.Symbol}' from patch plan";
|
||||
}
|
||||
|
||||
if (change.ChangeType is not "removed")
|
||||
{
|
||||
if (signatureIndex.TryGetValue(change.Symbol, out var symbolSignature))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(change.PostHash) &&
|
||||
!HashesEqual(change.PostHash!, symbolSignature.HashHex))
|
||||
{
|
||||
return $"Hybrid post-hash mismatch for symbol '{change.Symbol}'";
|
||||
}
|
||||
|
||||
if (!HashesEqual(patch.Post.Hash, symbolSignature.HashHex))
|
||||
{
|
||||
return $"Hybrid patch manifest post hash mismatch for symbol '{change.Symbol}'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryParseAddressRange(patch.AddressRange, out var rangeStart, out var rangeEnd))
|
||||
{
|
||||
return $"Hybrid patch manifest has invalid address range for symbol '{change.Symbol}'";
|
||||
}
|
||||
|
||||
var rangeMap = change.ChangeType == "removed" ? oldSymbolIndex : newSymbolIndex;
|
||||
if (rangeMap.TryGetValue(change.Symbol, out var mapEntry))
|
||||
{
|
||||
if (rangeStart < mapEntry.AddressStart || rangeEnd > mapEntry.AddressEnd)
|
||||
{
|
||||
return $"Hybrid patch range for symbol '{change.Symbol}' exceeds declared symbol boundaries";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, SymbolMapEntry> BuildSymbolIndex(SymbolMap symbolMap)
|
||||
{
|
||||
var index = new Dictionary<string, SymbolMapEntry>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var symbol in symbolMap.Symbols)
|
||||
{
|
||||
foreach (var alias in EnumerateSymbolAliases(symbol.Name))
|
||||
{
|
||||
if (!index.ContainsKey(alias))
|
||||
{
|
||||
index[alias] = symbol;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, SymbolSignature> BuildSignatureIndex(DeltaSignature signature)
|
||||
{
|
||||
var index = new Dictionary<string, SymbolSignature>(StringComparer.Ordinal);
|
||||
foreach (var symbol in signature.Symbols)
|
||||
{
|
||||
foreach (var alias in EnumerateSymbolAliases(symbol.Name))
|
||||
{
|
||||
if (!index.ContainsKey(alias))
|
||||
{
|
||||
index[alias] = symbol;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateSymbolAliases(string symbol)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
yield return symbol;
|
||||
|
||||
var typeSeparator = symbol.LastIndexOf("::", StringComparison.Ordinal);
|
||||
if (typeSeparator >= 0 && typeSeparator + 2 < symbol.Length)
|
||||
{
|
||||
yield return symbol[(typeSeparator + 2)..];
|
||||
}
|
||||
|
||||
var dotSeparator = symbol.LastIndexOf('.');
|
||||
if (dotSeparator >= 0 && dotSeparator + 1 < symbol.Length)
|
||||
{
|
||||
yield return symbol[(dotSeparator + 1)..];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long ResolveSymbolSize(
|
||||
SymbolSignature? signature,
|
||||
SymbolMapEntry? mapEntry,
|
||||
SymbolMatchResult result,
|
||||
bool usePositiveSizeDelta)
|
||||
{
|
||||
if (signature is not null && signature.SizeBytes > 0)
|
||||
{
|
||||
return signature.SizeBytes;
|
||||
}
|
||||
|
||||
if (mapEntry is not null && mapEntry.Size > 0)
|
||||
{
|
||||
return mapEntry.Size;
|
||||
}
|
||||
|
||||
if (result.SizeDelta != 0)
|
||||
{
|
||||
var directionalSize = usePositiveSizeDelta
|
||||
? Math.Max(0, result.SizeDelta)
|
||||
: Math.Max(0, -result.SizeDelta);
|
||||
|
||||
if (directionalSize > 0)
|
||||
{
|
||||
return directionalSize;
|
||||
}
|
||||
}
|
||||
|
||||
return result.ChunksTotal > 0 ? result.ChunksTotal * 2048L : 0L;
|
||||
}
|
||||
|
||||
private static long? ResolveDiffLength(
|
||||
int sizeDelta,
|
||||
long oldSize,
|
||||
long newSize,
|
||||
string? oldHash,
|
||||
string? newHash)
|
||||
{
|
||||
if (sizeDelta != 0)
|
||||
{
|
||||
return Math.Abs((long)sizeDelta);
|
||||
}
|
||||
|
||||
if (oldSize != newSize)
|
||||
{
|
||||
return Math.Abs(newSize - oldSize);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(oldHash) &&
|
||||
!string.IsNullOrWhiteSpace(newHash) &&
|
||||
!HashesEqual(oldHash, newHash))
|
||||
{
|
||||
return Math.Max(oldSize, newSize);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseAddressRange(string range, out ulong start, out ulong end)
|
||||
{
|
||||
start = 0;
|
||||
end = 0;
|
||||
|
||||
var parts = range.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ulong.TryParse(parts[0].Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out start) &&
|
||||
ulong.TryParse(parts[1].Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out end) &&
|
||||
end >= start;
|
||||
}
|
||||
|
||||
private static bool MatchesAnyPrefix(string symbol, IReadOnlyList<string>? prefixes)
|
||||
{
|
||||
if (prefixes is null || prefixes.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(prefix) && symbol.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HashesEqual(string left, string right)
|
||||
{
|
||||
return string.Equals(StripDigestPrefix(left), StripDigestPrefix(right), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string StripDigestPrefix(string digest)
|
||||
{
|
||||
var separator = digest.IndexOf(':', StringComparison.Ordinal);
|
||||
return separator >= 0 ? digest[(separator + 1)..] : digest;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseDigestString(string digest)
|
||||
{
|
||||
var separator = digest.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separator <= 0 || separator == digest.Length - 1)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sha256"] = StripDigestPrefix(digest)
|
||||
};
|
||||
}
|
||||
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[digest[..separator]] = digest[(separator + 1)..]
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetDigestWithPrefix(IReadOnlyDictionary<string, string> digests)
|
||||
{
|
||||
if (digests.TryGetValue("sha256", out var sha256))
|
||||
{
|
||||
var normalized = StripDigestPrefix(sha256);
|
||||
return $"sha256:{normalized}";
|
||||
}
|
||||
|
||||
var first = digests.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(first.Key) || string.IsNullOrWhiteSpace(first.Value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{first.Key}:{StripDigestPrefix(first.Value)}";
|
||||
}
|
||||
private static async Task<IReadOnlyDictionary<string, string>> ComputeDigestAsync(
|
||||
Stream stream,
|
||||
CancellationToken ct)
|
||||
@@ -615,3 +1116,13 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
return blobs;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -92,11 +92,16 @@ public sealed class DeltaSignatureGenerator : IDeltaSignatureGenerator
|
||||
// Get all symbols
|
||||
var symbols = plugin.GetSymbols(binary).ToDictionary(s => s.Name);
|
||||
|
||||
// Generate signatures for each target symbol. Empty target list means "all symbols".
|
||||
var targetSymbols = request.TargetSymbols.Count == 0
|
||||
? symbols.Keys.OrderBy(v => v, StringComparer.Ordinal).ToArray()
|
||||
: request.TargetSymbols;
|
||||
|
||||
// Generate signatures for each target symbol
|
||||
var symbolSignatures = new List<SymbolSignature>();
|
||||
var appliedSteps = new List<string>();
|
||||
|
||||
foreach (var symbolName in request.TargetSymbols)
|
||||
foreach (var symbolName in targetSymbols)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -486,3 +491,4 @@ public sealed class DeltaSignatureGenerator : IDeltaSignatureGenerator
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for deterministic hybrid diff artifacts.
|
||||
/// </summary>
|
||||
public interface IHybridDiffComposer
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates semantic edits from source file pairs.
|
||||
/// </summary>
|
||||
SemanticEditScript GenerateSemanticEditScript(IReadOnlyList<SourceFileDiff>? sourceDiffs);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a canonical symbol map from a symbol manifest.
|
||||
/// </summary>
|
||||
SymbolMap BuildSymbolMap(SymbolManifest manifest, string? binaryDigest = null);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic fallback map from signature symbols when debug data is unavailable.
|
||||
/// </summary>
|
||||
SymbolMap BuildFallbackSymbolMap(DeltaSignature signature, BinaryReference binary, string role);
|
||||
|
||||
/// <summary>
|
||||
/// Builds symbol patch plan by linking edits and symbol-level deltas.
|
||||
/// </summary>
|
||||
SymbolPatchPlan BuildSymbolPatchPlan(
|
||||
SemanticEditScript editScript,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas);
|
||||
|
||||
/// <summary>
|
||||
/// Builds normalized patch manifest from function deltas.
|
||||
/// </summary>
|
||||
PatchManifest BuildPatchManifest(
|
||||
string buildId,
|
||||
string normalizationRecipeId,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas);
|
||||
|
||||
/// <summary>
|
||||
/// Composes all hybrid diff artifacts into one evidence object.
|
||||
/// </summary>
|
||||
HybridDiffEvidence Compose(
|
||||
IReadOnlyList<SourceFileDiff>? sourceDiffs,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas,
|
||||
string normalizationRecipeId);
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic digest of a serializable value.
|
||||
/// </summary>
|
||||
string ComputeDigest<T>(T value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic implementation of hybrid diff composition.
|
||||
/// </summary>
|
||||
public sealed class HybridDiffComposer : IHybridDiffComposer
|
||||
{
|
||||
private static readonly JsonSerializerOptions DigestJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ControlKeywords =
|
||||
[
|
||||
"if",
|
||||
"for",
|
||||
"while",
|
||||
"switch",
|
||||
"catch",
|
||||
"return",
|
||||
"sizeof"
|
||||
];
|
||||
|
||||
private static readonly Regex FunctionAnchorRegex = new(
|
||||
@"(?<name>[A-Za-z_][A-Za-z0-9_:\.]*)\s*\(",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <inheritdoc />
|
||||
public SemanticEditScript GenerateSemanticEditScript(IReadOnlyList<SourceFileDiff>? sourceDiffs)
|
||||
{
|
||||
var diffs = (sourceDiffs ?? Array.Empty<SourceFileDiff>())
|
||||
.OrderBy(d => NormalizePath(d.Path), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var edits = new List<SemanticEdit>();
|
||||
var treeMaterial = new StringBuilder();
|
||||
|
||||
foreach (var diff in diffs)
|
||||
{
|
||||
var normalizedPath = NormalizePath(diff.Path);
|
||||
var before = diff.BeforeContent ?? string.Empty;
|
||||
var after = diff.AfterContent ?? string.Empty;
|
||||
var beforeDigest = ComputeDigest(before);
|
||||
var afterDigest = ComputeDigest(after);
|
||||
|
||||
treeMaterial
|
||||
.Append(normalizedPath)
|
||||
.Append('|')
|
||||
.Append(beforeDigest)
|
||||
.Append('|')
|
||||
.Append(afterDigest)
|
||||
.Append('\n');
|
||||
|
||||
if (string.Equals(beforeDigest, afterDigest, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var beforeSymbols = ExtractSymbolBlocks(before);
|
||||
var afterSymbols = ExtractSymbolBlocks(after);
|
||||
|
||||
if (beforeSymbols.Count == 0 && afterSymbols.Count == 0)
|
||||
{
|
||||
edits.Add(CreateFileEdit(normalizedPath, beforeDigest, afterDigest));
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var symbol in beforeSymbols.Keys.Except(afterSymbols.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal))
|
||||
{
|
||||
var pre = beforeSymbols[symbol];
|
||||
edits.Add(CreateSymbolEdit(
|
||||
normalizedPath,
|
||||
symbol,
|
||||
"remove",
|
||||
pre.Hash,
|
||||
null,
|
||||
new SourceSpan { StartLine = pre.StartLine, EndLine = pre.EndLine },
|
||||
null));
|
||||
}
|
||||
|
||||
foreach (var symbol in afterSymbols.Keys.Except(beforeSymbols.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal))
|
||||
{
|
||||
var post = afterSymbols[symbol];
|
||||
edits.Add(CreateSymbolEdit(
|
||||
normalizedPath,
|
||||
symbol,
|
||||
"add",
|
||||
null,
|
||||
post.Hash,
|
||||
null,
|
||||
new SourceSpan { StartLine = post.StartLine, EndLine = post.EndLine }));
|
||||
}
|
||||
|
||||
foreach (var symbol in beforeSymbols.Keys.Intersect(afterSymbols.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal))
|
||||
{
|
||||
var pre = beforeSymbols[symbol];
|
||||
var post = afterSymbols[symbol];
|
||||
if (!string.Equals(pre.Hash, post.Hash, StringComparison.Ordinal))
|
||||
{
|
||||
edits.Add(CreateSymbolEdit(
|
||||
normalizedPath,
|
||||
symbol,
|
||||
"update",
|
||||
pre.Hash,
|
||||
post.Hash,
|
||||
new SourceSpan { StartLine = pre.StartLine, EndLine = pre.EndLine },
|
||||
new SourceSpan { StartLine = post.StartLine, EndLine = post.EndLine }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var orderedEdits = edits
|
||||
.OrderBy(e => e.NodePath, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.EditType, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new SemanticEditScript
|
||||
{
|
||||
SourceTreeDigest = ComputeDigest(treeMaterial.ToString()),
|
||||
Edits = orderedEdits
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SymbolMap BuildSymbolMap(SymbolManifest manifest, string? binaryDigest = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var sourcePathByCompiled = (manifest.SourceMappings ?? Array.Empty<SourceMapping>())
|
||||
.GroupBy(m => m.CompiledPath, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().SourcePath, StringComparer.Ordinal);
|
||||
|
||||
var symbols = manifest.Symbols
|
||||
.OrderBy(s => s.Address)
|
||||
.ThenBy(s => s.MangledName, StringComparer.Ordinal)
|
||||
.Select(s =>
|
||||
{
|
||||
var size = s.Size == 0 ? 1UL : s.Size;
|
||||
var mappedPath = ResolveSourcePath(s.SourceFile, sourcePathByCompiled);
|
||||
var ranges = mappedPath is null || s.SourceLine is null
|
||||
? null
|
||||
: new[]
|
||||
{
|
||||
new SourceRange
|
||||
{
|
||||
File = NormalizePath(mappedPath),
|
||||
LineStart = s.SourceLine.Value,
|
||||
LineEnd = s.SourceLine.Value
|
||||
}
|
||||
};
|
||||
|
||||
return new SymbolMapEntry
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(s.DemangledName) ? s.MangledName : s.DemangledName,
|
||||
Kind = MapSymbolKind(s.Type),
|
||||
AddressStart = s.Address,
|
||||
AddressEnd = s.Address + size - 1UL,
|
||||
Section = ".text",
|
||||
SourceRanges = ranges
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new SymbolMap
|
||||
{
|
||||
BuildId = manifest.DebugId,
|
||||
BinaryDigest = binaryDigest,
|
||||
AddressSource = "manifest",
|
||||
Symbols = symbols
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SymbolMap BuildFallbackSymbolMap(DeltaSignature signature, BinaryReference binary, string role)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
ArgumentNullException.ThrowIfNull(binary);
|
||||
|
||||
var sha = GetDigestString(binary.Digest);
|
||||
var buildId = string.IsNullOrWhiteSpace(sha)
|
||||
? $"{role}-fallback"
|
||||
: $"{role}:{sha[..Math.Min(16, sha.Length)]}";
|
||||
|
||||
ulong nextAddress = string.Equals(role, "old", StringComparison.OrdinalIgnoreCase)
|
||||
? 0x100000UL
|
||||
: 0x200000UL;
|
||||
|
||||
var symbols = new List<SymbolMapEntry>();
|
||||
foreach (var symbol in signature.Symbols.OrderBy(s => s.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var size = symbol.SizeBytes <= 0 ? 1UL : (ulong)symbol.SizeBytes;
|
||||
var start = nextAddress;
|
||||
var end = start + size - 1UL;
|
||||
|
||||
symbols.Add(new SymbolMapEntry
|
||||
{
|
||||
Name = symbol.Name,
|
||||
Kind = "function",
|
||||
AddressStart = start,
|
||||
AddressEnd = end,
|
||||
Section = symbol.Scope,
|
||||
SourceRanges = null
|
||||
});
|
||||
|
||||
var aligned = ((size + 15UL) / 16UL) * 16UL;
|
||||
nextAddress += aligned;
|
||||
}
|
||||
|
||||
return new SymbolMap
|
||||
{
|
||||
BuildId = buildId,
|
||||
BinaryDigest = string.IsNullOrWhiteSpace(sha) ? null : $"sha256:{sha}",
|
||||
AddressSource = "synthetic-signature",
|
||||
Symbols = symbols
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SymbolPatchPlan BuildSymbolPatchPlan(
|
||||
SemanticEditScript editScript,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(editScript);
|
||||
ArgumentNullException.ThrowIfNull(oldSymbolMap);
|
||||
ArgumentNullException.ThrowIfNull(newSymbolMap);
|
||||
ArgumentNullException.ThrowIfNull(deltas);
|
||||
|
||||
var editsDigest = ComputeDigest(editScript);
|
||||
var oldMapDigest = ComputeDigest(oldSymbolMap);
|
||||
var newMapDigest = ComputeDigest(newSymbolMap);
|
||||
|
||||
var changes = deltas
|
||||
.OrderBy(d => d.FunctionId, StringComparer.Ordinal)
|
||||
.Select(delta =>
|
||||
{
|
||||
var anchors = editScript.Edits
|
||||
.Where(e => IsAnchorMatch(e.Anchor, delta.FunctionId))
|
||||
.Select(e => e.Anchor)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (anchors.Count == 0)
|
||||
{
|
||||
anchors.Add(delta.FunctionId);
|
||||
}
|
||||
|
||||
return new SymbolPatchChange
|
||||
{
|
||||
Symbol = delta.FunctionId,
|
||||
ChangeType = delta.ChangeType,
|
||||
AstAnchors = anchors,
|
||||
PreHash = delta.OldHash,
|
||||
PostHash = delta.NewHash,
|
||||
DeltaRef = "sha256:" + ComputeDigest($"{delta.FunctionId}|{delta.OldHash}|{delta.NewHash}|{delta.OldSize}|{delta.NewSize}")
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new SymbolPatchPlan
|
||||
{
|
||||
BuildIdBefore = oldSymbolMap.BuildId,
|
||||
BuildIdAfter = newSymbolMap.BuildId,
|
||||
EditsDigest = editsDigest,
|
||||
SymbolMapDigestBefore = oldMapDigest,
|
||||
SymbolMapDigestAfter = newMapDigest,
|
||||
Changes = changes
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PatchManifest BuildPatchManifest(
|
||||
string buildId,
|
||||
string normalizationRecipeId,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(buildId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(normalizationRecipeId);
|
||||
ArgumentNullException.ThrowIfNull(deltas);
|
||||
|
||||
var patches = deltas
|
||||
.OrderBy(d => d.FunctionId, StringComparer.Ordinal)
|
||||
.Select(delta =>
|
||||
{
|
||||
var start = delta.Address < 0 ? 0UL : (ulong)delta.Address;
|
||||
var rangeSize = delta.NewSize > 0 ? delta.NewSize : delta.OldSize;
|
||||
var end = rangeSize > 0
|
||||
? start + (ulong)rangeSize - 1UL
|
||||
: start;
|
||||
|
||||
return new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = delta.FunctionId,
|
||||
AddressRange = $"0x{start:x}-0x{end:x}",
|
||||
DeltaDigest = "sha256:" + ComputeDigest($"{delta.FunctionId}|{delta.OldHash}|{delta.NewHash}|{delta.OldSize}|{delta.NewSize}|{delta.DiffLen}"),
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = delta.OldSize,
|
||||
Hash = string.IsNullOrWhiteSpace(delta.OldHash) ? "sha256:0" : delta.OldHash!
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = delta.NewSize,
|
||||
Hash = string.IsNullOrWhiteSpace(delta.NewHash) ? "sha256:0" : delta.NewHash!
|
||||
}
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PatchManifest
|
||||
{
|
||||
BuildId = buildId,
|
||||
NormalizationRecipeId = normalizationRecipeId,
|
||||
Patches = patches
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HybridDiffEvidence Compose(
|
||||
IReadOnlyList<SourceFileDiff>? sourceDiffs,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas,
|
||||
string normalizationRecipeId)
|
||||
{
|
||||
var script = GenerateSemanticEditScript(sourceDiffs);
|
||||
var patchPlan = BuildSymbolPatchPlan(script, oldSymbolMap, newSymbolMap, deltas);
|
||||
var patchManifest = BuildPatchManifest(newSymbolMap.BuildId, normalizationRecipeId, deltas);
|
||||
|
||||
var scriptDigest = ComputeDigest(script);
|
||||
var oldMapDigest = ComputeDigest(oldSymbolMap);
|
||||
var newMapDigest = ComputeDigest(newSymbolMap);
|
||||
var patchPlanDigest = ComputeDigest(patchPlan);
|
||||
var patchManifestDigest = ComputeDigest(patchManifest);
|
||||
|
||||
return new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScript = script,
|
||||
OldSymbolMap = oldSymbolMap,
|
||||
NewSymbolMap = newSymbolMap,
|
||||
SymbolPatchPlan = patchPlan,
|
||||
PatchManifest = patchManifest,
|
||||
SemanticEditScriptDigest = scriptDigest,
|
||||
OldSymbolMapDigest = oldMapDigest,
|
||||
NewSymbolMapDigest = newMapDigest,
|
||||
SymbolPatchPlanDigest = patchPlanDigest,
|
||||
PatchManifestDigest = patchManifestDigest
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeDigest<T>(T value)
|
||||
{
|
||||
var json = value is string s
|
||||
? s
|
||||
: JsonSerializer.Serialize(value, DigestJsonOptions);
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ResolveSourcePath(string? sourceFile, IReadOnlyDictionary<string, string> sourcePathByCompiled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return sourcePathByCompiled.TryGetValue(sourceFile, out var mapped)
|
||||
? mapped
|
||||
: sourceFile;
|
||||
}
|
||||
|
||||
private static string MapSymbolKind(SymbolType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
SymbolType.Function => "function",
|
||||
SymbolType.Object or SymbolType.Variable or SymbolType.TlsData => "object",
|
||||
SymbolType.Section => "section",
|
||||
_ => "function"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDigestString(IReadOnlyDictionary<string, string> digest)
|
||||
{
|
||||
if (digest.TryGetValue("sha256", out var sha))
|
||||
{
|
||||
return sha;
|
||||
}
|
||||
|
||||
return digest.Values.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
return path.Replace('\\', '/').Trim();
|
||||
}
|
||||
|
||||
private static SemanticEdit CreateFileEdit(string path, string beforeDigest, string afterDigest)
|
||||
{
|
||||
var type = string.IsNullOrWhiteSpace(beforeDigest) || beforeDigest == ComputeEmptyDigest()
|
||||
? "add"
|
||||
: string.IsNullOrWhiteSpace(afterDigest) || afterDigest == ComputeEmptyDigest()
|
||||
? "remove"
|
||||
: "update";
|
||||
|
||||
var nodePath = $"{path}::file";
|
||||
var stableId = ComputeStableId(path, nodePath, type, beforeDigest, afterDigest);
|
||||
return new SemanticEdit
|
||||
{
|
||||
StableId = stableId,
|
||||
EditType = type,
|
||||
NodeKind = "file",
|
||||
NodePath = nodePath,
|
||||
Anchor = path,
|
||||
PreDigest = beforeDigest,
|
||||
PostDigest = afterDigest
|
||||
};
|
||||
}
|
||||
|
||||
private static SemanticEdit CreateSymbolEdit(
|
||||
string path,
|
||||
string symbol,
|
||||
string type,
|
||||
string? preDigest,
|
||||
string? postDigest,
|
||||
SourceSpan? preSpan,
|
||||
SourceSpan? postSpan)
|
||||
{
|
||||
var nodePath = $"{path}::{symbol}";
|
||||
var stableId = ComputeStableId(path, nodePath, type, preDigest, postDigest);
|
||||
|
||||
return new SemanticEdit
|
||||
{
|
||||
StableId = stableId,
|
||||
EditType = type,
|
||||
NodeKind = "method",
|
||||
NodePath = nodePath,
|
||||
Anchor = symbol,
|
||||
PreSpan = preSpan,
|
||||
PostSpan = postSpan,
|
||||
PreDigest = preDigest,
|
||||
PostDigest = postDigest
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeStableId(string path, string nodePath, string type, string? preDigest, string? postDigest)
|
||||
{
|
||||
var material = $"{path}|{nodePath}|{type}|{preDigest}|{postDigest}";
|
||||
var bytes = Encoding.UTF8.GetBytes(material);
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Dictionary<string, SymbolBlock> ExtractSymbolBlocks(string content)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
var blocks = new Dictionary<string, SymbolBlock>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var match = FunctionAnchorRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = match.Groups["name"].Value;
|
||||
if (ControlKeywords.Contains(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var startLine = i + 1;
|
||||
var endLine = startLine;
|
||||
|
||||
var depth = CountChar(line, '{') - CountChar(line, '}');
|
||||
var foundOpening = line.Contains('{', StringComparison.Ordinal);
|
||||
|
||||
var j = i;
|
||||
while (foundOpening && depth > 0 && j + 1 < lines.Length)
|
||||
{
|
||||
j++;
|
||||
var candidate = lines[j];
|
||||
depth += CountChar(candidate, '{');
|
||||
depth -= CountChar(candidate, '}');
|
||||
}
|
||||
|
||||
if (foundOpening)
|
||||
{
|
||||
endLine = j + 1;
|
||||
i = j;
|
||||
}
|
||||
|
||||
var sliceStart = startLine - 1;
|
||||
var sliceLength = endLine - startLine + 1;
|
||||
var blockContent = string.Join("\n", lines.Skip(sliceStart).Take(sliceLength));
|
||||
var blockHash = ComputeBlockHash(blockContent);
|
||||
|
||||
blocks[name] = new SymbolBlock(name, blockHash, startLine, endLine);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static int CountChar(string value, char token)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == token)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string ComputeBlockHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsAnchorMatch(string anchor, string functionId)
|
||||
{
|
||||
if (string.Equals(anchor, functionId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return anchor.EndsWith($".{functionId}", StringComparison.Ordinal) ||
|
||||
anchor.EndsWith($"::{functionId}", StringComparison.Ordinal) ||
|
||||
anchor.Contains(functionId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ComputeEmptyDigest()
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(Array.Empty<byte>(), hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record SymbolBlock(string Name, string Hash, int StartLine, int EndLine);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig;
|
||||
|
||||
@@ -166,6 +167,35 @@ public sealed record DeltaSigRequest
|
||||
/// for the two-tier bundle format.
|
||||
/// </summary>
|
||||
public bool IncludeLargeBlobs { get; init; } = true;
|
||||
/// <summary>
|
||||
/// Source file pairs used to generate semantic edit scripts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SourceFileDiff>? SourceDiffs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Old symbol map from build/debug metadata.
|
||||
/// </summary>
|
||||
public SymbolMap? OldSymbolMap { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New symbol map from build/debug metadata.
|
||||
/// </summary>
|
||||
public SymbolMap? NewSymbolMap { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional old symbol manifest used to derive symbol map.
|
||||
/// </summary>
|
||||
public SymbolManifest? OldSymbolManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional new symbol manifest used to derive symbol map.
|
||||
/// </summary>
|
||||
public SymbolManifest? NewSymbolManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include composed hybrid diff evidence in predicate output.
|
||||
/// </summary>
|
||||
public bool IncludeHybridDiffEvidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -296,6 +326,10 @@ public enum DeltaSigVerificationStatus
|
||||
/// </summary>
|
||||
FunctionNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid evidence artifacts are inconsistent or invalid.
|
||||
/// </summary>
|
||||
HybridEvidenceMismatch,
|
||||
/// <summary>
|
||||
/// Binary analysis failed.
|
||||
/// </summary>
|
||||
@@ -398,6 +432,30 @@ public sealed record DeltaSigPolicyOptions
|
||||
/// Required diffing algorithm.
|
||||
/// </summary>
|
||||
public string? RequiredDiffAlgorithm { get; init; }
|
||||
/// <summary>
|
||||
/// Require hybrid diff evidence to be present.
|
||||
/// </summary>
|
||||
public bool RequireHybridEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Require each changed symbol to map to at least one AST anchor.
|
||||
/// </summary>
|
||||
public bool RequireAstAnchors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol prefixes that are denied from change scope.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? DeniedSymbolPrefixes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol prefixes considered protected and therefore immutable.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ProtectedSymbolPrefixes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum byte budget from patch manifest delta totals.
|
||||
/// </summary>
|
||||
public long? MaxPatchManifestDeltaBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -442,3 +500,4 @@ public sealed record DeltaSigPolicyResult
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ public static class ServiceCollectionExtensions
|
||||
logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<IHybridDiffComposer, HybridDiffComposer>();
|
||||
services.AddSingleton<ISymbolChangeTracer, SymbolChangeTracer>();
|
||||
services.AddSingleton<IDeltaSignatureMatcher, DeltaSignatureMatcher>();
|
||||
|
||||
@@ -105,3 +106,4 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.GroundTruth.Abstractions\StellaOps.BinaryIndex.GroundTruth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Normalization\StellaOps.BinaryIndex.Normalization.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Semantic\StellaOps.BinaryIndex.Semantic.csproj" />
|
||||
<ProjectReference Include="..\..\..\Symbols\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,3 +27,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# StellaOps.BinaryIndex.DeltaSig Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md` (hybrid diff) and `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` (remediation backlog).
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| BHP-01..05 | DONE | SPRINT_20260216_001: implementing hybrid source-symbol-binary diff pipeline (semantic edits, symbol maps, patch manifests, verifier and policy hooks). |
|
||||
| QA-BINARYINDEX-VERIFY-032 | DOING | SPRINT_20260211_033 run-001: verifying `symbol-source-connectors` with Tier 0/1/2 evidence and claim-parity review. |
|
||||
| QA-BINARYINDEX-VERIFY-031 | DONE | SPRINT_20260211_033 run-001: Tier 0/1/2 command checks passed, but claim-parity review terminalized `symbol-change-tracking-in-binary-diffs` as `not_implemented` because `IrDiffGenerator` is still placeholder-backed. |
|
||||
| QA-BINARYINDEX-VERIFY-015 | DONE | SPRINT_20260211_033 run-002: remediated PatchCoverage runtime wiring and rechecked Tier 0/1/2; terminalized `delta-signature-matching-and-patch-coverage-analysis` as `not_implemented` because `IrDiffGenerator` remains placeholder-backed. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0738-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0738-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | Added contract JSON roundtrip assertions for ResolutionEvidence.hybridDiff and function-level fields. |
|
||||
|
||||
|
||||
@@ -75,7 +75,89 @@ public sealed class VulnResolutionContractsTests
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.HashExact,
|
||||
Confidence = 0.9m,
|
||||
FixMethod = ResolutionFixMethods.SecurityFeed
|
||||
FixMethod = ResolutionFixMethods.SecurityFeed,
|
||||
FixConfidence = 0.9m,
|
||||
ChangedFunctions =
|
||||
[
|
||||
new FunctionChangeInfo
|
||||
{
|
||||
Name = "openssl::verify",
|
||||
ChangeType = "Modified",
|
||||
Similarity = 0.82m,
|
||||
VulnerableSize = 304,
|
||||
PatchedSize = 312
|
||||
}
|
||||
],
|
||||
HybridDiff = new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScriptDigest = "sha256:edits",
|
||||
OldSymbolMapDigest = "sha256:old-map",
|
||||
NewSymbolMapDigest = "sha256:new-map",
|
||||
SymbolPatchPlanDigest = "sha256:plan",
|
||||
PatchManifestDigest = "sha256:manifest",
|
||||
SemanticEditScript = new SemanticEditScriptArtifact
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
SourceTreeDigest = "sha256:tree",
|
||||
Edits =
|
||||
[
|
||||
new SemanticEditRecord
|
||||
{
|
||||
StableId = "sha256:edit-1",
|
||||
EditType = "update",
|
||||
NodeKind = "method",
|
||||
NodePath = "openssl::verify",
|
||||
Anchor = "openssl::verify"
|
||||
}
|
||||
]
|
||||
},
|
||||
SymbolPatchPlan = new SymbolPatchPlanArtifact
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
BuildIdBefore = "baseline:build-id",
|
||||
BuildIdAfter = "build-id",
|
||||
EditsDigest = "sha256:edits",
|
||||
SymbolMapDigestBefore = "sha256:old-map",
|
||||
SymbolMapDigestAfter = "sha256:new-map",
|
||||
Changes =
|
||||
[
|
||||
new SymbolPatchChange
|
||||
{
|
||||
Symbol = "openssl::verify",
|
||||
ChangeType = "modified",
|
||||
AstAnchors = ["openssl::verify"],
|
||||
DeltaRef = "sha256:delta"
|
||||
}
|
||||
]
|
||||
},
|
||||
PatchManifest = new PatchManifestArtifact
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
BuildId = "build-id",
|
||||
NormalizationRecipeId = "recipe-v1",
|
||||
TotalDeltaBytes = 8,
|
||||
Patches =
|
||||
[
|
||||
new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = "openssl::verify",
|
||||
AddressRange = "0x401120-0x4012AF",
|
||||
DeltaDigest = "sha256:delta",
|
||||
DeltaSizeBytes = 8,
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = 304,
|
||||
Hash = "sha256:pre"
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = 312,
|
||||
Hash = "sha256:post"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,6 +171,12 @@ public sealed class VulnResolutionContractsTests
|
||||
roundTrip.ResolvedAt.Should().Be(response.ResolvedAt);
|
||||
roundTrip.Evidence!.MatchType.Should().Be(response.Evidence!.MatchType);
|
||||
roundTrip.Evidence!.FixMethod.Should().Be(response.Evidence!.FixMethod);
|
||||
roundTrip.Evidence!.FixConfidence.Should().Be(response.Evidence!.FixConfidence);
|
||||
roundTrip.Evidence!.ChangedFunctions.Should().HaveCount(1);
|
||||
roundTrip.Evidence!.ChangedFunctions![0].Name.Should().Be("openssl::verify");
|
||||
roundTrip.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
roundTrip.Evidence!.HybridDiff!.PatchManifestDigest.Should().Be("sha256:manifest");
|
||||
roundTrip.Evidence!.HybridDiff!.PatchManifest!.Patches.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static List<ValidationResult> Validate(object instance)
|
||||
@@ -98,3 +186,4 @@ public sealed class VulnResolutionContractsTests
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,80 @@ public sealed class ResolutionServiceTests
|
||||
result.Status.Should().Be(ResolutionStatus.Unknown);
|
||||
result.Evidence!.MatchType.Should().Be(ResolutionMatchTypes.Fingerprint);
|
||||
}
|
||||
[Fact]
|
||||
public async Task ResolveAsync_IdentityMatch_EmitsHybridDiffEvidence()
|
||||
{
|
||||
var stub = new StubBinaryVulnerabilityService
|
||||
{
|
||||
OnIdentity = _ =>
|
||||
[
|
||||
new BinaryVulnMatch
|
||||
{
|
||||
CveId = "CVE-2024-1111",
|
||||
VulnerablePurl = "pkg:deb/debian/openssl@1.2.3",
|
||||
Method = MatchMethod.BuildIdCatalog,
|
||||
Confidence = 0.97m,
|
||||
Evidence = new MatchEvidence
|
||||
{
|
||||
MatchedFunction = "openssl::verify_chain",
|
||||
Similarity = 0.89m
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var service = CreateService(stub);
|
||||
|
||||
var request = new VulnResolutionRequest
|
||||
{
|
||||
Package = "pkg:deb/debian/openssl@1.2.3",
|
||||
BuildId = "build-id"
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(request, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
result.Status.Should().Be(ResolutionStatus.Fixed);
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence!.ChangedFunctions.Should().ContainSingle();
|
||||
result.Evidence!.ChangedFunctions![0].Name.Should().Be("openssl::verify_chain");
|
||||
result.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.PatchManifest.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.PatchManifest!.Patches.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_SpecificCve_EmitsHybridDiffEvidence()
|
||||
{
|
||||
var stub = new StubBinaryVulnerabilityService
|
||||
{
|
||||
OnFixStatus = (_, _, _, _) => new FixStatusResult
|
||||
{
|
||||
State = FixState.Fixed,
|
||||
FixedVersion = "1.0.1",
|
||||
Method = FixMethod.PatchHeader,
|
||||
Confidence = 0.91m,
|
||||
EvidenceId = Guid.NewGuid()
|
||||
}
|
||||
};
|
||||
|
||||
var service = CreateService(stub);
|
||||
|
||||
var request = new VulnResolutionRequest
|
||||
{
|
||||
Package = "pkg:deb/debian/openssl@1.2.3",
|
||||
BuildId = "build-id",
|
||||
CveId = "CVE-2024-2222"
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(request, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
result.Status.Should().Be(ResolutionStatus.Fixed);
|
||||
result.FixedVersion.Should().Be("1.0.1");
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence!.FixMethod.Should().Be(ResolutionFixMethods.PatchHeader);
|
||||
result.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.SemanticEditScript!.Edits.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveBatchAsync_TruncatesToMaxBatchSize()
|
||||
@@ -122,6 +196,7 @@ public sealed class ResolutionServiceTests
|
||||
{
|
||||
public Func<BinaryIdentity, ImmutableArray<BinaryVulnMatch>>? OnIdentity { get; init; }
|
||||
public Func<byte[], ImmutableArray<BinaryVulnMatch>>? OnFingerprint { get; init; }
|
||||
public Func<string, string, string, string, FixStatusResult?>? OnFixStatus { get; init; }
|
||||
|
||||
public Task<ImmutableArray<BinaryVulnMatch>> LookupByIdentityAsync(
|
||||
BinaryIdentity identity,
|
||||
@@ -148,7 +223,8 @@ public sealed class ResolutionServiceTests
|
||||
string cveId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<FixStatusResult?>(null);
|
||||
var status = OnFixStatus?.Invoke(distro, release, sourcePkg, cveId);
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public Task<ImmutableDictionary<string, FixStatusResult>> GetFixStatusBatchAsync(
|
||||
@@ -227,3 +303,4 @@ public sealed class ResolutionServiceTests
|
||||
public override DateTimeOffset GetUtcNow() => _fixed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0117-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0117-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | Added resolution behavioral tests validating hybrid diff evidence emission for identity and CVE-specific resolution paths. |
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
public sealed class DeltaSigServiceHybridPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvaluatePolicy_EnforcesHybridEvidenceAndNamespaceControls()
|
||||
{
|
||||
var service = CreateService();
|
||||
var predicate = CreatePredicate(CreateHybridEvidence(
|
||||
symbol: "Crypto.Core.Encrypt",
|
||||
anchors: [],
|
||||
deltaBytes: 24));
|
||||
|
||||
var result = service.EvaluatePolicy(predicate, new DeltaSigPolicyOptions
|
||||
{
|
||||
RequireHybridEvidence = true,
|
||||
RequireAstAnchors = true,
|
||||
DeniedSymbolPrefixes = ["Crypto."],
|
||||
MaxPatchManifestDeltaBytes = 8
|
||||
});
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Violations.Should().Contain(v => v.Contains("AST anchors", StringComparison.Ordinal));
|
||||
result.Violations.Should().Contain(v => v.Contains("Denied symbol prefix", StringComparison.Ordinal));
|
||||
result.Violations.Should().Contain(v => v.Contains("Patch manifest changed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePolicy_PassesWhenHybridEvidenceIsCompliant()
|
||||
{
|
||||
var service = CreateService();
|
||||
var predicate = CreatePredicate(CreateHybridEvidence(
|
||||
symbol: "Safe.Module.Apply",
|
||||
anchors: ["Safe.Module.Apply"],
|
||||
deltaBytes: 4));
|
||||
|
||||
var result = service.EvaluatePolicy(predicate, new DeltaSigPolicyOptions
|
||||
{
|
||||
RequireHybridEvidence = true,
|
||||
RequireAstAnchors = true,
|
||||
MaxPatchManifestDeltaBytes = 16,
|
||||
DeniedSymbolPrefixes = ["Crypto."],
|
||||
ProtectedSymbolPrefixes = ["Immutable.Namespace."]
|
||||
});
|
||||
|
||||
result.Passed.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static DeltaSigService CreateService()
|
||||
{
|
||||
return new DeltaSigService(
|
||||
Mock.Of<IDeltaSignatureGenerator>(),
|
||||
Mock.Of<IDeltaSignatureMatcher>(),
|
||||
NullLogger<DeltaSigService>.Instance,
|
||||
new HybridDiffComposer());
|
||||
}
|
||||
|
||||
private static DeltaSigPredicate CreatePredicate(HybridDiffEvidence? hybrid)
|
||||
{
|
||||
return new DeltaSigPredicate
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://old",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "old" },
|
||||
Arch = "linux-amd64",
|
||||
Role = "old"
|
||||
},
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://new",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "new" },
|
||||
Arch = "linux-amd64",
|
||||
Role = "new"
|
||||
}
|
||||
],
|
||||
Delta =
|
||||
[
|
||||
new FunctionDelta
|
||||
{
|
||||
FunctionId = hybrid?.SymbolPatchPlan.Changes.FirstOrDefault()?.Symbol ?? "unknown",
|
||||
Address = 0x1000,
|
||||
OldHash = "a",
|
||||
NewHash = "b",
|
||||
OldSize = 10,
|
||||
NewSize = 12,
|
||||
DiffLen = 2,
|
||||
ChangeType = "modified"
|
||||
}
|
||||
],
|
||||
Summary = new DeltaSummary
|
||||
{
|
||||
TotalFunctions = 1,
|
||||
FunctionsAdded = 0,
|
||||
FunctionsRemoved = 0,
|
||||
FunctionsModified = 1,
|
||||
FunctionsUnchanged = 0,
|
||||
TotalBytesChanged = 2,
|
||||
MinSemanticSimilarity = 1,
|
||||
AvgSemanticSimilarity = 1,
|
||||
MaxSemanticSimilarity = 1
|
||||
},
|
||||
Tooling = new DeltaTooling
|
||||
{
|
||||
Lifter = "b2r2",
|
||||
LifterVersion = "0.7.0",
|
||||
CanonicalIr = "b2r2-lowuir",
|
||||
DiffAlgorithm = "ir-semantic"
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
HybridDiff = hybrid
|
||||
};
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence CreateHybridEvidence(string symbol, IReadOnlyList<string> anchors, long deltaBytes)
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
|
||||
var oldMap = new SymbolMap
|
||||
{
|
||||
BuildId = "old-build",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = symbol,
|
||||
AddressStart = 0x1000,
|
||||
AddressEnd = 0x100f,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var newMap = new SymbolMap
|
||||
{
|
||||
BuildId = "new-build",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = symbol,
|
||||
AddressStart = 0x2000,
|
||||
AddressEnd = 0x200f,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var functionDelta = new FunctionDelta
|
||||
{
|
||||
FunctionId = symbol,
|
||||
Address = 0x2000,
|
||||
OldHash = "old-hash",
|
||||
NewHash = "new-hash",
|
||||
OldSize = 16,
|
||||
NewSize = 16 + deltaBytes,
|
||||
DiffLen = deltaBytes,
|
||||
ChangeType = "modified"
|
||||
};
|
||||
|
||||
var evidence = composer.Compose(
|
||||
sourceDiffs: [],
|
||||
oldSymbolMap: oldMap,
|
||||
newSymbolMap: newMap,
|
||||
deltas: [functionDelta],
|
||||
normalizationRecipeId: "recipe-1");
|
||||
|
||||
var changes = evidence.SymbolPatchPlan.Changes
|
||||
.Select(c => c with { AstAnchors = anchors })
|
||||
.ToList();
|
||||
|
||||
var updatedPlan = evidence.SymbolPatchPlan with { Changes = changes };
|
||||
|
||||
return evidence with
|
||||
{
|
||||
SymbolPatchPlan = updatedPlan,
|
||||
SymbolPatchPlanDigest = composer.ComputeDigest(updatedPlan)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
public sealed class DeltaSigServiceVerificationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsHybridEvidenceMismatch_WhenHybridDigestsAreInvalid()
|
||||
{
|
||||
var binary = new MemoryStream([1, 2, 3, 4, 5]);
|
||||
var newDigest = ComputeSha256(binary);
|
||||
|
||||
var generator = new Mock<IDeltaSignatureGenerator>();
|
||||
generator
|
||||
.Setup(x => x.GenerateSignaturesAsync(
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<DeltaSignatureRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DeltaSignature
|
||||
{
|
||||
Cve = "verification",
|
||||
Package = new PackageRef("pkg", null),
|
||||
Target = new TargetRef("x86_64", "gnu"),
|
||||
Normalization = new NormalizationRef("recipe-1", "1.0.0", []),
|
||||
SignatureState = "verification",
|
||||
Symbols = []
|
||||
});
|
||||
|
||||
var service = new DeltaSigService(
|
||||
generator.Object,
|
||||
Mock.Of<IDeltaSignatureMatcher>(),
|
||||
NullLogger<DeltaSigService>.Instance,
|
||||
new HybridDiffComposer());
|
||||
|
||||
var hybrid = CreateHybridEvidenceWithInvalidDigest();
|
||||
|
||||
var predicate = new DeltaSigPredicate
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://old",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "old" },
|
||||
Arch = "linux-amd64",
|
||||
Role = "old"
|
||||
},
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://new",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = newDigest },
|
||||
Arch = "linux-amd64",
|
||||
Role = "new"
|
||||
}
|
||||
],
|
||||
Delta = [],
|
||||
Summary = new DeltaSummary
|
||||
{
|
||||
TotalFunctions = 0,
|
||||
FunctionsAdded = 0,
|
||||
FunctionsRemoved = 0,
|
||||
FunctionsModified = 0,
|
||||
FunctionsUnchanged = 0,
|
||||
TotalBytesChanged = 0,
|
||||
MinSemanticSimilarity = 1,
|
||||
AvgSemanticSimilarity = 1,
|
||||
MaxSemanticSimilarity = 1
|
||||
},
|
||||
Tooling = new DeltaTooling
|
||||
{
|
||||
Lifter = "b2r2",
|
||||
LifterVersion = "0.7.0",
|
||||
CanonicalIr = "b2r2-lowuir",
|
||||
DiffAlgorithm = "ir-semantic"
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
HybridDiff = hybrid
|
||||
};
|
||||
|
||||
binary.Position = 0;
|
||||
var result = await service.VerifyAsync(predicate, binary);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Status.Should().Be(DeltaSigVerificationStatus.HybridEvidenceMismatch);
|
||||
result.Message.Should().Contain("semantic_edit_script");
|
||||
}
|
||||
|
||||
private static string ComputeSha256(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
stream.Position = 0;
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence CreateHybridEvidenceWithInvalidDigest()
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
|
||||
var oldMap = new SymbolMap
|
||||
{
|
||||
BuildId = "old-build",
|
||||
BinaryDigest = "sha256:old",
|
||||
Symbols = []
|
||||
};
|
||||
|
||||
var newMap = new SymbolMap
|
||||
{
|
||||
BuildId = "new-build",
|
||||
BinaryDigest = "sha256:new",
|
||||
Symbols = []
|
||||
};
|
||||
|
||||
var evidence = composer.Compose(
|
||||
sourceDiffs: [],
|
||||
oldSymbolMap: oldMap,
|
||||
newSymbolMap: newMap,
|
||||
deltas: [],
|
||||
normalizationRecipeId: "recipe-1");
|
||||
|
||||
return evidence with { SemanticEditScriptDigest = "tampered-digest" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
public sealed class HybridDiffComposerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compose_WithIdenticalInputs_IsDeterministic()
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
var sourceDiffs = new[]
|
||||
{
|
||||
new SourceFileDiff
|
||||
{
|
||||
Path = "src/Example.cs",
|
||||
BeforeContent = "class C { int Add(int a, int b) { return a + b; } }",
|
||||
AfterContent = "class C { int Add(int a, int b) { return a + b + 1; } }"
|
||||
}
|
||||
};
|
||||
|
||||
var oldMap = new SymbolMap
|
||||
{
|
||||
BuildId = "build-old",
|
||||
BinaryDigest = "sha256:old",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = "C::Add",
|
||||
AddressStart = 0x401000,
|
||||
AddressEnd = 0x40103F,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var newMap = new SymbolMap
|
||||
{
|
||||
BuildId = "build-new",
|
||||
BinaryDigest = "sha256:new",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = "C::Add",
|
||||
AddressStart = 0x501000,
|
||||
AddressEnd = 0x501047,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var deltas = new[]
|
||||
{
|
||||
new FunctionDelta
|
||||
{
|
||||
FunctionId = "C::Add",
|
||||
Address = 0x501000,
|
||||
OldHash = "old-hash",
|
||||
NewHash = "new-hash",
|
||||
OldSize = 64,
|
||||
NewSize = 72,
|
||||
DiffLen = 8,
|
||||
ChangeType = "modified"
|
||||
}
|
||||
};
|
||||
|
||||
var left = composer.Compose(sourceDiffs, oldMap, newMap, deltas, "recipe-1");
|
||||
var right = composer.Compose(sourceDiffs, oldMap, newMap, deltas, "recipe-1");
|
||||
|
||||
left.SemanticEditScriptDigest.Should().Be(right.SemanticEditScriptDigest);
|
||||
left.OldSymbolMapDigest.Should().Be(right.OldSymbolMapDigest);
|
||||
left.NewSymbolMapDigest.Should().Be(right.NewSymbolMapDigest);
|
||||
left.SymbolPatchPlanDigest.Should().Be(right.SymbolPatchPlanDigest);
|
||||
left.PatchManifestDigest.Should().Be(right.PatchManifestDigest);
|
||||
left.PatchManifest.Patches.Should().ContainSingle(p => p.Symbol == "C::Add");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSymbolMap_MapsSourcePaths_AndOrdersByAddress()
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
|
||||
var manifest = new SymbolManifest
|
||||
{
|
||||
ManifestId = "manifest-1",
|
||||
DebugId = "dbg-1",
|
||||
BinaryName = "sample.bin",
|
||||
Format = BinaryFormat.Elf,
|
||||
TenantId = "tenant-a",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolEntry
|
||||
{
|
||||
Address = 0x401100,
|
||||
Size = 16,
|
||||
MangledName = "b",
|
||||
DemangledName = "B::Method",
|
||||
Type = SymbolType.Function,
|
||||
SourceFile = "/obj/B.cs",
|
||||
SourceLine = 20
|
||||
},
|
||||
new SymbolEntry
|
||||
{
|
||||
Address = 0x401000,
|
||||
Size = 32,
|
||||
MangledName = "a",
|
||||
DemangledName = "A::Method",
|
||||
Type = SymbolType.Function,
|
||||
SourceFile = "/obj/A.cs",
|
||||
SourceLine = 10
|
||||
}
|
||||
],
|
||||
SourceMappings =
|
||||
[
|
||||
new SourceMapping
|
||||
{
|
||||
CompiledPath = "/obj/A.cs",
|
||||
SourcePath = "src/A.cs"
|
||||
},
|
||||
new SourceMapping
|
||||
{
|
||||
CompiledPath = "/obj/B.cs",
|
||||
SourcePath = "src/B.cs"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var map = composer.BuildSymbolMap(manifest, "sha256:abc");
|
||||
|
||||
map.BuildId.Should().Be("dbg-1");
|
||||
map.Symbols.Should().HaveCount(2);
|
||||
map.Symbols[0].Name.Should().Be("A::Method");
|
||||
map.Symbols[1].Name.Should().Be("B::Method");
|
||||
map.Symbols[0].SourceRanges.Should().ContainSingle(r => r.File == "src/A.cs" && r.LineStart == 10 && r.LineEnd == 10);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
# BinaryIndex DeltaSig Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md` (hybrid tests) and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md` (historical baseline).
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| BHP-TEST-20260216 | DONE | SPRINT_20260216_001: targeted behavioral tests for hybrid diff composer/service policy and verifier logic. |
|
||||
| QA-BINARYINDEX-VERIFY-034 | DONE | SPRINT_20260211_033 run-002: expanded golden CVE fixture package coverage to include glibc/zlib/curl and added regression assertion for required high-impact package set. |
|
||||
| QA-BINARYINDEX-VERIFY-032 | DOING | SPRINT_20260211_033 run-001: executing Tier 0/1/2 verification for `symbol-source-connectors` with deterministic behavioral evidence capture. |
|
||||
| QA-BINARYINDEX-VERIFY-031 | DONE | SPRINT_20260211_033 run-001: executed Tier 0/1/2 verification for `symbol-change-tracking-in-binary-diffs`; terminalized feature as `not_implemented` due missing IR-diff behavioral implementation and test coverage. |
|
||||
@@ -13,3 +14,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0743-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0743-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -155,6 +155,46 @@ public sealed class CachedResolutionServiceTests
|
||||
cache.GetCalls.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FromCache_ProvidesHybridDiffEvidence()
|
||||
{
|
||||
var fakeInner = new FakeResolutionService(_timeProvider);
|
||||
var cache = new FakeResolutionCacheService();
|
||||
var cacheOptions = Options.Create(new ResolutionCacheOptions());
|
||||
var serviceOptions = Options.Create(new ResolutionServiceOptions());
|
||||
|
||||
var service = new CachedResolutionService(
|
||||
fakeInner,
|
||||
cache,
|
||||
cacheOptions,
|
||||
serviceOptions,
|
||||
_timeProvider,
|
||||
NullLogger<CachedResolutionService>.Instance);
|
||||
|
||||
var request = new VulnResolutionRequest
|
||||
{
|
||||
Package = "pkg:deb/debian/openssl@3.0.7",
|
||||
BuildId = "build-hybrid"
|
||||
};
|
||||
|
||||
var cacheKey = cache.GenerateCacheKey(request);
|
||||
cache.Entries[cacheKey] = new CachedResolution
|
||||
{
|
||||
Status = ResolutionStatus.Fixed,
|
||||
FixedVersion = "1.0.2",
|
||||
CachedAt = _timeProvider.GetUtcNow(),
|
||||
Confidence = 0.93m,
|
||||
MatchType = ResolutionMatchTypes.BuildId
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(request, null, TestContext.Current.CancellationToken);
|
||||
|
||||
result.FromCache.Should().BeTrue();
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.PatchManifestDigest.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_BypassCache_SkipsCache()
|
||||
{
|
||||
@@ -529,3 +569,4 @@ internal sealed class FakeResolutionCacheService : IResolutionCacheService
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,3 +16,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0747-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0747-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | Added cached resolution behavioral test proving evidence.hybridDiff is present on cache hits. |
|
||||
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Management endpoints for feed mirrors, bundles, version locks, and offline status.
|
||||
/// These endpoints serve the frontend dashboard at /operations/feeds.
|
||||
/// Routes: /api/v1/concelier/mirrors, /bundles, /version-locks, /offline-status, /imports, /snapshots
|
||||
/// </summary>
|
||||
internal static class FeedMirrorManagementEndpoints
|
||||
{
|
||||
public static void MapFeedMirrorManagementEndpoints(this WebApplication app)
|
||||
{
|
||||
// Mirror management
|
||||
var mirrors = app.MapGroup("/api/v1/concelier/mirrors")
|
||||
.WithTags("FeedMirrors");
|
||||
|
||||
mirrors.MapGet(string.Empty, ListMirrors);
|
||||
mirrors.MapGet("/{mirrorId}", GetMirror);
|
||||
mirrors.MapPatch("/{mirrorId}", UpdateMirrorConfig);
|
||||
mirrors.MapPost("/{mirrorId}/sync", TriggerSync);
|
||||
mirrors.MapGet("/{mirrorId}/snapshots", ListMirrorSnapshots);
|
||||
mirrors.MapGet("/{mirrorId}/retention", GetRetentionConfig);
|
||||
mirrors.MapPut("/{mirrorId}/retention", UpdateRetentionConfig);
|
||||
|
||||
// Snapshot operations (by snapshotId)
|
||||
var snapshots = app.MapGroup("/api/v1/concelier/snapshots")
|
||||
.WithTags("FeedSnapshots");
|
||||
|
||||
snapshots.MapGet("/{snapshotId}", GetSnapshot);
|
||||
snapshots.MapPost("/{snapshotId}/download", DownloadSnapshot);
|
||||
snapshots.MapPatch("/{snapshotId}", PinSnapshot);
|
||||
snapshots.MapDelete("/{snapshotId}", DeleteSnapshot);
|
||||
|
||||
// Bundle management
|
||||
var bundles = app.MapGroup("/api/v1/concelier/bundles")
|
||||
.WithTags("AirGapBundles");
|
||||
|
||||
bundles.MapGet(string.Empty, ListBundles);
|
||||
bundles.MapGet("/{bundleId}", GetBundle);
|
||||
bundles.MapPost(string.Empty, CreateBundle);
|
||||
bundles.MapDelete("/{bundleId}", DeleteBundle);
|
||||
bundles.MapPost("/{bundleId}/download", DownloadBundle);
|
||||
|
||||
// Import operations
|
||||
var imports = app.MapGroup("/api/v1/concelier/imports")
|
||||
.WithTags("AirGapImports");
|
||||
|
||||
imports.MapPost("/validate", ValidateImport);
|
||||
imports.MapPost("/", StartImport);
|
||||
imports.MapGet("/{importId}", GetImportProgress);
|
||||
|
||||
// Version lock operations
|
||||
var versionLocks = app.MapGroup("/api/v1/concelier/version-locks")
|
||||
.WithTags("VersionLocks");
|
||||
|
||||
versionLocks.MapGet(string.Empty, ListVersionLocks);
|
||||
versionLocks.MapGet("/{feedType}", GetVersionLock);
|
||||
versionLocks.MapPut("/{feedType}", SetVersionLock);
|
||||
versionLocks.MapDelete("/{lockId}", RemoveVersionLock);
|
||||
|
||||
// Offline status
|
||||
app.MapGet("/api/v1/concelier/offline-status", GetOfflineSyncStatus)
|
||||
.WithTags("OfflineStatus");
|
||||
}
|
||||
|
||||
// ---- Mirror Handlers ----
|
||||
|
||||
private static IResult ListMirrors(
|
||||
[FromQuery] string? feedTypes,
|
||||
[FromQuery] string? syncStatuses,
|
||||
[FromQuery] bool? enabled,
|
||||
[FromQuery] string? search)
|
||||
{
|
||||
var result = MirrorSeedData.Mirrors.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(feedTypes))
|
||||
{
|
||||
var types = feedTypes.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
result = result.Where(m => types.Contains(m.FeedType, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(syncStatuses))
|
||||
{
|
||||
var statuses = syncStatuses.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
result = result.Where(m => statuses.Contains(m.SyncStatus, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
result = result.Where(m => m.Enabled == enabled.Value);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.ToLowerInvariant();
|
||||
result = result.Where(m =>
|
||||
m.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.FeedType.Contains(term, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return HttpResults.Ok(result.ToList());
|
||||
}
|
||||
|
||||
private static IResult GetMirror(string mirrorId)
|
||||
{
|
||||
var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId);
|
||||
return mirror is not null ? HttpResults.Ok(mirror) : HttpResults.NotFound();
|
||||
}
|
||||
|
||||
private static IResult UpdateMirrorConfig(string mirrorId, [FromBody] MirrorConfigUpdateDto config)
|
||||
{
|
||||
var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId);
|
||||
if (mirror is null) return HttpResults.NotFound();
|
||||
|
||||
return HttpResults.Ok(mirror with
|
||||
{
|
||||
Enabled = config.Enabled ?? mirror.Enabled,
|
||||
SyncIntervalMinutes = config.SyncIntervalMinutes ?? mirror.SyncIntervalMinutes,
|
||||
UpstreamUrl = config.UpstreamUrl ?? mirror.UpstreamUrl,
|
||||
UpdatedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult TriggerSync(string mirrorId)
|
||||
{
|
||||
var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId);
|
||||
if (mirror is null) return HttpResults.NotFound();
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
mirrorId,
|
||||
success = true,
|
||||
snapshotId = $"snap-{mirror.FeedType}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
|
||||
recordsUpdated = 542,
|
||||
durationSeconds = 25,
|
||||
error = (string?)null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Snapshot Handlers ----
|
||||
|
||||
private static IResult ListMirrorSnapshots(string mirrorId)
|
||||
{
|
||||
var snapshots = MirrorSeedData.Snapshots.Where(s => s.MirrorId == mirrorId).ToList();
|
||||
return HttpResults.Ok(snapshots);
|
||||
}
|
||||
|
||||
private static IResult GetSnapshot(string snapshotId)
|
||||
{
|
||||
var snapshot = MirrorSeedData.Snapshots.FirstOrDefault(s => s.SnapshotId == snapshotId);
|
||||
return snapshot is not null ? HttpResults.Ok(snapshot) : HttpResults.NotFound();
|
||||
}
|
||||
|
||||
private static IResult DownloadSnapshot(string snapshotId)
|
||||
{
|
||||
var snapshot = MirrorSeedData.Snapshots.FirstOrDefault(s => s.SnapshotId == snapshotId);
|
||||
if (snapshot is null) return HttpResults.NotFound();
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
snapshotId,
|
||||
status = "completed",
|
||||
bytesDownloaded = snapshot.SizeBytes,
|
||||
totalBytes = snapshot.SizeBytes,
|
||||
percentComplete = 100,
|
||||
estimatedSecondsRemaining = (int?)null,
|
||||
error = (string?)null,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult PinSnapshot(string snapshotId, [FromBody] PinSnapshotDto request)
|
||||
{
|
||||
var snapshot = MirrorSeedData.Snapshots.FirstOrDefault(s => s.SnapshotId == snapshotId);
|
||||
if (snapshot is null) return HttpResults.NotFound();
|
||||
return HttpResults.Ok(snapshot with { IsPinned = request.IsPinned });
|
||||
}
|
||||
|
||||
private static IResult DeleteSnapshot(string snapshotId)
|
||||
{
|
||||
var exists = MirrorSeedData.Snapshots.Any(s => s.SnapshotId == snapshotId);
|
||||
return exists ? HttpResults.NoContent() : HttpResults.NotFound();
|
||||
}
|
||||
|
||||
private static IResult GetRetentionConfig(string mirrorId)
|
||||
{
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
mirrorId,
|
||||
policy = "keep_n",
|
||||
keepCount = 10,
|
||||
excludePinned = true,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult UpdateRetentionConfig(string mirrorId, [FromBody] RetentionConfigDto config)
|
||||
{
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
mirrorId,
|
||||
policy = config.Policy ?? "keep_n",
|
||||
keepCount = config.KeepCount ?? 10,
|
||||
excludePinned = config.ExcludePinned ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Bundle Handlers ----
|
||||
|
||||
private static IResult ListBundles()
|
||||
{
|
||||
return HttpResults.Ok(MirrorSeedData.Bundles);
|
||||
}
|
||||
|
||||
private static IResult GetBundle(string bundleId)
|
||||
{
|
||||
var bundle = MirrorSeedData.Bundles.FirstOrDefault(b => b.BundleId == bundleId);
|
||||
return bundle is not null ? HttpResults.Ok(bundle) : HttpResults.NotFound();
|
||||
}
|
||||
|
||||
private static IResult CreateBundle([FromBody] CreateBundleDto request)
|
||||
{
|
||||
var bundle = new AirGapBundleDto
|
||||
{
|
||||
BundleId = $"bundle-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Status = "pending",
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
SizeBytes = 0,
|
||||
ChecksumSha256 = "",
|
||||
ChecksumSha512 = "",
|
||||
IncludedFeeds = request.IncludedFeeds ?? Array.Empty<string>(),
|
||||
SnapshotIds = request.SnapshotIds ?? Array.Empty<string>(),
|
||||
FeedVersions = new Dictionary<string, string>(),
|
||||
CreatedBy = "api",
|
||||
Metadata = new Dictionary<string, object>(),
|
||||
};
|
||||
return HttpResults.Created($"/api/v1/concelier/bundles/{bundle.BundleId}", bundle);
|
||||
}
|
||||
|
||||
private static IResult DeleteBundle(string bundleId)
|
||||
{
|
||||
var exists = MirrorSeedData.Bundles.Any(b => b.BundleId == bundleId);
|
||||
return exists ? HttpResults.NoContent() : HttpResults.NotFound();
|
||||
}
|
||||
|
||||
private static IResult DownloadBundle(string bundleId)
|
||||
{
|
||||
var bundle = MirrorSeedData.Bundles.FirstOrDefault(b => b.BundleId == bundleId);
|
||||
if (bundle is null) return HttpResults.NotFound();
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
snapshotId = bundleId,
|
||||
status = "completed",
|
||||
bytesDownloaded = bundle.SizeBytes,
|
||||
totalBytes = bundle.SizeBytes,
|
||||
percentComplete = 100,
|
||||
estimatedSecondsRemaining = (int?)null,
|
||||
error = (string?)null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Import Handlers ----
|
||||
|
||||
private static IResult ValidateImport()
|
||||
{
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
bundleId = "import-validation-temp",
|
||||
status = "valid",
|
||||
checksumValid = true,
|
||||
signatureValid = true,
|
||||
manifestValid = true,
|
||||
feedsFound = new[] { "nvd", "ghsa", "oval" },
|
||||
snapshotsFound = new[] { "snap-nvd-imported", "snap-ghsa-imported", "snap-oval-imported" },
|
||||
totalRecords = 325000,
|
||||
validationErrors = Array.Empty<string>(),
|
||||
warnings = new[] { "OVAL data is 3 days older than NVD data" },
|
||||
canImport = true,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult StartImport([FromBody] StartImportDto request)
|
||||
{
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
importId = $"import-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
|
||||
bundleId = request.BundleId,
|
||||
status = "importing",
|
||||
currentFeed = "nvd",
|
||||
feedsCompleted = 0,
|
||||
feedsTotal = 3,
|
||||
recordsImported = 0,
|
||||
recordsTotal = 325000,
|
||||
percentComplete = 0,
|
||||
startedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
completedAt = (string?)null,
|
||||
error = (string?)null,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetImportProgress(string importId)
|
||||
{
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
importId,
|
||||
bundleId = "bundle-full-20251229",
|
||||
status = "completed",
|
||||
currentFeed = (string?)null,
|
||||
feedsCompleted = 3,
|
||||
feedsTotal = 3,
|
||||
recordsImported = 325000,
|
||||
recordsTotal = 325000,
|
||||
percentComplete = 100,
|
||||
startedAt = "2025-12-29T10:00:00Z",
|
||||
completedAt = "2025-12-29T10:15:00Z",
|
||||
error = (string?)null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Version Lock Handlers ----
|
||||
|
||||
private static IResult ListVersionLocks()
|
||||
{
|
||||
return HttpResults.Ok(MirrorSeedData.VersionLocks);
|
||||
}
|
||||
|
||||
private static IResult GetVersionLock(string feedType)
|
||||
{
|
||||
var vLock = MirrorSeedData.VersionLocks.FirstOrDefault(l =>
|
||||
string.Equals(l.FeedType, feedType, StringComparison.OrdinalIgnoreCase));
|
||||
return vLock is not null ? HttpResults.Ok(vLock) : HttpResults.Ok((object?)null);
|
||||
}
|
||||
|
||||
private static IResult SetVersionLock(string feedType, [FromBody] SetVersionLockDto request)
|
||||
{
|
||||
var newLock = new VersionLockDto
|
||||
{
|
||||
LockId = $"lock-{feedType}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
|
||||
FeedType = feedType,
|
||||
Mode = request.Mode ?? "pinned",
|
||||
PinnedVersion = request.PinnedVersion,
|
||||
PinnedSnapshotId = request.PinnedSnapshotId,
|
||||
LockedDate = request.LockedDate,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
CreatedBy = "api",
|
||||
Notes = request.Notes,
|
||||
};
|
||||
return HttpResults.Ok(newLock);
|
||||
}
|
||||
|
||||
private static IResult RemoveVersionLock(string lockId)
|
||||
{
|
||||
var exists = MirrorSeedData.VersionLocks.Any(l => l.LockId == lockId);
|
||||
return exists ? HttpResults.NoContent() : HttpResults.NotFound();
|
||||
}
|
||||
|
||||
// ---- Offline Status Handler ----
|
||||
|
||||
private static IResult GetOfflineSyncStatus()
|
||||
{
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
state = "partial",
|
||||
lastOnlineAt = "2025-12-29T08:00:00Z",
|
||||
mirrorStats = new { total = 6, synced = 3, stale = 1, error = 1 },
|
||||
feedStats = new Dictionary<string, object>
|
||||
{
|
||||
["nvd"] = new { lastUpdated = "2025-12-29T08:00:00Z", recordCount = 245832, isStale = false },
|
||||
["ghsa"] = new { lastUpdated = "2025-12-29T09:30:00Z", recordCount = 48523, isStale = false },
|
||||
["oval"] = new { lastUpdated = "2025-12-27T08:00:00Z", recordCount = 35621, isStale = true },
|
||||
["osv"] = new { lastUpdated = "2025-12-28T20:00:00Z", recordCount = 125432, isStale = true },
|
||||
["epss"] = new { lastUpdated = "2025-12-29T00:00:00Z", recordCount = 245000, isStale = false },
|
||||
["kev"] = new { lastUpdated = "2025-12-15T00:00:00Z", recordCount = 1123, isStale = true },
|
||||
["custom"] = new { lastUpdated = (string?)null, recordCount = 0, isStale = false },
|
||||
},
|
||||
totalStorageBytes = 5_145_000_000L,
|
||||
oldestDataAge = "2025-12-15T00:00:00Z",
|
||||
recommendations = new[]
|
||||
{
|
||||
"OSV mirror has sync errors - check network connectivity",
|
||||
"OVAL mirror is 2 days stale - trigger manual sync",
|
||||
"KEV mirror is disabled - enable for complete coverage",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record FeedMirrorDto
|
||||
{
|
||||
public required string MirrorId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string FeedType { get; init; }
|
||||
public required string UpstreamUrl { get; init; }
|
||||
public required string LocalPath { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
public required string SyncStatus { get; init; }
|
||||
public string? LastSyncAt { get; init; }
|
||||
public string? NextSyncAt { get; init; }
|
||||
public int SyncIntervalMinutes { get; init; }
|
||||
public int SnapshotCount { get; init; }
|
||||
public long TotalSizeBytes { get; init; }
|
||||
public string? LatestSnapshotId { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public required string UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record FeedSnapshotDto
|
||||
{
|
||||
public required string SnapshotId { get; init; }
|
||||
public required string MirrorId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public long SizeBytes { get; init; }
|
||||
public required string ChecksumSha256 { get; init; }
|
||||
public required string ChecksumSha512 { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
public required string FeedDate { get; init; }
|
||||
public bool IsLatest { get; init; }
|
||||
public bool IsPinned { get; init; }
|
||||
public required string DownloadUrl { get; init; }
|
||||
public string? ExpiresAt { get; init; }
|
||||
public Dictionary<string, object> Metadata { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record AirGapBundleDto
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public string? ExpiresAt { get; init; }
|
||||
public long SizeBytes { get; init; }
|
||||
public required string ChecksumSha256 { get; init; }
|
||||
public required string ChecksumSha512 { get; init; }
|
||||
public string[] IncludedFeeds { get; init; } = Array.Empty<string>();
|
||||
public string[] SnapshotIds { get; init; } = Array.Empty<string>();
|
||||
public Dictionary<string, string> FeedVersions { get; init; } = new();
|
||||
public string? DownloadUrl { get; init; }
|
||||
public string? SignatureUrl { get; init; }
|
||||
public string? ManifestUrl { get; init; }
|
||||
public required string CreatedBy { get; init; }
|
||||
public Dictionary<string, object> Metadata { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record VersionLockDto
|
||||
{
|
||||
public required string LockId { get; init; }
|
||||
public required string FeedType { get; init; }
|
||||
public required string Mode { get; init; }
|
||||
public string? PinnedVersion { get; init; }
|
||||
public string? PinnedSnapshotId { get; init; }
|
||||
public string? LockedDate { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public required string CreatedBy { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorConfigUpdateDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? SyncIntervalMinutes { get; init; }
|
||||
public string? UpstreamUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PinSnapshotDto
|
||||
{
|
||||
public bool IsPinned { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RetentionConfigDto
|
||||
{
|
||||
public string? MirrorId { get; init; }
|
||||
public string? Policy { get; init; }
|
||||
public int? KeepCount { get; init; }
|
||||
public bool? ExcludePinned { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CreateBundleDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string[]? IncludedFeeds { get; init; }
|
||||
public string[]? SnapshotIds { get; init; }
|
||||
public int? ExpirationDays { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StartImportDto
|
||||
{
|
||||
public string? BundleId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SetVersionLockDto
|
||||
{
|
||||
public string? Mode { get; init; }
|
||||
public string? PinnedVersion { get; init; }
|
||||
public string? PinnedSnapshotId { get; init; }
|
||||
public string? LockedDate { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
// ---- Seed Data ----
|
||||
|
||||
internal static class MirrorSeedData
|
||||
{
|
||||
public static readonly List<FeedMirrorDto> Mirrors = new()
|
||||
{
|
||||
new() { MirrorId = "mirror-nvd-001", Name = "NVD Mirror", FeedType = "nvd", UpstreamUrl = "https://nvd.nist.gov/feeds/json/cve/1.1", LocalPath = "/data/mirrors/nvd", Enabled = true, SyncStatus = "synced", LastSyncAt = "2025-12-29T08:00:00Z", NextSyncAt = "2025-12-29T14:00:00Z", SyncIntervalMinutes = 360, SnapshotCount = 12, TotalSizeBytes = 2_500_000_000, LatestSnapshotId = "snap-nvd-20251229", CreatedAt = "2024-01-15T10:00:00Z", UpdatedAt = "2025-12-29T08:00:00Z" },
|
||||
new() { MirrorId = "mirror-ghsa-001", Name = "GitHub Security Advisories", FeedType = "ghsa", UpstreamUrl = "https://github.com/advisories", LocalPath = "/data/mirrors/ghsa", Enabled = true, SyncStatus = "syncing", LastSyncAt = "2025-12-29T06:00:00Z", SyncIntervalMinutes = 120, SnapshotCount = 24, TotalSizeBytes = 850_000_000, LatestSnapshotId = "snap-ghsa-20251229", CreatedAt = "2024-01-15T10:00:00Z", UpdatedAt = "2025-12-29T09:30:00Z" },
|
||||
new() { MirrorId = "mirror-oval-rhel-001", Name = "RHEL OVAL Definitions", FeedType = "oval", UpstreamUrl = "https://www.redhat.com/security/data/oval/v2", LocalPath = "/data/mirrors/oval-rhel", Enabled = true, SyncStatus = "stale", LastSyncAt = "2025-12-27T08:00:00Z", NextSyncAt = "2025-12-29T08:00:00Z", SyncIntervalMinutes = 1440, SnapshotCount = 8, TotalSizeBytes = 420_000_000, LatestSnapshotId = "snap-oval-rhel-20251227", CreatedAt = "2024-02-01T10:00:00Z", UpdatedAt = "2025-12-27T08:00:00Z" },
|
||||
new() { MirrorId = "mirror-osv-001", Name = "OSV Database", FeedType = "osv", UpstreamUrl = "https://osv.dev/api", LocalPath = "/data/mirrors/osv", Enabled = true, SyncStatus = "error", LastSyncAt = "2025-12-28T20:00:00Z", SyncIntervalMinutes = 240, SnapshotCount = 18, TotalSizeBytes = 1_200_000_000, LatestSnapshotId = "snap-osv-20251228", ErrorMessage = "Connection timeout after 30s.", CreatedAt = "2024-01-20T10:00:00Z", UpdatedAt = "2025-12-28T20:15:00Z" },
|
||||
new() { MirrorId = "mirror-epss-001", Name = "EPSS Scores", FeedType = "epss", UpstreamUrl = "https://api.first.org/data/v1/epss", LocalPath = "/data/mirrors/epss", Enabled = true, SyncStatus = "synced", LastSyncAt = "2025-12-29T00:00:00Z", NextSyncAt = "2025-12-30T00:00:00Z", SyncIntervalMinutes = 1440, SnapshotCount = 30, TotalSizeBytes = 150_000_000, LatestSnapshotId = "snap-epss-20251229", CreatedAt = "2024-03-01T10:00:00Z", UpdatedAt = "2025-12-29T00:00:00Z" },
|
||||
new() { MirrorId = "mirror-kev-001", Name = "CISA KEV Catalog", FeedType = "kev", UpstreamUrl = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json", LocalPath = "/data/mirrors/kev", Enabled = false, SyncStatus = "disabled", LastSyncAt = "2025-12-15T00:00:00Z", SyncIntervalMinutes = 720, SnapshotCount = 5, TotalSizeBytes = 25_000_000, LatestSnapshotId = "snap-kev-20251215", CreatedAt = "2024-04-01T10:00:00Z", UpdatedAt = "2025-12-15T00:00:00Z" },
|
||||
};
|
||||
|
||||
public static readonly List<FeedSnapshotDto> Snapshots = new()
|
||||
{
|
||||
new() { SnapshotId = "snap-nvd-20251229", MirrorId = "mirror-nvd-001", Version = "2025.12.29-001", CreatedAt = "2025-12-29T08:00:00Z", SizeBytes = 245_000_000, ChecksumSha256 = "a1b2c3d4e5f67890abcdef1234567890fedcba0987654321a1b2c3d4e5f67890", ChecksumSha512 = "sha512-checksum-placeholder", RecordCount = 245_832, FeedDate = "2025-12-29", IsLatest = true, IsPinned = false, DownloadUrl = "/api/mirrors/nvd/snapshots/snap-nvd-20251229/download", Metadata = new() { ["cveCount"] = 245832, ["modifiedCount"] = 1523 } },
|
||||
new() { SnapshotId = "snap-nvd-20251228", MirrorId = "mirror-nvd-001", Version = "2025.12.28-001", CreatedAt = "2025-12-28T08:00:00Z", SizeBytes = 244_800_000, ChecksumSha256 = "b2c3d4e5f67890abcdef1234567890fedcba0987654321a1b2c3d4e5f67890ab", ChecksumSha512 = "sha512-checksum-placeholder-2", RecordCount = 245_621, FeedDate = "2025-12-28", IsLatest = false, IsPinned = true, DownloadUrl = "/api/mirrors/nvd/snapshots/snap-nvd-20251228/download", Metadata = new() { ["cveCount"] = 245621, ["modifiedCount"] = 892 } },
|
||||
new() { SnapshotId = "snap-nvd-20251227", MirrorId = "mirror-nvd-001", Version = "2025.12.27-001", CreatedAt = "2025-12-27T08:00:00Z", SizeBytes = 244_500_000, ChecksumSha256 = "c3d4e5f67890abcdef1234567890fedcba0987654321a1b2c3d4e5f67890abcd", ChecksumSha512 = "sha512-checksum-placeholder-3", RecordCount = 245_412, FeedDate = "2025-12-27", IsLatest = false, IsPinned = false, DownloadUrl = "/api/mirrors/nvd/snapshots/snap-nvd-20251227/download", ExpiresAt = "2026-01-27T08:00:00Z", Metadata = new() { ["cveCount"] = 245412, ["modifiedCount"] = 756 } },
|
||||
};
|
||||
|
||||
public static readonly List<AirGapBundleDto> Bundles = new()
|
||||
{
|
||||
new() { BundleId = "bundle-full-20251229", Name = "Full Feed Bundle - December 2025", Description = "Complete vulnerability feed bundle for air-gapped deployment", Status = "ready", CreatedAt = "2025-12-29T06:00:00Z", ExpiresAt = "2026-03-29T06:00:00Z", SizeBytes = 4_500_000_000, ChecksumSha256 = "bundle-sha256-checksum-full-20251229", ChecksumSha512 = "bundle-sha512-checksum-full-20251229", IncludedFeeds = new[] { "nvd", "ghsa", "oval", "osv", "epss" }, SnapshotIds = new[] { "snap-nvd-20251229", "snap-ghsa-20251229", "snap-oval-20251229" }, FeedVersions = new() { ["nvd"] = "2025.12.29-001", ["ghsa"] = "2025.12.29-001", ["oval"] = "2025.12.27-001", ["osv"] = "2025.12.28-001", ["epss"] = "2025.12.29-001" }, DownloadUrl = "/api/airgap/bundles/bundle-full-20251229/download", SignatureUrl = "/api/airgap/bundles/bundle-full-20251229/signature", ManifestUrl = "/api/airgap/bundles/bundle-full-20251229/manifest", CreatedBy = "system", Metadata = new() { ["totalRecords"] = 850000 } },
|
||||
new() { BundleId = "bundle-critical-20251229", Name = "Critical Feeds Only - December 2025", Description = "NVD and KEV feeds for minimal deployment", Status = "building", CreatedAt = "2025-12-29T09:00:00Z", SizeBytes = 0, ChecksumSha256 = "", ChecksumSha512 = "", IncludedFeeds = new[] { "nvd", "kev" }, CreatedBy = "admin@stellaops.io" },
|
||||
};
|
||||
|
||||
public static readonly List<VersionLockDto> VersionLocks = new()
|
||||
{
|
||||
new() { LockId = "lock-nvd-001", FeedType = "nvd", Mode = "pinned", PinnedVersion = "2025.12.28-001", PinnedSnapshotId = "snap-nvd-20251228", Enabled = true, CreatedAt = "2025-12-28T10:00:00Z", CreatedBy = "security-team", Notes = "Pinned for Q4 compliance audit" },
|
||||
new() { LockId = "lock-epss-001", FeedType = "epss", Mode = "latest", Enabled = true, CreatedAt = "2025-11-01T10:00:00Z", CreatedBy = "risk-team", Notes = "Always use latest EPSS scores" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -647,17 +647,24 @@ if (authorityConfigured)
|
||||
resourceOptions.MetadataAddress = concelierOptions.Authority.MetadataAddress;
|
||||
}
|
||||
|
||||
foreach (var audience in concelierOptions.Authority.Audiences)
|
||||
// Read collections directly from IConfiguration to work around
|
||||
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
|
||||
var authSection = builder.Configuration.GetSection("Authority");
|
||||
|
||||
var cfgAudiences = authSection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
foreach (var audience in cfgAudiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var scope in concelierOptions.Authority.RequiredScopes)
|
||||
var cfgScopes = authSection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
foreach (var scope in cfgScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var network in concelierOptions.Authority.BypassNetworks)
|
||||
var cfgBypassNetworks = authSection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
foreach (var network in cfgBypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
@@ -762,7 +769,13 @@ if (authorityConfigured)
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authority.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authority.TokenClockSkewSeconds);
|
||||
|
||||
foreach (var audience in authority.Audiences)
|
||||
// Also read collections directly from IConfiguration here (TestSigningSecret branch)
|
||||
// to work around .NET Configuration.Bind() not populating IList<string>.
|
||||
var cfg = builder.Configuration;
|
||||
var authCfgSection = cfg.GetSection("Authority");
|
||||
|
||||
var cfgAudiences2 = authCfgSection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
foreach (var audience in cfgAudiences2)
|
||||
{
|
||||
if (!resourceOptions.Audiences.Contains(audience))
|
||||
{
|
||||
@@ -770,7 +783,8 @@ if (authorityConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scope in authority.RequiredScopes)
|
||||
var cfgScopes2 = authCfgSection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
foreach (var scope in cfgScopes2)
|
||||
{
|
||||
if (!resourceOptions.RequiredScopes.Contains(scope))
|
||||
{
|
||||
@@ -778,7 +792,8 @@ if (authorityConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var network in authority.BypassNetworks)
|
||||
var cfgBypass2 = authCfgSection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
foreach (var network in cfgBypass2)
|
||||
{
|
||||
if (!resourceOptions.BypassNetworks.Contains(network))
|
||||
{
|
||||
@@ -786,7 +801,8 @@ if (authorityConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tenant in authority.RequiredTenants)
|
||||
var cfgTenants2 = authCfgSection.GetSection("RequiredTenants").Get<string[]>() ?? [];
|
||||
foreach (var tenant in cfgTenants2)
|
||||
{
|
||||
if (!resourceOptions.RequiredTenants.Contains(tenant))
|
||||
{
|
||||
@@ -898,6 +914,15 @@ app.MapInterestScoreEndpoints();
|
||||
// Federation endpoints for site-to-site bundle sync
|
||||
app.MapConcelierFederationEndpoints();
|
||||
|
||||
// AirGap endpoints for sealed-mode operations
|
||||
app.MapConcelierAirGapEndpoints();
|
||||
|
||||
// Feed snapshot endpoints for atomic multi-source snapshots
|
||||
app.MapFeedSnapshotEndpoints();
|
||||
|
||||
// Feed mirror management, bundles, version locks, offline status
|
||||
app.MapFeedMirrorManagementEndpoints();
|
||||
|
||||
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
|
||||
{
|
||||
var (payload, etag) = provider.GetDocument();
|
||||
|
||||
@@ -85,26 +85,34 @@ builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
|
||||
|
||||
// Read collections directly from IConfiguration to work around
|
||||
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
|
||||
var authoritySection = builder.Configuration.GetSection("Doctor:Authority");
|
||||
|
||||
var audiences = authoritySection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in bootstrapOptions.Authority.Audiences)
|
||||
foreach (var audience in audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
var requiredScopes = authoritySection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
resourceOptions.RequiredScopes.Clear();
|
||||
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
|
||||
foreach (var scope in requiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
var requiredTenants = authoritySection.GetSection("RequiredTenants").Get<string[]>() ?? [];
|
||||
resourceOptions.RequiredTenants.Clear();
|
||||
foreach (var tenant in bootstrapOptions.Authority.RequiredTenants)
|
||||
foreach (var tenant in requiredTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
var bypassNetworks = authoritySection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
resourceOptions.BypassNetworks.Clear();
|
||||
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
|
||||
foreach (var network in bypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -59,6 +59,11 @@ public sealed record BundleData
|
||||
/// </summary>
|
||||
public IReadOnlyList<BundleArtifact> ScanResults { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness triplet artifacts (trace, DSSE, Sigstore bundle).
|
||||
/// </summary>
|
||||
public IReadOnlyList<BundleArtifact> RuntimeWitnesses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Public keys for verification.
|
||||
/// </summary>
|
||||
@@ -94,6 +99,26 @@ public sealed record BundleArtifact
|
||||
/// Subject of the artifact.
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness identity this artifact belongs to.
|
||||
/// </summary>
|
||||
public string? WitnessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness artifact role (trace, dsse, sigstore_bundle).
|
||||
/// </summary>
|
||||
public string? WitnessRole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic runtime witness lookup keys.
|
||||
/// </summary>
|
||||
public RuntimeWitnessIndexKey? WitnessIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related artifact paths for witness-level linkage.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? LinkedArtifacts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -79,18 +79,25 @@ public sealed record BundleManifest
|
||||
[JsonPropertyOrder(8)]
|
||||
public ImmutableArray<ArtifactEntry> ScanResults { get; init; } = ImmutableArray<ArtifactEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness artifacts (trace.json/trace.dsse.json/trace.sigstore.json) included in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("runtimeWitnesses")]
|
||||
[JsonPropertyOrder(9)]
|
||||
public ImmutableArray<ArtifactEntry> RuntimeWitnesses { get; init; } = ImmutableArray<ArtifactEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Public keys for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("publicKeys")]
|
||||
[JsonPropertyOrder(9)]
|
||||
[JsonPropertyOrder(10)]
|
||||
public ImmutableArray<KeyEntry> PublicKeys { get; init; } = ImmutableArray<KeyEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root hash of all artifacts for integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
[JsonPropertyOrder(10)]
|
||||
[JsonPropertyOrder(11)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MerkleRoot { get; init; }
|
||||
|
||||
@@ -99,15 +106,20 @@ public sealed record BundleManifest
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<ArtifactEntry> AllArtifacts =>
|
||||
Sboms.Concat(VexStatements).Concat(Attestations).Concat(PolicyVerdicts).Concat(ScanResults);
|
||||
Sboms
|
||||
.Concat(VexStatements)
|
||||
.Concat(Attestations)
|
||||
.Concat(PolicyVerdicts)
|
||||
.Concat(ScanResults)
|
||||
.Concat(RuntimeWitnesses);
|
||||
|
||||
/// <summary>
|
||||
/// Total count of artifacts in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalArtifacts")]
|
||||
[JsonPropertyOrder(11)]
|
||||
[JsonPropertyOrder(12)]
|
||||
public int TotalArtifacts => Sboms.Length + VexStatements.Length + Attestations.Length +
|
||||
PolicyVerdicts.Length + ScanResults.Length;
|
||||
PolicyVerdicts.Length + ScanResults.Length + RuntimeWitnesses.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,6 +177,82 @@ public sealed record ArtifactEntry
|
||||
[JsonPropertyOrder(6)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness identity this artifact belongs to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witnessId")]
|
||||
[JsonPropertyOrder(7)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? WitnessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness artifact role (trace, dsse, sigstore_bundle).
|
||||
/// </summary>
|
||||
[JsonPropertyName("witnessRole")]
|
||||
[JsonPropertyOrder(8)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? WitnessRole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness lookup keys for deterministic replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witnessIndex")]
|
||||
[JsonPropertyOrder(9)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public RuntimeWitnessIndexKey? WitnessIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related artifact paths for this witness artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linkedArtifacts")]
|
||||
[JsonPropertyOrder(10)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableArray<string>? LinkedArtifacts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic lookup keys for runtime witness artifacts.
|
||||
/// </summary>
|
||||
public sealed record RuntimeWitnessIndexKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Build ID of the observed userspace binary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Kernel release used during runtime collection.
|
||||
/// </summary>
|
||||
[JsonPropertyName("kernelRelease")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string KernelRelease { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Probe identifier that produced this runtime witness.
|
||||
/// </summary>
|
||||
[JsonPropertyName("probeId")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string ProbeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy run identifier associated with the runtime evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyRunId")]
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string PolicyRunId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness artifact role values.
|
||||
/// </summary>
|
||||
public static class RuntimeWitnessArtifactRoles
|
||||
{
|
||||
public const string Trace = "trace";
|
||||
public const string Dsse = "dsse";
|
||||
public const string SigstoreBundle = "sigstore_bundle";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -234,6 +322,7 @@ public static class BundlePaths
|
||||
public const string AttestationsDirectory = "attestations";
|
||||
public const string PolicyDirectory = "policy";
|
||||
public const string ScansDirectory = "scans";
|
||||
public const string RuntimeWitnessesDirectory = "runtime-witnesses";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -249,4 +338,6 @@ public static class BundleMediaTypes
|
||||
public const string PolicyVerdict = "application/json";
|
||||
public const string ScanResult = "application/json";
|
||||
public const string PublicKeyPem = "application/x-pem-file";
|
||||
public const string RuntimeWitnessTrace = "application/vnd.stellaops.witness.v1+json";
|
||||
public const string SigstoreBundleV03 = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
||||
}
|
||||
|
||||
@@ -329,32 +329,39 @@ public sealed record ExportConfiguration
|
||||
[JsonPropertyOrder(4)]
|
||||
public bool IncludeScanResults { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include runtime witness triplets (trace, DSSE, Sigstore bundle) in export.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeRuntimeWitnesses")]
|
||||
[JsonPropertyOrder(5)]
|
||||
public bool IncludeRuntimeWitnesses { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include public keys for offline verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeKeys")]
|
||||
[JsonPropertyOrder(5)]
|
||||
[JsonPropertyOrder(6)]
|
||||
public bool IncludeKeys { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include verification scripts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeVerifyScripts")]
|
||||
[JsonPropertyOrder(6)]
|
||||
[JsonPropertyOrder(7)]
|
||||
public bool IncludeVerifyScripts { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Compression algorithm (gzip, brotli, none).
|
||||
/// </summary>
|
||||
[JsonPropertyName("compression")]
|
||||
[JsonPropertyOrder(7)]
|
||||
[JsonPropertyOrder(8)]
|
||||
public string Compression { get; init; } = "gzip";
|
||||
|
||||
/// <summary>
|
||||
/// Compression level (1-9).
|
||||
/// </summary>
|
||||
[JsonPropertyName("compressionLevel")]
|
||||
[JsonPropertyOrder(8)]
|
||||
[JsonPropertyOrder(9)]
|
||||
public int CompressionLevel { get; init; } = 6;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
using StellaOps.EvidenceLocker.Export.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Validates runtime witness triplets for offline replay verification.
|
||||
/// </summary>
|
||||
public sealed class RuntimeWitnessOfflineVerifier
|
||||
{
|
||||
private static readonly HashSet<string> RequiredRoles = new(StringComparer.Ordinal)
|
||||
{
|
||||
RuntimeWitnessArtifactRoles.Trace,
|
||||
RuntimeWitnessArtifactRoles.Dsse,
|
||||
RuntimeWitnessArtifactRoles.SigstoreBundle
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies runtime witness triplets using only bundle-contained artifacts.
|
||||
/// </summary>
|
||||
public RuntimeWitnessOfflineVerificationResult Verify(
|
||||
BundleManifest manifest,
|
||||
IReadOnlyDictionary<string, byte[]> artifactsByPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(artifactsByPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
var witnessArtifacts = manifest.RuntimeWitnesses
|
||||
.OrderBy(static artifact => artifact.WitnessId, StringComparer.Ordinal)
|
||||
.ThenBy(static artifact => artifact.Path, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var artifact in witnessArtifacts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifact.WitnessId))
|
||||
{
|
||||
errors.Add($"runtime witness artifact '{artifact.Path}' is missing witnessId.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifact.WitnessRole))
|
||||
{
|
||||
errors.Add($"runtime witness artifact '{artifact.Path}' is missing witnessRole.");
|
||||
}
|
||||
else if (!RequiredRoles.Contains(artifact.WitnessRole))
|
||||
{
|
||||
errors.Add($"runtime witness artifact '{artifact.Path}' has unsupported witnessRole '{artifact.WitnessRole}'.");
|
||||
}
|
||||
|
||||
if (artifact.WitnessIndex is null)
|
||||
{
|
||||
errors.Add($"runtime witness artifact '{artifact.Path}' is missing witnessIndex.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in witnessArtifacts.GroupBy(static artifact => artifact.WitnessId, StringComparer.Ordinal))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(group.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
VerifyWitnessTriplet(group.Key!, group.ToList(), artifactsByPath, errors);
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? RuntimeWitnessOfflineVerificationResult.Passed()
|
||||
: RuntimeWitnessOfflineVerificationResult.Failure(errors);
|
||||
}
|
||||
|
||||
private static void VerifyWitnessTriplet(
|
||||
string witnessId,
|
||||
IReadOnlyList<ArtifactEntry> artifacts,
|
||||
IReadOnlyDictionary<string, byte[]> artifactsByPath,
|
||||
ICollection<string> errors)
|
||||
{
|
||||
var errorCountBefore = errors.Count;
|
||||
|
||||
var roleMap = artifacts
|
||||
.Where(static artifact => !string.IsNullOrWhiteSpace(artifact.WitnessRole))
|
||||
.GroupBy(static artifact => artifact.WitnessRole!, StringComparer.Ordinal)
|
||||
.ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal);
|
||||
|
||||
foreach (var requiredRole in RequiredRoles)
|
||||
{
|
||||
if (!roleMap.ContainsKey(requiredRole))
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' is missing '{requiredRole}' artifact.");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > errorCountBefore && !roleMap.ContainsKey(RuntimeWitnessArtifactRoles.Trace))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roleMap.TryGetValue(RuntimeWitnessArtifactRoles.Trace, out var traceArtifact)
|
||||
|| !roleMap.TryGetValue(RuntimeWitnessArtifactRoles.Dsse, out var dsseArtifact)
|
||||
|| !roleMap.TryGetValue(RuntimeWitnessArtifactRoles.SigstoreBundle, out var sigstoreArtifact))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetArtifactBytes(traceArtifact, artifactsByPath, errors, out var traceBytes)
|
||||
|| !TryGetArtifactBytes(dsseArtifact, artifactsByPath, errors, out var dsseBytes)
|
||||
|| !TryGetArtifactBytes(sigstoreArtifact, artifactsByPath, errors, out var sigstoreBytes))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetDssePayload(dsseBytes, dsseArtifact.Path, errors, out var dssePayloadType, out var dssePayloadBase64))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] dssePayloadBytes;
|
||||
try
|
||||
{
|
||||
dssePayloadBytes = Convert.FromBase64String(dssePayloadBase64!);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' DSSE payload is not valid base64 in '{dsseArtifact.Path}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!traceBytes.SequenceEqual(dssePayloadBytes))
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' trace payload bytes do not match DSSE payload.");
|
||||
}
|
||||
|
||||
VerifySigstoreBundle(sigstoreBytes, sigstoreArtifact.Path, witnessId, dssePayloadType!, dssePayloadBase64!, errors);
|
||||
}
|
||||
|
||||
private static bool TryGetArtifactBytes(
|
||||
ArtifactEntry artifact,
|
||||
IReadOnlyDictionary<string, byte[]> artifactsByPath,
|
||||
ICollection<string> errors,
|
||||
out byte[] bytes)
|
||||
{
|
||||
if (!artifactsByPath.TryGetValue(artifact.Path, out bytes!))
|
||||
{
|
||||
errors.Add($"runtime witness artifact '{artifact.Path}' is missing from offline artifact set.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var computedDigest = ComputeSha256Hex(bytes);
|
||||
var expectedDigest = NormalizeDigest(artifact.Digest);
|
||||
if (!string.Equals(expectedDigest, computedDigest, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"runtime witness artifact '{artifact.Path}' digest mismatch.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetDssePayload(
|
||||
byte[] dsseBytes,
|
||||
string path,
|
||||
ICollection<string> errors,
|
||||
out string? payloadType,
|
||||
out string? payloadBase64)
|
||||
{
|
||||
payloadType = null;
|
||||
payloadBase64 = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(dsseBytes);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("payloadType", out var payloadTypeElement)
|
||||
|| string.IsNullOrWhiteSpace(payloadTypeElement.GetString()))
|
||||
{
|
||||
errors.Add($"DSSE envelope '{path}' is missing payloadType.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("payload", out var payloadElement)
|
||||
|| string.IsNullOrWhiteSpace(payloadElement.GetString()))
|
||||
{
|
||||
errors.Add($"DSSE envelope '{path}' is missing payload.");
|
||||
return false;
|
||||
}
|
||||
|
||||
payloadType = payloadTypeElement.GetString();
|
||||
payloadBase64 = payloadElement.GetString();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
errors.Add($"DSSE envelope '{path}' is not valid JSON.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifySigstoreBundle(
|
||||
byte[] sigstoreBytes,
|
||||
string path,
|
||||
string witnessId,
|
||||
string expectedPayloadType,
|
||||
string expectedPayloadBase64,
|
||||
ICollection<string> errors)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(sigstoreBytes);
|
||||
var root = document.RootElement;
|
||||
|
||||
var mediaType = root.TryGetProperty("mediaType", out var mediaTypeElement)
|
||||
? mediaTypeElement.GetString()
|
||||
: null;
|
||||
if (!string.Equals(mediaType, BundleMediaTypes.SigstoreBundleV03, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' has unsupported mediaType '{mediaType ?? "<missing>"}'.");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("dsseEnvelope", out var dsseEnvelope))
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' is missing dsseEnvelope.");
|
||||
return;
|
||||
}
|
||||
|
||||
var bundlePayloadType = dsseEnvelope.TryGetProperty("payloadType", out var payloadTypeElement)
|
||||
? payloadTypeElement.GetString()
|
||||
: null;
|
||||
var bundlePayload = dsseEnvelope.TryGetProperty("payload", out var payloadElement)
|
||||
? payloadElement.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.Equals(bundlePayloadType, expectedPayloadType, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' sigstore bundle payloadType does not match trace DSSE envelope.");
|
||||
}
|
||||
|
||||
if (!string.Equals(bundlePayload, expectedPayloadBase64, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' sigstore bundle payload does not match trace DSSE envelope.");
|
||||
}
|
||||
|
||||
if (!dsseEnvelope.TryGetProperty("signatures", out var signatures)
|
||||
|| signatures.ValueKind != JsonValueKind.Array
|
||||
|| signatures.GetArrayLength() == 0)
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' has no DSSE signatures.");
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' is not valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
const string prefix = "sha256:";
|
||||
return digest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
? digest[prefix.Length..].ToLowerInvariant()
|
||||
: digest.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness offline verification outcome.
|
||||
/// </summary>
|
||||
public sealed record RuntimeWitnessOfflineVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification errors, if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
public static RuntimeWitnessOfflineVerificationResult Passed()
|
||||
=> new()
|
||||
{
|
||||
Success = true
|
||||
};
|
||||
|
||||
public static RuntimeWitnessOfflineVerificationResult Failure(IReadOnlyList<string> errors)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
@@ -165,6 +165,22 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
|
||||
}
|
||||
}
|
||||
|
||||
// Add runtime witness artifacts (trace / trace.dsse / trace.sigstore)
|
||||
if (config.IncludeRuntimeWitnesses)
|
||||
{
|
||||
foreach (var runtimeWitnessArtifact in bundleData.RuntimeWitnesses)
|
||||
{
|
||||
var entry = await AddArtifactAsync(
|
||||
tarWriter,
|
||||
runtimeWitnessArtifact,
|
||||
BundlePaths.RuntimeWitnessesDirectory,
|
||||
"runtime_witness",
|
||||
cancellationToken);
|
||||
manifestBuilder.AddRuntimeWitness(entry);
|
||||
checksumEntries.Add((entry.Path, entry.Digest));
|
||||
}
|
||||
}
|
||||
|
||||
// Add public keys
|
||||
if (config.IncludeKeys)
|
||||
{
|
||||
@@ -261,7 +277,13 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
|
||||
Size = content.Length,
|
||||
Type = type,
|
||||
Format = artifact.Format,
|
||||
Subject = artifact.Subject
|
||||
Subject = artifact.Subject,
|
||||
WitnessId = artifact.WitnessId,
|
||||
WitnessRole = artifact.WitnessRole,
|
||||
WitnessIndex = artifact.WitnessIndex,
|
||||
LinkedArtifacts = artifact.LinkedArtifacts is null
|
||||
? null
|
||||
: [.. artifact.LinkedArtifacts.Order(StringComparer.Ordinal)]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -450,6 +472,7 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
|
||||
- Attestations: {manifest.Attestations.Length}
|
||||
- Policy Verdicts: {manifest.PolicyVerdicts.Length}
|
||||
- Scan Results: {manifest.ScanResults.Length}
|
||||
- Runtime Witness Artifacts: {manifest.RuntimeWitnesses.Length}
|
||||
- Public Keys: {manifest.PublicKeys.Length}
|
||||
|
||||
Total Artifacts: {manifest.TotalArtifacts}
|
||||
@@ -469,6 +492,7 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
|
||||
+-- attestations/ # DSSE attestation envelopes
|
||||
+-- policy/ # Policy verdicts
|
||||
+-- scans/ # Scan results
|
||||
+-- runtime-witnesses/ # Runtime witness triplets and index metadata
|
||||
+-- keys/ # Public keys for verification
|
||||
```
|
||||
|
||||
@@ -515,6 +539,7 @@ internal sealed class BundleManifestBuilder
|
||||
private readonly List<ArtifactEntry> _attestations = [];
|
||||
private readonly List<ArtifactEntry> _policyVerdicts = [];
|
||||
private readonly List<ArtifactEntry> _scanResults = [];
|
||||
private readonly List<ArtifactEntry> _runtimeWitnesses = [];
|
||||
private readonly List<KeyEntry> _publicKeys = [];
|
||||
|
||||
public BundleManifestBuilder(string bundleId, DateTimeOffset createdAt)
|
||||
@@ -529,6 +554,7 @@ internal sealed class BundleManifestBuilder
|
||||
public void AddAttestation(ArtifactEntry entry) => _attestations.Add(entry);
|
||||
public void AddPolicyVerdict(ArtifactEntry entry) => _policyVerdicts.Add(entry);
|
||||
public void AddScanResult(ArtifactEntry entry) => _scanResults.Add(entry);
|
||||
public void AddRuntimeWitness(ArtifactEntry entry) => _runtimeWitnesses.Add(entry);
|
||||
public void AddPublicKey(KeyEntry entry) => _publicKeys.Add(entry);
|
||||
|
||||
public BundleManifest Build() => new()
|
||||
@@ -541,6 +567,7 @@ internal sealed class BundleManifestBuilder
|
||||
Attestations = [.. _attestations],
|
||||
PolicyVerdicts = [.. _policyVerdicts],
|
||||
ScanResults = [.. _scanResults],
|
||||
RuntimeWitnesses = [.. _runtimeWitnesses],
|
||||
PublicKeys = [.. _publicKeys]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,6 +343,7 @@ if __name__ == ""__main__"":
|
||||
| Attestations | {manifest.Attestations.Length} |
|
||||
| Policy Verdicts | {manifest.PolicyVerdicts.Length} |
|
||||
| Scan Results | {manifest.ScanResults.Length} |
|
||||
| Runtime Witness Artifacts | {manifest.RuntimeWitnesses.Length} |
|
||||
| Public Keys | {manifest.PublicKeys.Length} |
|
||||
| **Total Artifacts** | **{manifest.TotalArtifacts}** |
|
||||
|
||||
@@ -362,6 +363,7 @@ if __name__ == ""__main__"":
|
||||
+-- attestations/ # DSSE attestation envelopes
|
||||
+-- policy/ # Policy verdicts
|
||||
+-- scans/ # Scan results
|
||||
+-- runtime-witnesses/ # Runtime witness triplets (trace + DSSE + Sigstore bundle)
|
||||
+-- keys/ # Public keys for verification
|
||||
```
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ public class BundleManifestSerializationTests
|
||||
config.IncludeAttestations.Should().BeTrue();
|
||||
config.IncludePolicyVerdicts.Should().BeTrue();
|
||||
config.IncludeScanResults.Should().BeTrue();
|
||||
config.IncludeRuntimeWitnesses.Should().BeTrue();
|
||||
config.IncludeKeys.Should().BeTrue();
|
||||
config.IncludeVerifyScripts.Should().BeTrue();
|
||||
config.Compression.Should().Be("gzip");
|
||||
@@ -202,12 +203,13 @@ public class BundleManifestSerializationTests
|
||||
var allArtifacts = manifest.AllArtifacts.ToList();
|
||||
|
||||
// Assert
|
||||
allArtifacts.Should().HaveCount(5);
|
||||
allArtifacts.Should().HaveCount(6);
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("sbom");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("vex");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("attestation");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("policy");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("scan");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("runtime_witness");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +219,7 @@ public class BundleManifestSerializationTests
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
// Act & Assert
|
||||
manifest.TotalArtifacts.Should().Be(5);
|
||||
manifest.TotalArtifacts.Should().Be(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -264,6 +266,7 @@ public class BundleManifestSerializationTests
|
||||
BundlePaths.AttestationsDirectory.Should().Be("attestations");
|
||||
BundlePaths.PolicyDirectory.Should().Be("policy");
|
||||
BundlePaths.ScansDirectory.Should().Be("scans");
|
||||
BundlePaths.RuntimeWitnessesDirectory.Should().Be("runtime-witnesses");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -275,6 +278,8 @@ public class BundleManifestSerializationTests
|
||||
BundleMediaTypes.VexOpenVex.Should().Be("application/vnd.openvex+json");
|
||||
BundleMediaTypes.DsseEnvelope.Should().Be("application/vnd.dsse.envelope+json");
|
||||
BundleMediaTypes.PublicKeyPem.Should().Be("application/x-pem-file");
|
||||
BundleMediaTypes.RuntimeWitnessTrace.Should().Be("application/vnd.stellaops.witness.v1+json");
|
||||
BundleMediaTypes.SigstoreBundleV03.Should().Be("application/vnd.dev.sigstore.bundle.v0.3+json");
|
||||
}
|
||||
|
||||
private static BundleManifest CreateTestManifest()
|
||||
@@ -326,6 +331,28 @@ public class BundleManifestSerializationTests
|
||||
Size = 10000,
|
||||
Type = "scan"
|
||||
}),
|
||||
RuntimeWitnesses = ImmutableArray.Create(new ArtifactEntry
|
||||
{
|
||||
Path = "runtime-witnesses/wit-sha256-001/trace.sigstore.json",
|
||||
Digest = "sha256:wit123",
|
||||
MediaType = BundleMediaTypes.SigstoreBundleV03,
|
||||
Size = 4096,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.SigstoreBundle,
|
||||
WitnessIndex = new RuntimeWitnessIndexKey
|
||||
{
|
||||
BuildId = "gnu-build-id:abc",
|
||||
KernelRelease = "6.8.0",
|
||||
ProbeId = "probe-runtime-core",
|
||||
PolicyRunId = "policy-run-001"
|
||||
},
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-sha256-001/trace.json",
|
||||
"runtime-witnesses/wit-sha256-001/trace.dsse.json"
|
||||
]
|
||||
}),
|
||||
PublicKeys = ImmutableArray.Create(new KeyEntry
|
||||
{
|
||||
Path = "keys/signing.pub",
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentAssertions;
|
||||
using StellaOps.EvidenceLocker.Export.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Export.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RuntimeWitnessOfflineVerifierTests
|
||||
{
|
||||
private readonly RuntimeWitnessOfflineVerifier _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithValidTriplet_ReturnsSuccess()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
|
||||
var result = _sut.Verify(fixture.Manifest, fixture.ArtifactsByPath);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithMissingSigstoreArtifact_ReturnsFailure()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var manifest = fixture.Manifest with
|
||||
{
|
||||
RuntimeWitnesses = fixture.Manifest.RuntimeWitnesses
|
||||
.Where(artifact => artifact.WitnessRole != RuntimeWitnessArtifactRoles.SigstoreBundle)
|
||||
.ToImmutableArray()
|
||||
};
|
||||
|
||||
var result = _sut.Verify(manifest, fixture.ArtifactsByPath);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.Contains("sigstore_bundle", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithMismatchedDssePayload_ReturnsFailure()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var mismatchedDsseBytes = Encoding.UTF8.GetBytes("""
|
||||
{"payloadType":"application/vnd.stellaops.witness.v1+json","payload":"eyJ3aXRuZXNzX2lkIjoid2l0OnNoYTI1NjpESUZGRVJFTlQifQ==","signatures":[{"keyid":"runtime-key","sig":"c2ln"}]}
|
||||
""");
|
||||
var artifacts = fixture.ArtifactsByPath
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
artifacts["runtime-witnesses/wit-001/trace.dsse.json"] = mismatchedDsseBytes;
|
||||
|
||||
var manifest = fixture.Manifest with
|
||||
{
|
||||
RuntimeWitnesses = fixture.Manifest.RuntimeWitnesses
|
||||
.Select(artifact => artifact.Path == "runtime-witnesses/wit-001/trace.dsse.json"
|
||||
? artifact with { Digest = $"sha256:{ComputeSha256Hex(mismatchedDsseBytes)}" }
|
||||
: artifact)
|
||||
.ToImmutableArray()
|
||||
};
|
||||
|
||||
var result = _sut.Verify(manifest, artifacts);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.Contains("do not match DSSE payload", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Regulatory")]
|
||||
public void ReplayFrames_WithFixedWitnessArtifacts_AreByteIdenticalAcrossKernelLibcMatrix()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var verification = _sut.Verify(fixture.Manifest, fixture.ArtifactsByPath);
|
||||
verification.Success.Should().BeTrue();
|
||||
|
||||
var matrix = CreateReplayMatrix();
|
||||
matrix.Select(row => row.KernelRelease)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count()
|
||||
.Should()
|
||||
.BeGreaterThanOrEqualTo(3);
|
||||
matrix.Select(row => row.LibcVariant)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Should()
|
||||
.Contain(["glibc", "musl"]);
|
||||
|
||||
var projections = matrix
|
||||
.Select(row => ProjectReplayFrames(fixture.Manifest, fixture.ArtifactsByPath, row))
|
||||
.ToList();
|
||||
|
||||
projections.Should().NotBeEmpty();
|
||||
projections.Select(projection => projection.FrameCount)
|
||||
.Should()
|
||||
.OnlyContain(static count => count > 0);
|
||||
|
||||
var baselineBytes = projections[0].FrameBytes;
|
||||
projections.Select(projection => projection.FrameBytes)
|
||||
.Should()
|
||||
.OnlyContain(bytes => bytes.SequenceEqual(baselineBytes));
|
||||
|
||||
projections.Select(projection => projection.FrameDigest)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Should()
|
||||
.ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Safety")]
|
||||
public void BuildReplayFrameBytes_WithReorderedObservations_ProducesIdenticalDigest()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var tracePath = fixture.Manifest.RuntimeWitnesses
|
||||
.Single(artifact => artifact.WitnessRole == RuntimeWitnessArtifactRoles.Trace)
|
||||
.Path;
|
||||
var baselineTraceBytes = fixture.ArtifactsByPath[tracePath];
|
||||
var reorderedTraceBytes = ReorderObservations(baselineTraceBytes);
|
||||
|
||||
var baselineFrames = BuildReplayFrameBytes(baselineTraceBytes);
|
||||
var reorderedFrames = BuildReplayFrameBytes(reorderedTraceBytes);
|
||||
|
||||
baselineFrames.Should().Equal(reorderedFrames);
|
||||
ComputeSha256Hex(baselineFrames).Should().Be(ComputeSha256Hex(reorderedFrames));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Safety")]
|
||||
public void BuildReplayFrameBytes_WithMutatedObservation_ProducesDifferentDigest()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var tracePath = fixture.Manifest.RuntimeWitnesses
|
||||
.Single(artifact => artifact.WitnessRole == RuntimeWitnessArtifactRoles.Trace)
|
||||
.Path;
|
||||
var baselineTraceBytes = fixture.ArtifactsByPath[tracePath];
|
||||
var mutatedTraceBytes = MutateFirstObservationStackHash(baselineTraceBytes, "sha256:ccc");
|
||||
|
||||
var baselineFrames = BuildReplayFrameBytes(baselineTraceBytes);
|
||||
var mutatedFrames = BuildReplayFrameBytes(mutatedTraceBytes);
|
||||
|
||||
ComputeSha256Hex(baselineFrames).Should().NotBe(ComputeSha256Hex(mutatedFrames));
|
||||
}
|
||||
|
||||
private static (BundleManifest Manifest, IReadOnlyDictionary<string, byte[]> ArtifactsByPath) CreateFixture()
|
||||
{
|
||||
var tracePath = "runtime-witnesses/wit-001/trace.json";
|
||||
var dssePath = "runtime-witnesses/wit-001/trace.dsse.json";
|
||||
var sigstorePath = "runtime-witnesses/wit-001/trace.sigstore.json";
|
||||
|
||||
var traceBytes = Encoding.UTF8.GetBytes("""
|
||||
{
|
||||
"witness_schema":"stellaops.witness.v1",
|
||||
"witness_id":"wit:sha256:runtime-001",
|
||||
"claim_id":"claim:sha256:artifact123:pathabcdef123456",
|
||||
"observation_type":"runtime",
|
||||
"observations":[
|
||||
{
|
||||
"observed_at":"2026-02-17T11:59:01Z",
|
||||
"observation_count":1,
|
||||
"stack_sample_hash":"sha256:bbb",
|
||||
"process_id":4421,
|
||||
"container_id":"container-a",
|
||||
"pod_name":"api-0",
|
||||
"namespace":"prod",
|
||||
"source_type":"tetragon",
|
||||
"observation_id":"obs-b"
|
||||
},
|
||||
{
|
||||
"observed_at":"2026-02-17T11:59:00Z",
|
||||
"observation_count":2,
|
||||
"stack_sample_hash":"sha256:aaa",
|
||||
"process_id":4421,
|
||||
"container_id":"container-a",
|
||||
"pod_name":"api-0",
|
||||
"namespace":"prod",
|
||||
"source_type":"tetragon",
|
||||
"observation_id":"obs-a"
|
||||
}
|
||||
],
|
||||
"symbolization":{
|
||||
"build_id":"gnu-build-id:runtime-test",
|
||||
"debug_artifact_uri":"cas://symbols/runtime-test.debug",
|
||||
"symbolizer":{
|
||||
"name":"llvm-symbolizer",
|
||||
"version":"18.1.7",
|
||||
"digest":"sha256:symbolizer"
|
||||
},
|
||||
"libc_variant":"glibc",
|
||||
"sysroot_digest":"sha256:sysroot"
|
||||
}
|
||||
}
|
||||
""");
|
||||
var payloadBase64 = Convert.ToBase64String(traceBytes);
|
||||
|
||||
var dsseBytes = Encoding.UTF8.GetBytes(
|
||||
$"{{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"{payloadBase64}\",\"signatures\":[{{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}}]}}");
|
||||
|
||||
var sigstoreBytes = Encoding.UTF8.GetBytes(
|
||||
$"{{\"mediaType\":\"application/vnd.dev.sigstore.bundle.v0.3+json\",\"verificationMaterial\":{{\"publicKey\":{{\"rawBytes\":\"cHVibGlj\"}}}},\"dsseEnvelope\":{{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"{payloadBase64}\",\"signatures\":[{{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}}]}}}}");
|
||||
|
||||
var index = new RuntimeWitnessIndexKey
|
||||
{
|
||||
BuildId = "gnu-build-id:abc123",
|
||||
KernelRelease = "6.8.0-45-generic",
|
||||
ProbeId = "probe-runtime-core",
|
||||
PolicyRunId = "policy-run-42"
|
||||
};
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = "bundle-runtime-001",
|
||||
CreatedAt = new DateTimeOffset(2026, 2, 17, 12, 0, 0, TimeSpan.Zero),
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
Subject = new BundleSubject
|
||||
{
|
||||
Type = SubjectTypes.ContainerImage,
|
||||
Digest = "sha256:subject"
|
||||
},
|
||||
Provenance = new BundleProvenance
|
||||
{
|
||||
Creator = new CreatorInfo
|
||||
{
|
||||
Name = "StellaOps",
|
||||
Version = "1.0.0"
|
||||
},
|
||||
ExportedAt = new DateTimeOffset(2026, 2, 17, 12, 0, 0, TimeSpan.Zero)
|
||||
},
|
||||
TimeWindow = new TimeWindow
|
||||
{
|
||||
Earliest = new DateTimeOffset(2026, 2, 17, 11, 0, 0, TimeSpan.Zero),
|
||||
Latest = new DateTimeOffset(2026, 2, 17, 12, 0, 0, TimeSpan.Zero)
|
||||
}
|
||||
},
|
||||
RuntimeWitnesses =
|
||||
[
|
||||
new ArtifactEntry
|
||||
{
|
||||
Path = tracePath,
|
||||
Digest = $"sha256:{ComputeSha256Hex(traceBytes)}",
|
||||
MediaType = BundleMediaTypes.RuntimeWitnessTrace,
|
||||
Size = traceBytes.Length,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Trace,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts = [dssePath, sigstorePath]
|
||||
},
|
||||
new ArtifactEntry
|
||||
{
|
||||
Path = dssePath,
|
||||
Digest = $"sha256:{ComputeSha256Hex(dsseBytes)}",
|
||||
MediaType = BundleMediaTypes.DsseEnvelope,
|
||||
Size = dsseBytes.Length,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Dsse,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts = [tracePath, sigstorePath]
|
||||
},
|
||||
new ArtifactEntry
|
||||
{
|
||||
Path = sigstorePath,
|
||||
Digest = $"sha256:{ComputeSha256Hex(sigstoreBytes)}",
|
||||
MediaType = BundleMediaTypes.SigstoreBundleV03,
|
||||
Size = sigstoreBytes.Length,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.SigstoreBundle,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts = [tracePath, dssePath]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var artifactsByPath = new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[tracePath] = traceBytes,
|
||||
[dssePath] = dsseBytes,
|
||||
[sigstorePath] = sigstoreBytes
|
||||
};
|
||||
|
||||
return (manifest, artifactsByPath);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ReplayEnvironment> CreateReplayMatrix()
|
||||
{
|
||||
return
|
||||
[
|
||||
new ReplayEnvironment("5.15.0-1068-azure", "glibc"),
|
||||
new ReplayEnvironment("6.1.0-21-amd64", "glibc"),
|
||||
new ReplayEnvironment("6.6.32-0-lts", "musl")
|
||||
];
|
||||
}
|
||||
|
||||
private static ReplayProjection ProjectReplayFrames(
|
||||
BundleManifest manifest,
|
||||
IReadOnlyDictionary<string, byte[]> artifactsByPath,
|
||||
ReplayEnvironment environment)
|
||||
{
|
||||
var dsseArtifact = manifest.RuntimeWitnesses.Single(
|
||||
artifact => artifact.WitnessRole == RuntimeWitnessArtifactRoles.Dsse);
|
||||
var dsseBytes = artifactsByPath[dsseArtifact.Path];
|
||||
|
||||
using var dsseDocument = JsonDocument.Parse(dsseBytes);
|
||||
var payload = ReadRequiredString(dsseDocument.RootElement, "payload");
|
||||
var traceBytes = Convert.FromBase64String(payload);
|
||||
var frameBytes = BuildReplayFrameBytes(traceBytes);
|
||||
|
||||
return new ReplayProjection(
|
||||
environment.KernelRelease,
|
||||
environment.LibcVariant,
|
||||
frameBytes,
|
||||
$"sha256:{ComputeSha256Hex(frameBytes)}",
|
||||
GetFrameCount(frameBytes));
|
||||
}
|
||||
|
||||
private static byte[] BuildReplayFrameBytes(byte[] traceBytes)
|
||||
{
|
||||
using var traceDocument = JsonDocument.Parse(traceBytes);
|
||||
var root = traceDocument.RootElement;
|
||||
var symbolization = root.GetProperty("symbolization");
|
||||
|
||||
var frames = root.GetProperty("observations")
|
||||
.EnumerateArray()
|
||||
.Select(observation => new ReplayFrame
|
||||
{
|
||||
ObservedAt = ReadRequiredString(observation, "observed_at"),
|
||||
ObservationId = ReadRequiredString(observation, "observation_id"),
|
||||
StackSampleHash = ReadRequiredString(observation, "stack_sample_hash"),
|
||||
ProcessId = ReadOptionalInt(observation, "process_id"),
|
||||
ContainerId = ReadOptionalString(observation, "container_id"),
|
||||
Namespace = ReadOptionalString(observation, "namespace"),
|
||||
PodName = ReadOptionalString(observation, "pod_name"),
|
||||
SourceType = ReadOptionalString(observation, "source_type"),
|
||||
ObservationCount = ReadOptionalInt(observation, "observation_count")
|
||||
})
|
||||
.OrderBy(static frame => frame.ObservedAt, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.ObservationId, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.StackSampleHash, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.ProcessId ?? int.MinValue)
|
||||
.ThenBy(static frame => frame.ContainerId ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.Namespace ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.PodName ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.SourceType ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.ObservationCount ?? int.MinValue)
|
||||
.ToList();
|
||||
|
||||
var replay = new ReplayFrameDocument
|
||||
{
|
||||
WitnessId = ReadRequiredString(root, "witness_id"),
|
||||
ClaimId = ReadRequiredString(root, "claim_id"),
|
||||
BuildId = ReadRequiredString(symbolization, "build_id"),
|
||||
SymbolizerName = ReadRequiredString(symbolization.GetProperty("symbolizer"), "name"),
|
||||
SymbolizerVersion = ReadRequiredString(symbolization.GetProperty("symbolizer"), "version"),
|
||||
SymbolizerDigest = ReadRequiredString(symbolization.GetProperty("symbolizer"), "digest"),
|
||||
LibcVariant = ReadRequiredString(symbolization, "libc_variant"),
|
||||
SysrootDigest = ReadRequiredString(symbolization, "sysroot_digest"),
|
||||
Frames = frames
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(replay, ReplayJsonOptions);
|
||||
}
|
||||
|
||||
private static int GetFrameCount(byte[] frameBytes)
|
||||
{
|
||||
using var frameDocument = JsonDocument.Parse(frameBytes);
|
||||
return frameDocument.RootElement
|
||||
.GetProperty("frames")
|
||||
.GetArrayLength();
|
||||
}
|
||||
|
||||
private static string ReadRequiredString(JsonElement element, string propertyName)
|
||||
{
|
||||
var value = ReadOptionalString(element, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException($"Required string '{propertyName}' missing from replay fixture.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string? ReadOptionalString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.GetString(),
|
||||
JsonValueKind.Number => property.GetRawText(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static int? ReadOptionalInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] ReorderObservations(byte[] traceBytes)
|
||||
{
|
||||
var root = JsonNode.Parse(traceBytes)?.AsObject()
|
||||
?? throw new InvalidOperationException("Trace JSON must parse into an object.");
|
||||
var observations = root["observations"]?.AsArray()
|
||||
?? throw new InvalidOperationException("Trace JSON must contain observations.");
|
||||
|
||||
var reordered = new JsonArray();
|
||||
for (var i = observations.Count - 1; i >= 0; i--)
|
||||
{
|
||||
reordered.Add(observations[i]?.DeepClone());
|
||||
}
|
||||
|
||||
root["observations"] = reordered;
|
||||
return Encoding.UTF8.GetBytes(root.ToJsonString());
|
||||
}
|
||||
|
||||
private static byte[] MutateFirstObservationStackHash(byte[] traceBytes, string newHash)
|
||||
{
|
||||
var root = JsonNode.Parse(traceBytes)?.AsObject()
|
||||
?? throw new InvalidOperationException("Trace JSON must parse into an object.");
|
||||
var observations = root["observations"]?.AsArray()
|
||||
?? throw new InvalidOperationException("Trace JSON must contain observations.");
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Trace JSON observations array cannot be empty.");
|
||||
}
|
||||
|
||||
var first = observations[0]?.AsObject()
|
||||
?? throw new InvalidOperationException("Observation entry must be an object.");
|
||||
first["stack_sample_hash"] = newHash;
|
||||
|
||||
return Encoding.UTF8.GetBytes(root.ToJsonString());
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ReplayJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private sealed record ReplayEnvironment(string KernelRelease, string LibcVariant);
|
||||
|
||||
private sealed record ReplayProjection(
|
||||
string KernelRelease,
|
||||
string LibcVariant,
|
||||
byte[] FrameBytes,
|
||||
string FrameDigest,
|
||||
int FrameCount);
|
||||
|
||||
private sealed record ReplayFrameDocument
|
||||
{
|
||||
public required string WitnessId { get; init; }
|
||||
public required string ClaimId { get; init; }
|
||||
public required string BuildId { get; init; }
|
||||
public required string SymbolizerName { get; init; }
|
||||
public required string SymbolizerVersion { get; init; }
|
||||
public required string SymbolizerDigest { get; init; }
|
||||
public required string LibcVariant { get; init; }
|
||||
public required string SysrootDigest { get; init; }
|
||||
public required IReadOnlyList<ReplayFrame> Frames { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ReplayFrame
|
||||
{
|
||||
public required string ObservedAt { get; init; }
|
||||
public required string ObservationId { get; init; }
|
||||
public required string StackSampleHash { get; init; }
|
||||
public int? ProcessId { get; init; }
|
||||
public string? ContainerId { get; init; }
|
||||
public string? Namespace { get; init; }
|
||||
public string? PodName { get; init; }
|
||||
public string? SourceType { get; init; }
|
||||
public int? ObservationCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,74 @@ public class TarGzBundleExporterTests
|
||||
manifest.TotalArtifacts.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToStreamAsync_IncludesRuntimeWitnessTriplet_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var bundleData = CreateTestBundleData() with
|
||||
{
|
||||
RuntimeWitnesses = CreateRuntimeWitnessArtifacts()
|
||||
};
|
||||
_dataProviderMock
|
||||
.Setup(x => x.LoadBundleDataAsync("test-bundle", null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundleData);
|
||||
|
||||
var request = new ExportRequest
|
||||
{
|
||||
BundleId = "test-bundle",
|
||||
Configuration = new ExportConfiguration { IncludeRuntimeWitnesses = true }
|
||||
};
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportToStreamAsync(request, stream, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.RuntimeWitnesses.Should().HaveCount(3);
|
||||
result.Manifest.RuntimeWitnesses.Select(a => a.WitnessRole).Should().BeEquivalentTo(
|
||||
[
|
||||
RuntimeWitnessArtifactRoles.Trace,
|
||||
RuntimeWitnessArtifactRoles.Dsse,
|
||||
RuntimeWitnessArtifactRoles.SigstoreBundle
|
||||
]);
|
||||
result.Manifest.RuntimeWitnesses.Should().OnlyContain(a => a.WitnessId == "wit:sha256:runtime-001");
|
||||
result.Manifest.RuntimeWitnesses.Should().OnlyContain(a => a.WitnessIndex != null);
|
||||
|
||||
stream.Position = 0;
|
||||
var entries = await ExtractTarGzEntries(stream);
|
||||
entries.Should().Contain("runtime-witnesses/wit-001/trace.json");
|
||||
entries.Should().Contain("runtime-witnesses/wit-001/trace.dsse.json");
|
||||
entries.Should().Contain("runtime-witnesses/wit-001/trace.sigstore.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToStreamAsync_ExcludesRuntimeWitnessTriplet_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var bundleData = CreateTestBundleData() with
|
||||
{
|
||||
RuntimeWitnesses = CreateRuntimeWitnessArtifacts()
|
||||
};
|
||||
_dataProviderMock
|
||||
.Setup(x => x.LoadBundleDataAsync("test-bundle", null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundleData);
|
||||
|
||||
var request = new ExportRequest
|
||||
{
|
||||
BundleId = "test-bundle",
|
||||
Configuration = new ExportConfiguration { IncludeRuntimeWitnesses = false }
|
||||
};
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportToStreamAsync(request, stream, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.RuntimeWitnesses.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportRequest_RequiresBundleId()
|
||||
{
|
||||
@@ -388,4 +456,61 @@ public class TarGzBundleExporterTests
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BundleArtifact> CreateRuntimeWitnessArtifacts()
|
||||
{
|
||||
var index = new RuntimeWitnessIndexKey
|
||||
{
|
||||
BuildId = "gnu-build-id:runtime-test",
|
||||
KernelRelease = "6.8.0-45-generic",
|
||||
ProbeId = "probe-runtime-core",
|
||||
PolicyRunId = "policy-run-42"
|
||||
};
|
||||
|
||||
return
|
||||
[
|
||||
new BundleArtifact
|
||||
{
|
||||
FileName = "wit-001/trace.json",
|
||||
Content = Encoding.UTF8.GetBytes("{\"witness_id\":\"wit:sha256:runtime-001\"}"),
|
||||
MediaType = BundleMediaTypes.RuntimeWitnessTrace,
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Trace,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-001/trace.dsse.json",
|
||||
"runtime-witnesses/wit-001/trace.sigstore.json"
|
||||
]
|
||||
},
|
||||
new BundleArtifact
|
||||
{
|
||||
FileName = "wit-001/trace.dsse.json",
|
||||
Content = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"eyJ3aXRuZXNzX2lkIjoid2l0OnNoYTI1NjpydW50aW1lLTAwMSJ9\",\"signatures\":[{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}]}"),
|
||||
MediaType = BundleMediaTypes.DsseEnvelope,
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Dsse,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-001/trace.json",
|
||||
"runtime-witnesses/wit-001/trace.sigstore.json"
|
||||
]
|
||||
},
|
||||
new BundleArtifact
|
||||
{
|
||||
FileName = "wit-001/trace.sigstore.json",
|
||||
Content = Encoding.UTF8.GetBytes("{\"mediaType\":\"application/vnd.dev.sigstore.bundle.v0.3+json\",\"verificationMaterial\":{\"publicKey\":{\"rawBytes\":\"cHVibGlj\"}},\"dsseEnvelope\":{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"eyJ3aXRuZXNzX2lkIjoid2l0OnNoYTI1NjpydW50aW1lLTAwMSJ9\",\"signatures\":[{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}]}}"),
|
||||
MediaType = BundleMediaTypes.SigstoreBundleV03,
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.SigstoreBundle,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-001/trace.json",
|
||||
"runtime-witnesses/wit-001/trace.dsse.json"
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ public class VerifyScriptGeneratorTests
|
||||
readme.Should().Contain("SBOMs");
|
||||
readme.Should().Contain("VEX Statements");
|
||||
readme.Should().Contain("Attestations");
|
||||
readme.Should().Contain("Runtime Witness Artifacts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -228,6 +229,7 @@ public class VerifyScriptGeneratorTests
|
||||
readme.Should().Contain("sboms/");
|
||||
readme.Should().Contain("vex/");
|
||||
readme.Should().Contain("attestations/");
|
||||
readme.Should().Contain("runtime-witnesses/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -122,7 +122,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -119,19 +119,26 @@ builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
resourceOptions.BackchannelTimeout = bootstrapOptions.Authority.BackchannelTimeout;
|
||||
resourceOptions.TokenClockSkew = bootstrapOptions.Authority.TokenClockSkew;
|
||||
|
||||
// Read collections directly from IConfiguration to work around
|
||||
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
|
||||
var authoritySection = builder.Configuration.GetSection("findings:ledger:Authority");
|
||||
|
||||
var audiences = authoritySection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in bootstrapOptions.Authority.Audiences)
|
||||
foreach (var audience in audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
var requiredScopes = authoritySection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
resourceOptions.RequiredScopes.Clear();
|
||||
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
|
||||
foreach (var scope in requiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
|
||||
var bypassNetworks = authoritySection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
foreach (var network in bypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
@@ -139,8 +146,11 @@ builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
var scopes = bootstrapOptions.Authority.RequiredScopes.Count > 0
|
||||
? bootstrapOptions.Authority.RequiredScopes.ToArray()
|
||||
var configuredScopes = builder.Configuration
|
||||
.GetSection("findings:ledger:Authority:RequiredScopes")
|
||||
.Get<string[]>() ?? [];
|
||||
var scopes = configuredScopes.Length > 0
|
||||
? configuredScopes
|
||||
: new[] { StellaOpsScopes.VulnOperate };
|
||||
|
||||
// Default policy uses StellaOpsScopeRequirement so bypass evaluator can grant
|
||||
@@ -186,6 +196,7 @@ builder.Services.AddAuthorization(options =>
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
});
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
|
||||
builder.Services.AddSingleton<ILedgerIncidentNotifier, LoggingLedgerIncidentNotifier>();
|
||||
builder.Services.AddSingleton<LedgerIncidentCoordinator>();
|
||||
|
||||
@@ -73,11 +73,11 @@ public sealed class LedgerServiceOptions
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
public IList<string> Audiences { get; } = new List<string>();
|
||||
public IList<string> Audiences { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> RequiredScopes { get; } = new List<string>();
|
||||
public IList<string> RequiredScopes { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> BypassNetworks { get; } = new List<string>();
|
||||
public IList<string> BypassNetworks { get; set; } = new List<string>();
|
||||
|
||||
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Integrations.Persistence;
|
||||
using StellaOps.Integrations.Plugin.GitHubApp;
|
||||
using StellaOps.Integrations.Plugin.Harbor;
|
||||
using StellaOps.Integrations.Plugin.InMemory;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using StellaOps.Integrations.WebService.AiCodeGuard;
|
||||
using StellaOps.Integrations.WebService.Infrastructure;
|
||||
@@ -16,6 +19,7 @@ builder.Services.AddSwaggerGen(options =>
|
||||
|
||||
// Database
|
||||
var connectionString = builder.Configuration.GetConnectionString("IntegrationsDb")
|
||||
?? builder.Configuration.GetConnectionString("Default")
|
||||
?? "Host=localhost;Database=stellaops_integrations;Username=postgres;Password=postgres";
|
||||
|
||||
builder.Services.AddDbContext<IntegrationDbContext>(options =>
|
||||
@@ -40,11 +44,19 @@ builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
||||
}
|
||||
|
||||
// Also load from current assembly (for built-in plugins)
|
||||
loader.LoadFromAssemblies([typeof(Program).Assembly]);
|
||||
loader.LoadFromAssemblies(
|
||||
[
|
||||
typeof(Program).Assembly,
|
||||
typeof(GitHubAppConnectorPlugin).Assembly,
|
||||
typeof(HarborConnectorPlugin).Assembly,
|
||||
typeof(InMemoryConnectorPlugin).Assembly
|
||||
]);
|
||||
|
||||
return loader;
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Infrastructure
|
||||
builder.Services.AddScoped<IIntegrationEventPublisher, LoggingEventPublisher>();
|
||||
builder.Services.AddScoped<IIntegrationAuditLogger, LoggingAuditLogger>();
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
|
||||
@@ -14,6 +14,11 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public GitHubAppConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public GitHubAppConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
@@ -15,6 +15,11 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public HarborConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public HarborConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
@@ -11,6 +11,11 @@ public sealed class InMemoryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
@@ -50,7 +50,6 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Map endpoints
|
||||
app.MapOpsMemoryEndpoints();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Orchestrator.Core.Backfill;
|
||||
using StellaOps.Orchestrator.Core.DeadLetter;
|
||||
using StellaOps.Orchestrator.Core.Observability;
|
||||
using StellaOps.Orchestrator.Core.Repositories;
|
||||
using StellaOps.Orchestrator.Core.Services;
|
||||
@@ -50,6 +51,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IPackRunLogRepository, PostgresPackRunLogRepository>();
|
||||
services.AddScoped<IPackRegistryRepository, PostgresPackRegistryRepository>();
|
||||
services.AddScoped<IFirstSignalSnapshotRepository, PostgresFirstSignalSnapshotRepository>();
|
||||
services.AddScoped<IDeadLetterRepository, PostgresDeadLetterRepository>();
|
||||
|
||||
// Register audit and ledger repositories
|
||||
services.AddScoped<IAuditRepository, PostgresAuditRepository>();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.ControlPlane;
|
||||
|
||||
public sealed class ReleaseDashboardSnapshotBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ReturnsExpectedControlPlaneShape()
|
||||
{
|
||||
var snapshot = ReleaseDashboardSnapshotBuilder.Build();
|
||||
|
||||
Assert.Equal(4, snapshot.PipelineData.Environments.Count);
|
||||
Assert.Equal(3, snapshot.PipelineData.Connections.Count);
|
||||
|
||||
Assert.Equal(2, snapshot.PendingApprovals.Count);
|
||||
Assert.All(
|
||||
snapshot.PendingApprovals,
|
||||
approval => Assert.Contains(approval.Urgency, new[] { "low", "normal", "high", "critical" }));
|
||||
|
||||
Assert.Single(snapshot.ActiveDeployments);
|
||||
Assert.Equal("running", snapshot.ActiveDeployments[0].Status);
|
||||
|
||||
Assert.Equal(5, snapshot.RecentReleases.Count);
|
||||
Assert.Equal("rel-003", snapshot.RecentReleases[0].Id);
|
||||
Assert.Equal("promoting", snapshot.RecentReleases[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministicAcrossInvocations()
|
||||
{
|
||||
var first = ReleaseDashboardSnapshotBuilder.Build();
|
||||
var second = ReleaseDashboardSnapshotBuilder.Build();
|
||||
|
||||
var firstJson = JsonSerializer.Serialize(first);
|
||||
var secondJson = JsonSerializer.Serialize(second);
|
||||
|
||||
Assert.Equal(firstJson, secondJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Approval endpoints for the release orchestrator.
|
||||
/// Routes: /api/release-orchestrator/approvals
|
||||
/// </summary>
|
||||
public static class ApprovalEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapApprovalEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/release-orchestrator/approvals")
|
||||
.WithTags("Approvals");
|
||||
|
||||
group.MapGet(string.Empty, ListApprovals)
|
||||
.WithName("Approval_List")
|
||||
.WithDescription("List approval requests with optional filtering");
|
||||
|
||||
group.MapGet("/{id}", GetApproval)
|
||||
.WithName("Approval_Get")
|
||||
.WithDescription("Get an approval by ID");
|
||||
|
||||
group.MapPost("/{id}/approve", Approve)
|
||||
.WithName("Approval_Approve")
|
||||
.WithDescription("Approve a pending approval request");
|
||||
|
||||
group.MapPost("/{id}/reject", Reject)
|
||||
.WithName("Approval_Reject")
|
||||
.WithDescription("Reject a pending approval request");
|
||||
|
||||
group.MapPost("/batch-approve", BatchApprove)
|
||||
.WithName("Approval_BatchApprove")
|
||||
.WithDescription("Batch approve multiple requests");
|
||||
|
||||
group.MapPost("/batch-reject", BatchReject)
|
||||
.WithName("Approval_BatchReject")
|
||||
.WithDescription("Batch reject multiple requests");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static IResult ListApprovals(
|
||||
[FromQuery] string? statuses,
|
||||
[FromQuery] string? urgencies,
|
||||
[FromQuery] string? environment)
|
||||
{
|
||||
var approvals = SeedData.Approvals.Select(a => new
|
||||
{
|
||||
a.Id, a.ReleaseId, a.ReleaseName, a.ReleaseVersion,
|
||||
a.SourceEnvironment, a.TargetEnvironment,
|
||||
a.RequestedBy, a.RequestedAt, a.Urgency, a.Justification,
|
||||
a.Status, a.CurrentApprovals, a.RequiredApprovals,
|
||||
a.GatesPassed, a.ScheduledTime, a.ExpiresAt,
|
||||
}).AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statuses))
|
||||
{
|
||||
var statusList = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
approvals = approvals.Where(a => statusList.Contains(a.Status, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(urgencies))
|
||||
{
|
||||
var urgencyList = urgencies.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
approvals = approvals.Where(a => urgencyList.Contains(a.Urgency, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
approvals = approvals.Where(a =>
|
||||
string.Equals(a.TargetEnvironment, environment, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Results.Ok(approvals.ToList());
|
||||
}
|
||||
|
||||
private static IResult GetApproval(string id)
|
||||
{
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
return approval is not null ? Results.Ok(approval) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult Approve(string id, [FromBody] ApprovalActionDto request)
|
||||
{
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
if (approval is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(approval with
|
||||
{
|
||||
CurrentApprovals = approval.CurrentApprovals + 1,
|
||||
Status = approval.CurrentApprovals + 1 >= approval.RequiredApprovals ? "approved" : approval.Status,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult Reject(string id, [FromBody] ApprovalActionDto request)
|
||||
{
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
if (approval is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(approval with { Status = "rejected" });
|
||||
}
|
||||
|
||||
private static IResult BatchApprove([FromBody] BatchActionDto request)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult BatchReject([FromBody] BatchActionDto request)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record ApprovalDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ReleaseName { get; init; }
|
||||
public required string ReleaseVersion { get; init; }
|
||||
public required string SourceEnvironment { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string RequestedBy { get; init; }
|
||||
public required string RequestedAt { get; init; }
|
||||
public required string Urgency { get; init; }
|
||||
public required string Justification { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int CurrentApprovals { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public bool GatesPassed { get; init; }
|
||||
public string? ScheduledTime { get; init; }
|
||||
public string? ExpiresAt { get; init; }
|
||||
public List<GateResultDto> GateResults { get; init; } = new();
|
||||
public List<ApprovalActionRecordDto> Actions { get; init; } = new();
|
||||
public List<ApproverDto> Approvers { get; init; } = new();
|
||||
public List<ReleaseComponentSummaryDto> ReleaseComponents { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record GateResultDto
|
||||
{
|
||||
public required string GateId { get; init; }
|
||||
public required string GateName { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public Dictionary<string, object> Details { get; init; } = new();
|
||||
public string? EvaluatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovalActionRecordDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ApprovalId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
public required string Comment { get; init; }
|
||||
public required string Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApproverDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Email { get; init; }
|
||||
public bool HasApproved { get; init; }
|
||||
public string? ApprovedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReleaseComponentSummaryDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovalActionDto
|
||||
{
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BatchActionDto
|
||||
{
|
||||
public string[]? Ids { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
// ---- Seed Data ----
|
||||
|
||||
internal static class SeedData
|
||||
{
|
||||
public static readonly List<ApprovalDto> Approvals = new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "apr-001", ReleaseId = "rel-001", ReleaseName = "API Gateway", ReleaseVersion = "2.1.0",
|
||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
||||
RequestedBy = "alice.johnson", RequestedAt = "2026-01-12T08:00:00Z",
|
||||
Urgency = "normal", Justification = "Scheduled release with new rate limiting feature and bug fixes.",
|
||||
Status = "pending", CurrentApprovals = 1, RequiredApprovals = 2, GatesPassed = true,
|
||||
ExpiresAt = "2026-01-14T08:00:00Z",
|
||||
GateResults = new()
|
||||
{
|
||||
new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities found", EvaluatedAt = "2026-01-12T08:05:00Z" },
|
||||
new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = "2026-01-12T08:06:00Z" },
|
||||
new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Code coverage: 85%", EvaluatedAt = "2026-01-12T08:07:00Z" },
|
||||
},
|
||||
Actions = new()
|
||||
{
|
||||
new() { Id = "act-1", ApprovalId = "apr-001", Action = "approved", Actor = "bob.smith", Comment = "Looks good, tests are passing.", Timestamp = "2026-01-12T09:30:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com", HasApproved = true, ApprovedAt = "2026-01-12T09:30:00Z" },
|
||||
new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "api-gateway", Version = "2.1.0", Digest = "sha256:abc123def456..." },
|
||||
new() { Name = "rate-limiter", Version = "1.0.5", Digest = "sha256:789xyz012..." },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "apr-002", ReleaseId = "rel-002", ReleaseName = "User Service", ReleaseVersion = "3.0.0-rc1",
|
||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
||||
RequestedBy = "david.wilson", RequestedAt = "2026-01-12T10:00:00Z",
|
||||
Urgency = "high", Justification = "Critical fix for user authentication timeout issue.",
|
||||
Status = "pending", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = false,
|
||||
ExpiresAt = "2026-01-13T10:00:00Z",
|
||||
GateResults = new()
|
||||
{
|
||||
new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "warning", Message = "2 low severity vulnerabilities", EvaluatedAt = "2026-01-12T10:05:00Z" },
|
||||
new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = "2026-01-12T10:06:00Z" },
|
||||
new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "failed", Message = "Code coverage: 72%", EvaluatedAt = "2026-01-12T10:07:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com" },
|
||||
new() { Id = "u3", Name = "Emily Chen", Email = "emily.chen@example.com" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "user-service", Version = "3.0.0-rc1", Digest = "sha256:user123..." },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "apr-003", ReleaseId = "rel-003", ReleaseName = "Payment Gateway", ReleaseVersion = "1.5.2",
|
||||
SourceEnvironment = "dev", TargetEnvironment = "staging",
|
||||
RequestedBy = "frank.miller", RequestedAt = "2026-01-11T14:00:00Z",
|
||||
Urgency = "critical", Justification = "Emergency fix for payment processing failure.",
|
||||
Status = "approved", CurrentApprovals = 2, RequiredApprovals = 2, GatesPassed = true,
|
||||
ScheduledTime = "2026-01-12T06:00:00Z", ExpiresAt = "2026-01-12T14:00:00Z",
|
||||
Actions = new()
|
||||
{
|
||||
new() { Id = "act-2", ApprovalId = "apr-003", Action = "approved", Actor = "carol.davis", Comment = "Urgent fix approved.", Timestamp = "2026-01-11T14:30:00Z" },
|
||||
new() { Id = "act-3", ApprovalId = "apr-003", Action = "approved", Actor = "grace.lee", Comment = "Confirmed, proceed.", Timestamp = "2026-01-11T15:00:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com", HasApproved = true, ApprovedAt = "2026-01-11T14:30:00Z" },
|
||||
new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com", HasApproved = true, ApprovedAt = "2026-01-11T15:00:00Z" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "payment-gateway", Version = "1.5.2", Digest = "sha256:pay456..." },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "apr-004", ReleaseId = "rel-004", ReleaseName = "Notification Service", ReleaseVersion = "2.0.0",
|
||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
||||
RequestedBy = "alice.johnson", RequestedAt = "2026-01-10T09:00:00Z",
|
||||
Urgency = "low", Justification = "Feature release with new email templates.",
|
||||
Status = "rejected", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = true,
|
||||
ExpiresAt = "2026-01-12T09:00:00Z",
|
||||
Actions = new()
|
||||
{
|
||||
new() { Id = "act-4", ApprovalId = "apr-004", Action = "rejected", Actor = "bob.smith", Comment = "Missing integration tests.", Timestamp = "2026-01-10T11:00:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "notification-service", Version = "2.0.0", Digest = "sha256:notify789..." },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release dashboard endpoints consumed by the Console control plane.
|
||||
/// </summary>
|
||||
public static class ReleaseDashboardEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReleaseDashboardEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapForPrefix(app, "/api/v1/release-orchestrator", includeRouteNames: true);
|
||||
MapForPrefix(app, "/api/release-orchestrator", includeRouteNames: false);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapForPrefix(IEndpointRouteBuilder app, string prefix, bool includeRouteNames)
|
||||
{
|
||||
var group = app.MapGroup(prefix)
|
||||
.WithTags("ReleaseDashboard");
|
||||
|
||||
var dashboard = group.MapGet("/dashboard", GetDashboard)
|
||||
.WithDescription("Get release dashboard data for control-plane views.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
dashboard.WithName("ReleaseDashboard_Get");
|
||||
}
|
||||
|
||||
var approve = group.MapPost("/promotions/{id}/approve", ApprovePromotion)
|
||||
.WithDescription("Approve a pending promotion request.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
approve.WithName("ReleaseDashboard_ApprovePromotion");
|
||||
}
|
||||
|
||||
var reject = group.MapPost("/promotions/{id}/reject", RejectPromotion)
|
||||
.WithDescription("Reject a pending promotion request.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
reject.WithName("ReleaseDashboard_RejectPromotion");
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetDashboard()
|
||||
{
|
||||
return Results.Ok(ReleaseDashboardSnapshotBuilder.Build());
|
||||
}
|
||||
|
||||
private static IResult ApprovePromotion(string id)
|
||||
{
|
||||
var exists = ApprovalEndpoints.SeedData.Approvals
|
||||
.Any(approval => string.Equals(approval.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return exists
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { message = $"Promotion '{id}' was not found." });
|
||||
}
|
||||
|
||||
private static IResult RejectPromotion(string id, [FromBody] RejectPromotionRequest? request)
|
||||
{
|
||||
var exists = ApprovalEndpoints.SeedData.Approvals
|
||||
.Any(approval => string.Equals(approval.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return exists
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { message = $"Promotion '{id}' was not found." });
|
||||
}
|
||||
|
||||
public sealed record RejectPromotionRequest(string? Reason);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release management endpoints for the Orchestrator service.
|
||||
/// Provides CRUD and lifecycle operations for managed releases.
|
||||
/// Routes: /api/release-orchestrator/releases
|
||||
/// </summary>
|
||||
public static class ReleaseEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReleaseEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/release-orchestrator/releases")
|
||||
.WithTags("Releases");
|
||||
|
||||
group.MapGet(string.Empty, ListReleases)
|
||||
.WithName("Release_List")
|
||||
.WithDescription("List releases with optional filtering");
|
||||
|
||||
group.MapGet("/{id}", GetRelease)
|
||||
.WithName("Release_Get")
|
||||
.WithDescription("Get a release by ID");
|
||||
|
||||
group.MapPost(string.Empty, CreateRelease)
|
||||
.WithName("Release_Create")
|
||||
.WithDescription("Create a new release");
|
||||
|
||||
group.MapPatch("/{id}", UpdateRelease)
|
||||
.WithName("Release_Update")
|
||||
.WithDescription("Update an existing release");
|
||||
|
||||
group.MapDelete("/{id}", DeleteRelease)
|
||||
.WithName("Release_Delete")
|
||||
.WithDescription("Delete a release");
|
||||
|
||||
// Lifecycle
|
||||
group.MapPost("/{id}/ready", MarkReady)
|
||||
.WithName("Release_MarkReady")
|
||||
.WithDescription("Mark a release as ready for promotion");
|
||||
|
||||
group.MapPost("/{id}/promote", RequestPromotion)
|
||||
.WithName("Release_Promote")
|
||||
.WithDescription("Request promotion to target environment");
|
||||
|
||||
group.MapPost("/{id}/deploy", Deploy)
|
||||
.WithName("Release_Deploy")
|
||||
.WithDescription("Deploy a release");
|
||||
|
||||
group.MapPost("/{id}/rollback", Rollback)
|
||||
.WithName("Release_Rollback")
|
||||
.WithDescription("Rollback a deployed release");
|
||||
|
||||
group.MapPost("/{id}/clone", CloneRelease)
|
||||
.WithName("Release_Clone")
|
||||
.WithDescription("Clone a release with new name and version");
|
||||
|
||||
// Components
|
||||
group.MapGet("/{releaseId}/components", GetComponents)
|
||||
.WithName("Release_GetComponents")
|
||||
.WithDescription("Get components for a release");
|
||||
|
||||
group.MapPost("/{releaseId}/components", AddComponent)
|
||||
.WithName("Release_AddComponent")
|
||||
.WithDescription("Add a component to a release");
|
||||
|
||||
group.MapPatch("/{releaseId}/components/{componentId}", UpdateComponent)
|
||||
.WithName("Release_UpdateComponent")
|
||||
.WithDescription("Update a release component");
|
||||
|
||||
group.MapDelete("/{releaseId}/components/{componentId}", RemoveComponent)
|
||||
.WithName("Release_RemoveComponent")
|
||||
.WithDescription("Remove a component from a release");
|
||||
|
||||
// Events
|
||||
group.MapGet("/{releaseId}/events", GetEvents)
|
||||
.WithName("Release_GetEvents")
|
||||
.WithDescription("Get events for a release");
|
||||
|
||||
// Promotion preview
|
||||
group.MapGet("/{releaseId}/promotion-preview", GetPromotionPreview)
|
||||
.WithName("Release_PromotionPreview")
|
||||
.WithDescription("Get promotion preview with gate results");
|
||||
|
||||
group.MapGet("/{releaseId}/available-environments", GetAvailableEnvironments)
|
||||
.WithName("Release_AvailableEnvironments")
|
||||
.WithDescription("Get available target environments for promotion");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
private static IResult ListReleases(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? statuses,
|
||||
[FromQuery] string? environment,
|
||||
[FromQuery] string? sortField,
|
||||
[FromQuery] string? sortOrder,
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] int? pageSize)
|
||||
{
|
||||
var releases = SeedData.Releases.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.ToLowerInvariant();
|
||||
releases = releases.Where(r =>
|
||||
r.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Version.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Description.Contains(term, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statuses))
|
||||
{
|
||||
var statusList = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
releases = releases.Where(r => statusList.Contains(r.Status, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
releases = releases.Where(r =>
|
||||
string.Equals(r.CurrentEnvironment, environment, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.TargetEnvironment, environment, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var sorted = (sortField?.ToLowerInvariant(), sortOrder?.ToLowerInvariant()) switch
|
||||
{
|
||||
("name", "asc") => releases.OrderBy(r => r.Name),
|
||||
("name", _) => releases.OrderByDescending(r => r.Name),
|
||||
("version", "asc") => releases.OrderBy(r => r.Version),
|
||||
("version", _) => releases.OrderByDescending(r => r.Version),
|
||||
("status", "asc") => releases.OrderBy(r => r.Status),
|
||||
("status", _) => releases.OrderByDescending(r => r.Status),
|
||||
(_, "asc") => releases.OrderBy(r => r.CreatedAt),
|
||||
_ => releases.OrderByDescending(r => r.CreatedAt),
|
||||
};
|
||||
|
||||
var all = sorted.ToList();
|
||||
var effectivePage = Math.Max(page ?? 1, 1);
|
||||
var effectivePageSize = Math.Clamp(pageSize ?? 20, 1, 100);
|
||||
var items = all.Skip((effectivePage - 1) * effectivePageSize).Take(effectivePageSize).ToList();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
items,
|
||||
total = all.Count,
|
||||
page = effectivePage,
|
||||
pageSize = effectivePageSize,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetRelease(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
return release is not null ? Results.Ok(release) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult CreateRelease([FromBody] CreateReleaseDto request, [FromServices] TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
var release = new ManagedReleaseDto
|
||||
{
|
||||
Id = $"rel-{Guid.NewGuid():N}"[..11],
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
Description = request.Description ?? "",
|
||||
Status = "draft",
|
||||
CurrentEnvironment = null,
|
||||
TargetEnvironment = request.TargetEnvironment,
|
||||
ComponentCount = 0,
|
||||
CreatedAt = now,
|
||||
CreatedBy = "api",
|
||||
UpdatedAt = now,
|
||||
DeployedAt = null,
|
||||
DeploymentStrategy = request.DeploymentStrategy ?? "rolling",
|
||||
};
|
||||
return Results.Created($"/api/release-orchestrator/releases/{release.Id}", release);
|
||||
}
|
||||
|
||||
private static IResult UpdateRelease(string id, [FromBody] UpdateReleaseDto request)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Name = request.Name ?? release.Name,
|
||||
Description = request.Description ?? release.Description,
|
||||
TargetEnvironment = request.TargetEnvironment ?? release.TargetEnvironment,
|
||||
DeploymentStrategy = request.DeploymentStrategy ?? release.DeploymentStrategy,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult DeleteRelease(string id)
|
||||
{
|
||||
var exists = SeedData.Releases.Any(r => r.Id == id);
|
||||
return exists ? Results.NoContent() : Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult MarkReady(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
return Results.Ok(release with { Status = "ready", UpdatedAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
|
||||
private static IResult RequestPromotion(string id, [FromBody] PromoteDto request)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
return Results.Ok(release with { TargetEnvironment = request.TargetEnvironment, UpdatedAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
|
||||
private static IResult Deploy(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Status = "deployed",
|
||||
CurrentEnvironment = release.TargetEnvironment,
|
||||
TargetEnvironment = null,
|
||||
DeployedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult Rollback(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Status = "rolled_back",
|
||||
CurrentEnvironment = null,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult CloneRelease(string id, [FromBody] CloneReleaseDto request)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Id = $"rel-{Guid.NewGuid():N}"[..11],
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
Status = "draft",
|
||||
CurrentEnvironment = null,
|
||||
TargetEnvironment = null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
DeployedAt = null,
|
||||
CreatedBy = "api",
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetComponents(string releaseId)
|
||||
{
|
||||
if (!SeedData.Components.TryGetValue(releaseId, out var components))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
return Results.Ok(components);
|
||||
}
|
||||
|
||||
private static IResult AddComponent(string releaseId, [FromBody] AddComponentDto request)
|
||||
{
|
||||
var component = new ReleaseComponentDto
|
||||
{
|
||||
Id = $"comp-{Guid.NewGuid():N}"[..12],
|
||||
ReleaseId = releaseId,
|
||||
Name = request.Name,
|
||||
ImageRef = request.ImageRef,
|
||||
Digest = request.Digest,
|
||||
Tag = request.Tag,
|
||||
Version = request.Version,
|
||||
Type = request.Type,
|
||||
ConfigOverrides = request.ConfigOverrides ?? new Dictionary<string, string>(),
|
||||
};
|
||||
return Results.Created($"/api/release-orchestrator/releases/{releaseId}/components/{component.Id}", component);
|
||||
}
|
||||
|
||||
private static IResult UpdateComponent(string releaseId, string componentId, [FromBody] UpdateComponentDto request)
|
||||
{
|
||||
if (!SeedData.Components.TryGetValue(releaseId, out var components))
|
||||
return Results.NotFound();
|
||||
var comp = components.FirstOrDefault(c => c.Id == componentId);
|
||||
if (comp is null) return Results.NotFound();
|
||||
return Results.Ok(comp with { ConfigOverrides = request.ConfigOverrides ?? comp.ConfigOverrides });
|
||||
}
|
||||
|
||||
private static IResult RemoveComponent(string releaseId, string componentId)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult GetEvents(string releaseId)
|
||||
{
|
||||
if (!SeedData.Events.TryGetValue(releaseId, out var events))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
return Results.Ok(events);
|
||||
}
|
||||
|
||||
private static IResult GetPromotionPreview(string releaseId, [FromQuery] string? targetEnvironmentId)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
releaseId,
|
||||
releaseName = "Platform Release",
|
||||
sourceEnvironment = "staging",
|
||||
targetEnvironment = targetEnvironmentId == "env-production" ? "production" : "staging",
|
||||
gateResults = new[]
|
||||
{
|
||||
new { gateId = "g1", gateName = "Security Scan", type = "security", status = "passed", message = "No vulnerabilities found", details = new Dictionary<string, object>(), evaluatedAt = DateTimeOffset.UtcNow },
|
||||
new { gateId = "g2", gateName = "Policy Compliance", type = "policy", status = "passed", message = "All policies satisfied", details = new Dictionary<string, object>(), evaluatedAt = DateTimeOffset.UtcNow },
|
||||
},
|
||||
allGatesPassed = true,
|
||||
requiredApprovers = 2,
|
||||
estimatedDeployTime = 300,
|
||||
warnings = Array.Empty<string>(),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetAvailableEnvironments(string releaseId)
|
||||
{
|
||||
return Results.Ok(new[]
|
||||
{
|
||||
new { id = "env-staging", name = "Staging", tier = "staging" },
|
||||
new { id = "env-production", name = "Production", tier = "production" },
|
||||
new { id = "env-canary", name = "Canary", tier = "production" },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record ManagedReleaseDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? CurrentEnvironment { get; init; }
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public DateTimeOffset? DeployedAt { get; init; }
|
||||
public string DeploymentStrategy { get; init; } = "rolling";
|
||||
}
|
||||
|
||||
public sealed record ReleaseComponentDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public Dictionary<string, string> ConfigOverrides { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record ReleaseEventDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public Dictionary<string, object> Metadata { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record CreateReleaseDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? DeploymentStrategy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateReleaseDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? DeploymentStrategy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PromoteDto
|
||||
{
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? TargetEnvironmentId { get; init; }
|
||||
public string? Urgency { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ScheduledTime { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CloneReleaseDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AddComponentDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public Dictionary<string, string>? ConfigOverrides { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateComponentDto
|
||||
{
|
||||
public Dictionary<string, string>? ConfigOverrides { get; init; }
|
||||
}
|
||||
|
||||
// ---- Seed Data ----
|
||||
|
||||
internal static class SeedData
|
||||
{
|
||||
public static readonly List<ManagedReleaseDto> Releases = new()
|
||||
{
|
||||
new() { Id = "rel-001", Name = "Platform Release", Version = "1.2.3", Description = "Feature release with API improvements and bug fixes", Status = "deployed", CurrentEnvironment = "production", TargetEnvironment = null, ComponentCount = 3, CreatedAt = DateTimeOffset.Parse("2026-01-10T08:00:00Z"), CreatedBy = "deploy-bot", UpdatedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z"), DeployedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z"), DeploymentStrategy = "rolling" },
|
||||
new() { Id = "rel-002", Name = "Platform Release", Version = "1.3.0-rc1", Description = "Release candidate for next major version", Status = "ready", CurrentEnvironment = "staging", TargetEnvironment = "production", ComponentCount = 4, CreatedAt = DateTimeOffset.Parse("2026-01-11T10:00:00Z"), CreatedBy = "ci-pipeline", UpdatedAt = DateTimeOffset.Parse("2026-01-12T09:00:00Z"), DeploymentStrategy = "blue_green" },
|
||||
new() { Id = "rel-003", Name = "Hotfix", Version = "1.2.4", Description = "Critical security patch", Status = "deploying", CurrentEnvironment = "staging", TargetEnvironment = "production", ComponentCount = 1, CreatedAt = DateTimeOffset.Parse("2026-01-12T06:00:00Z"), CreatedBy = "security-team", UpdatedAt = DateTimeOffset.Parse("2026-01-12T10:00:00Z"), DeploymentStrategy = "rolling" },
|
||||
new() { Id = "rel-004", Name = "Feature Branch", Version = "2.0.0-alpha", Description = "New architecture preview", Status = "draft", TargetEnvironment = "dev", ComponentCount = 5, CreatedAt = DateTimeOffset.Parse("2026-01-08T15:00:00Z"), CreatedBy = "dev-team", UpdatedAt = DateTimeOffset.Parse("2026-01-10T11:00:00Z"), DeploymentStrategy = "recreate" },
|
||||
new() { Id = "rel-005", Name = "Platform Release", Version = "1.2.2", Description = "Previous stable release", Status = "rolled_back", ComponentCount = 3, CreatedAt = DateTimeOffset.Parse("2026-01-05T12:00:00Z"), CreatedBy = "deploy-bot", UpdatedAt = DateTimeOffset.Parse("2026-01-10T08:00:00Z"), DeployedAt = DateTimeOffset.Parse("2026-01-06T10:00:00Z"), DeploymentStrategy = "rolling" },
|
||||
};
|
||||
|
||||
public static readonly Dictionary<string, List<ReleaseComponentDto>> Components = new()
|
||||
{
|
||||
["rel-001"] = new()
|
||||
{
|
||||
new() { Id = "comp-001", ReleaseId = "rel-001", Name = "api-service", ImageRef = "registry.example.com/api-service", Digest = "sha256:abc123def456", Tag = "v1.2.3", Version = "1.2.3", Type = "container" },
|
||||
new() { Id = "comp-002", ReleaseId = "rel-001", Name = "worker-service", ImageRef = "registry.example.com/worker-service", Digest = "sha256:def456abc789", Tag = "v1.2.3", Version = "1.2.3", Type = "container" },
|
||||
new() { Id = "comp-003", ReleaseId = "rel-001", Name = "web-app", ImageRef = "registry.example.com/web-app", Digest = "sha256:789abc123def", Tag = "v1.2.3", Version = "1.2.3", Type = "container" },
|
||||
},
|
||||
["rel-002"] = new()
|
||||
{
|
||||
new() { Id = "comp-004", ReleaseId = "rel-002", Name = "api-service", ImageRef = "registry.example.com/api-service", Digest = "sha256:new123new456", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" },
|
||||
new() { Id = "comp-005", ReleaseId = "rel-002", Name = "worker-service", ImageRef = "registry.example.com/worker-service", Digest = "sha256:new456new789", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" },
|
||||
new() { Id = "comp-006", ReleaseId = "rel-002", Name = "web-app", ImageRef = "registry.example.com/web-app", Digest = "sha256:new789newabc", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" },
|
||||
new() { Id = "comp-007", ReleaseId = "rel-002", Name = "migration", ImageRef = "registry.example.com/migration", Digest = "sha256:mig123mig456", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "script" },
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly Dictionary<string, List<ReleaseEventDto>> Events = new()
|
||||
{
|
||||
["rel-001"] = new()
|
||||
{
|
||||
new() { Id = "evt-001", ReleaseId = "rel-001", Type = "created", Environment = null, Actor = "deploy-bot", Message = "Release created", Timestamp = DateTimeOffset.Parse("2026-01-10T08:00:00Z") },
|
||||
new() { Id = "evt-002", ReleaseId = "rel-001", Type = "promoted", Environment = "dev", Actor = "deploy-bot", Message = "Promoted to dev", Timestamp = DateTimeOffset.Parse("2026-01-10T09:00:00Z") },
|
||||
new() { Id = "evt-003", ReleaseId = "rel-001", Type = "deployed", Environment = "dev", Actor = "deploy-bot", Message = "Successfully deployed to dev", Timestamp = DateTimeOffset.Parse("2026-01-10T09:30:00Z") },
|
||||
new() { Id = "evt-004", ReleaseId = "rel-001", Type = "approved", Environment = "staging", Actor = "qa-team", Message = "Approved for staging", Timestamp = DateTimeOffset.Parse("2026-01-10T14:00:00Z") },
|
||||
new() { Id = "evt-005", ReleaseId = "rel-001", Type = "deployed", Environment = "staging", Actor = "deploy-bot", Message = "Successfully deployed to staging", Timestamp = DateTimeOffset.Parse("2026-01-10T14:30:00Z") },
|
||||
new() { Id = "evt-006", ReleaseId = "rel-001", Type = "approved", Environment = "production", Actor = "release-manager", Message = "Approved for production", Timestamp = DateTimeOffset.Parse("2026-01-11T10:00:00Z") },
|
||||
new() { Id = "evt-007", ReleaseId = "rel-001", Type = "deployed", Environment = "production", Actor = "deploy-bot", Message = "Successfully deployed to production", Timestamp = DateTimeOffset.Parse("2026-01-11T14:30:00Z") },
|
||||
},
|
||||
["rel-002"] = new()
|
||||
{
|
||||
new() { Id = "evt-008", ReleaseId = "rel-002", Type = "created", Environment = null, Actor = "ci-pipeline", Message = "Release created from CI", Timestamp = DateTimeOffset.Parse("2026-01-11T10:00:00Z") },
|
||||
new() { Id = "evt-009", ReleaseId = "rel-002", Type = "deployed", Environment = "staging", Actor = "deploy-bot", Message = "Deployed to staging for testing", Timestamp = DateTimeOffset.Parse("2026-01-11T12:00:00Z") },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,14 @@ app.MapWorkerEndpoints();
|
||||
app.MapCircuitBreakerEndpoints();
|
||||
app.MapQuotaGovernanceEndpoints();
|
||||
|
||||
// Register dead-letter queue management endpoints
|
||||
app.MapDeadLetterEndpoints();
|
||||
|
||||
// Register release management and approval endpoints
|
||||
app.MapReleaseEndpoints();
|
||||
app.MapApprovalEndpoints();
|
||||
app.MapReleaseDashboardEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic release dashboard snapshots from in-memory seed data.
|
||||
/// </summary>
|
||||
public static class ReleaseDashboardSnapshotBuilder
|
||||
{
|
||||
private static readonly PipelineDefinition[] PipelineDefinitions =
|
||||
{
|
||||
new("dev", "development", "Development", 1),
|
||||
new("staging", "staging", "Staging", 2),
|
||||
new("uat", "uat", "UAT", 3),
|
||||
new("production", "production", "Production", 4),
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllowedReleaseStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"draft",
|
||||
"ready",
|
||||
"promoting",
|
||||
"deployed",
|
||||
"failed",
|
||||
"deprecated",
|
||||
"rolled_back",
|
||||
};
|
||||
|
||||
public static ReleaseDashboardSnapshot Build()
|
||||
{
|
||||
var releases = ReleaseEndpoints.SeedData.Releases
|
||||
.OrderByDescending(release => release.CreatedAt)
|
||||
.ThenBy(release => release.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var approvals = ApprovalEndpoints.SeedData.Approvals
|
||||
.OrderBy(approval => ParseTimestamp(approval.RequestedAt))
|
||||
.ThenBy(approval => approval.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var pendingApprovals = approvals
|
||||
.Where(approval => string.Equals(approval.Status, "pending", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(approval => new PendingApprovalItem(
|
||||
approval.Id,
|
||||
approval.ReleaseId,
|
||||
approval.ReleaseName,
|
||||
approval.ReleaseVersion,
|
||||
ToDisplayEnvironment(approval.SourceEnvironment),
|
||||
ToDisplayEnvironment(approval.TargetEnvironment),
|
||||
approval.RequestedBy,
|
||||
approval.RequestedAt,
|
||||
NormalizeUrgency(approval.Urgency)))
|
||||
.ToArray();
|
||||
|
||||
var activeDeployments = releases
|
||||
.Where(release => string.Equals(release.Status, "deploying", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(release => release.UpdatedAt)
|
||||
.ThenBy(release => release.Id, StringComparer.Ordinal)
|
||||
.Select((release, index) =>
|
||||
{
|
||||
var progress = Math.Min(90, 45 + (index * 15));
|
||||
var totalTargets = Math.Max(1, release.ComponentCount);
|
||||
var completedTargets = Math.Clamp(
|
||||
(int)Math.Round(totalTargets * (progress / 100d), MidpointRounding.AwayFromZero),
|
||||
1,
|
||||
totalTargets);
|
||||
|
||||
return new ActiveDeploymentItem(
|
||||
Id: $"dep-{release.Id}",
|
||||
ReleaseId: release.Id,
|
||||
ReleaseName: release.Name,
|
||||
ReleaseVersion: release.Version,
|
||||
Environment: ToDisplayEnvironment(release.TargetEnvironment ?? release.CurrentEnvironment ?? "staging"),
|
||||
Progress: progress,
|
||||
Status: "running",
|
||||
StartedAt: release.UpdatedAt.ToString("O"),
|
||||
CompletedTargets: completedTargets,
|
||||
TotalTargets: totalTargets);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var pipelineEnvironments = PipelineDefinitions
|
||||
.Select(definition =>
|
||||
{
|
||||
var releaseCount = releases.Count(release =>
|
||||
string.Equals(NormalizeEnvironment(release.CurrentEnvironment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase));
|
||||
var pendingCount = pendingApprovals.Count(approval =>
|
||||
string.Equals(NormalizeEnvironment(approval.TargetEnvironment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase));
|
||||
var hasActiveDeployment = activeDeployments.Any(deployment =>
|
||||
string.Equals(NormalizeEnvironment(deployment.Environment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var healthStatus = hasActiveDeployment || pendingCount > 0
|
||||
? "degraded"
|
||||
: releaseCount > 0
|
||||
? "healthy"
|
||||
: "unknown";
|
||||
|
||||
return new PipelineEnvironmentItem(
|
||||
definition.Id,
|
||||
definition.NormalizedName,
|
||||
definition.DisplayName,
|
||||
definition.Order,
|
||||
releaseCount,
|
||||
pendingCount,
|
||||
healthStatus);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var pipelineConnections = PipelineDefinitions
|
||||
.Skip(1)
|
||||
.Select((definition, index) => new PipelineConnectionItem(
|
||||
PipelineDefinitions[index].Id,
|
||||
definition.Id))
|
||||
.ToArray();
|
||||
|
||||
var recentReleases = releases
|
||||
.Take(10)
|
||||
.Select(release => new RecentReleaseItem(
|
||||
release.Id,
|
||||
release.Name,
|
||||
release.Version,
|
||||
NormalizeReleaseStatus(release.Status),
|
||||
release.CurrentEnvironment is null ? null : ToDisplayEnvironment(release.CurrentEnvironment),
|
||||
release.CreatedAt.ToString("O"),
|
||||
string.IsNullOrWhiteSpace(release.CreatedBy) ? "system" : release.CreatedBy,
|
||||
release.ComponentCount))
|
||||
.ToArray();
|
||||
|
||||
return new ReleaseDashboardSnapshot(
|
||||
new PipelineData(pipelineEnvironments, pipelineConnections),
|
||||
pendingApprovals,
|
||||
activeDeployments,
|
||||
recentReleases);
|
||||
}
|
||||
|
||||
private static DateTimeOffset ParseTimestamp(string value)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private static string NormalizeEnvironment(string? value)
|
||||
{
|
||||
var normalized = value?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
return normalized switch
|
||||
{
|
||||
"dev" => "development",
|
||||
"stage" => "staging",
|
||||
"prod" => "production",
|
||||
_ => normalized,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDisplayEnvironment(string? value)
|
||||
{
|
||||
return NormalizeEnvironment(value) switch
|
||||
{
|
||||
"development" => "Development",
|
||||
"staging" => "Staging",
|
||||
"uat" => "UAT",
|
||||
"production" => "Production",
|
||||
var other when string.IsNullOrWhiteSpace(other) => "Unknown",
|
||||
var other => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(other),
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeReleaseStatus(string value)
|
||||
{
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
if (string.Equals(normalized, "deploying", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "promoting";
|
||||
}
|
||||
|
||||
return AllowedReleaseStatuses.Contains(normalized) ? normalized : "draft";
|
||||
}
|
||||
|
||||
private static string NormalizeUrgency(string value)
|
||||
{
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"low" or "normal" or "high" or "critical" => normalized,
|
||||
_ => "normal",
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record PipelineDefinition(string Id, string NormalizedName, string DisplayName, int Order);
|
||||
}
|
||||
|
||||
public sealed record ReleaseDashboardSnapshot(
|
||||
PipelineData PipelineData,
|
||||
IReadOnlyList<PendingApprovalItem> PendingApprovals,
|
||||
IReadOnlyList<ActiveDeploymentItem> ActiveDeployments,
|
||||
IReadOnlyList<RecentReleaseItem> RecentReleases);
|
||||
|
||||
public sealed record PipelineData(
|
||||
IReadOnlyList<PipelineEnvironmentItem> Environments,
|
||||
IReadOnlyList<PipelineConnectionItem> Connections);
|
||||
|
||||
public sealed record PipelineEnvironmentItem(
|
||||
string Id,
|
||||
string Name,
|
||||
string DisplayName,
|
||||
int Order,
|
||||
int ReleaseCount,
|
||||
int PendingCount,
|
||||
string HealthStatus);
|
||||
|
||||
public sealed record PipelineConnectionItem(string From, string To);
|
||||
|
||||
public sealed record PendingApprovalItem(
|
||||
string Id,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string ReleaseVersion,
|
||||
string SourceEnvironment,
|
||||
string TargetEnvironment,
|
||||
string RequestedBy,
|
||||
string RequestedAt,
|
||||
string Urgency);
|
||||
|
||||
public sealed record ActiveDeploymentItem(
|
||||
string Id,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string ReleaseVersion,
|
||||
string Environment,
|
||||
int Progress,
|
||||
string Status,
|
||||
string StartedAt,
|
||||
int CompletedTargets,
|
||||
int TotalTargets);
|
||||
|
||||
public sealed record RecentReleaseItem(
|
||||
string Id,
|
||||
string Name,
|
||||
string Version,
|
||||
string Status,
|
||||
string? CurrentEnvironment,
|
||||
string CreatedAt,
|
||||
string CreatedBy,
|
||||
int ComponentCount);
|
||||
@@ -26,6 +26,7 @@ public static class PlatformEndpoints
|
||||
MapPreferencesEndpoints(platform);
|
||||
MapSearchEndpoints(app, platform);
|
||||
MapMetadataEndpoints(platform);
|
||||
MapLegacyQuotaCompatibilityEndpoints(app);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -472,6 +473,402 @@ public static class PlatformEndpoints
|
||||
}).RequireAuthorization(PlatformPolicies.MetadataRead);
|
||||
}
|
||||
|
||||
private static void MapLegacyQuotaCompatibilityEndpoints(IEndpointRouteBuilder app)
|
||||
{
|
||||
var quotas = app.MapGroup("/api/v1/authority/quotas")
|
||||
.WithTags("Platform Quotas Compatibility");
|
||||
|
||||
quotas.MapGet(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(BuildLegacyEntitlement(summary.Value, requestContext!));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/consumption", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(BuildLegacyConsumption(summary.Value));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/dashboard", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new
|
||||
{
|
||||
entitlement = BuildLegacyEntitlement(summary.Value, requestContext!),
|
||||
consumption = BuildLegacyConsumption(summary.Value),
|
||||
tenantCount = 1,
|
||||
activeAlerts = 0,
|
||||
recentViolations = 0
|
||||
});
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/history", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
[FromQuery] string? categories,
|
||||
[FromQuery] string? aggregation,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var selected = string.IsNullOrWhiteSpace(categories)
|
||||
? null
|
||||
: categories.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var points = BuildLegacyConsumption(summary.Value)
|
||||
.Where(item => selected is null || selected.Contains(item.Category, StringComparer.OrdinalIgnoreCase))
|
||||
.Select(item => new
|
||||
{
|
||||
timestamp = now.ToString("o"),
|
||||
category = item.Category,
|
||||
value = item.Current,
|
||||
percentage = item.Percentage
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
period = new
|
||||
{
|
||||
start = now.AddDays(-30).ToString("o"),
|
||||
end = now.ToString("o")
|
||||
},
|
||||
points,
|
||||
aggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation
|
||||
});
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/tenants", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
[FromQuery] int limit,
|
||||
[FromQuery] int offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
var consumption = BuildLegacyConsumption(summary.Value);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var item = new
|
||||
{
|
||||
tenantId = requestContext!.TenantId,
|
||||
tenantName = "Default Tenant",
|
||||
planName = "Local Development",
|
||||
quotas = new
|
||||
{
|
||||
license = GetLegacyQuota(consumption, "license"),
|
||||
jobs = GetLegacyQuota(consumption, "jobs"),
|
||||
api = GetLegacyQuota(consumption, "api"),
|
||||
storage = GetLegacyQuota(consumption, "storage")
|
||||
},
|
||||
trend = "stable",
|
||||
trendPercentage = 0,
|
||||
lastActivity = now.ToString("o")
|
||||
};
|
||||
|
||||
var items = new[] { item }
|
||||
.Skip(Math.Max(0, offset))
|
||||
.Take(limit > 0 ? limit : 50)
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new { items, total = 1 });
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/tenants/{tenantId}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var consumption = BuildLegacyConsumption(result.Value);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
tenantName = "Default Tenant",
|
||||
planName = "Local Development",
|
||||
licensePeriod = new
|
||||
{
|
||||
start = DateTimeOffset.UtcNow.AddDays(-30).ToString("o"),
|
||||
end = DateTimeOffset.UtcNow.AddDays(30).ToString("o")
|
||||
},
|
||||
quotaDetails = new
|
||||
{
|
||||
artifacts = BuildLegacyLimit(consumption, "license", 100000),
|
||||
users = BuildLegacyLimit(consumption, "license", 25),
|
||||
scansPerDay = BuildLegacyLimit(consumption, "jobs", 1000),
|
||||
storageMb = BuildLegacyLimit(consumption, "storage", 5000),
|
||||
concurrentJobs = BuildLegacyLimit(consumption, "jobs", 20)
|
||||
},
|
||||
usageByResourceType = new[]
|
||||
{
|
||||
new { type = "api", percentage = GetLegacyQuota(consumption, "api").Percentage },
|
||||
new { type = "jobs", percentage = GetLegacyQuota(consumption, "jobs").Percentage },
|
||||
new { type = "storage", percentage = GetLegacyQuota(consumption, "storage").Percentage }
|
||||
},
|
||||
forecast = BuildLegacyForecast("api")
|
||||
});
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/forecast", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
[FromQuery] string? category) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var categories = string.IsNullOrWhiteSpace(category)
|
||||
? new[] { "license", "jobs", "api", "storage" }
|
||||
: new[] { category.Trim().ToLowerInvariant() };
|
||||
|
||||
var forecasts = categories.Select(BuildLegacyForecast).ToArray();
|
||||
return Results.Ok(forecasts);
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/alerts", (HttpContext context, PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
{
|
||||
return Task.FromResult(failure!);
|
||||
}
|
||||
|
||||
return Task.FromResult<IResult>(Results.Ok(new
|
||||
{
|
||||
thresholds = new[]
|
||||
{
|
||||
new { category = "license", enabled = true, warningThreshold = 75, criticalThreshold = 90 },
|
||||
new { category = "jobs", enabled = true, warningThreshold = 75, criticalThreshold = 90 },
|
||||
new { category = "api", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
|
||||
new { category = "storage", enabled = true, warningThreshold = 80, criticalThreshold = 95 }
|
||||
},
|
||||
channels = Array.Empty<object>(),
|
||||
escalationMinutes = 30
|
||||
}));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapPost("/alerts", (HttpContext context, PlatformRequestContextResolver resolver, [FromBody] object config) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
{
|
||||
return Task.FromResult(failure!);
|
||||
}
|
||||
|
||||
return Task.FromResult<IResult>(Results.Ok(config));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaAdmin);
|
||||
|
||||
var rateLimits = app.MapGroup("/api/v1/gateway/rate-limits")
|
||||
.WithTags("Platform Gateway Compatibility");
|
||||
|
||||
rateLimits.MapGet(string.Empty, (HttpContext context, PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
{
|
||||
return Task.FromResult(failure!);
|
||||
}
|
||||
|
||||
return Task.FromResult<IResult>(Results.Ok(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/release-orchestrator/dashboard",
|
||||
method = "GET",
|
||||
limit = 600,
|
||||
remaining = 599,
|
||||
resetAt = DateTimeOffset.UtcNow.AddMinutes(1).ToString("o"),
|
||||
burstLimit = 120,
|
||||
burstRemaining = 119
|
||||
}
|
||||
}));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
rateLimits.MapGet("/violations", (HttpContext context, PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
{
|
||||
return Task.FromResult(failure!);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return Task.FromResult<IResult>(Results.Ok(new
|
||||
{
|
||||
items = Array.Empty<object>(),
|
||||
total = 0,
|
||||
period = new
|
||||
{
|
||||
start = now.AddDays(-1).ToString("o"),
|
||||
end = now.ToString("o")
|
||||
}
|
||||
}));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
}
|
||||
|
||||
private static LegacyQuotaItem[] BuildLegacyConsumption(IReadOnlyList<PlatformQuotaUsage> usage)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToString("o");
|
||||
var map = usage
|
||||
.ToDictionary(item => ToLegacyCategory(item.QuotaId), item => item, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new[]
|
||||
{
|
||||
BuildLegacyConsumptionItem("license", map.GetValueOrDefault("license"), 100m, 27m, now),
|
||||
BuildLegacyConsumptionItem("jobs", map.GetValueOrDefault("jobs"), 1000m, 120m, now),
|
||||
BuildLegacyConsumptionItem("api", map.GetValueOrDefault("api"), 100000m, 23000m, now),
|
||||
BuildLegacyConsumptionItem("storage", map.GetValueOrDefault("storage"), 5000m, 2400m, now)
|
||||
};
|
||||
}
|
||||
|
||||
private static object BuildLegacyEntitlement(IReadOnlyList<PlatformQuotaUsage> usage, PlatformRequestContext context)
|
||||
{
|
||||
return new
|
||||
{
|
||||
planId = $"local-{context.TenantId}",
|
||||
planName = "Local Development",
|
||||
features = new[] { "control-plane", "policy", "security", "operations" },
|
||||
limits = new
|
||||
{
|
||||
artifacts = 100000,
|
||||
users = 25,
|
||||
scansPerDay = 1000,
|
||||
storageMb = 5000,
|
||||
concurrentJobs = 20,
|
||||
apiRequestsPerMinute = 600
|
||||
},
|
||||
validFrom = DateTimeOffset.UtcNow.AddDays(-30).ToString("o"),
|
||||
validTo = DateTimeOffset.UtcNow.AddDays(30).ToString("o")
|
||||
};
|
||||
}
|
||||
|
||||
private static LegacyQuotaItem BuildLegacyConsumptionItem(string category, PlatformQuotaUsage? usage, decimal fallbackLimit, decimal fallbackUsed, string now)
|
||||
{
|
||||
var limit = usage?.Limit ?? fallbackLimit;
|
||||
var current = usage?.Used ?? fallbackUsed;
|
||||
var percentage = limit <= 0 ? 0 : Math.Round((current / limit) * 100m, 1);
|
||||
|
||||
return new LegacyQuotaItem(
|
||||
category,
|
||||
current,
|
||||
limit,
|
||||
percentage,
|
||||
GetLegacyStatus(percentage),
|
||||
"stable",
|
||||
0,
|
||||
now);
|
||||
}
|
||||
|
||||
private static LegacyQuotaItem GetLegacyQuota(LegacyQuotaItem[] items, string category)
|
||||
{
|
||||
return items.First(item => string.Equals(item.Category, category, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static object BuildLegacyLimit(LegacyQuotaItem[] items, string category, decimal hardLimit)
|
||||
{
|
||||
var quota = GetLegacyQuota(items, category);
|
||||
var current = quota.Current;
|
||||
var limit = Math.Max(hardLimit, quota.Limit);
|
||||
var percentage = limit <= 0 ? 0 : Math.Round((current / limit) * 100m, 1);
|
||||
|
||||
return new
|
||||
{
|
||||
current,
|
||||
limit,
|
||||
percentage
|
||||
};
|
||||
}
|
||||
|
||||
private static object BuildLegacyForecast(string category)
|
||||
{
|
||||
return new
|
||||
{
|
||||
category,
|
||||
exhaustionDays = 45,
|
||||
confidence = 0.82,
|
||||
trendSlope = 0.04,
|
||||
recommendation = "Current usage is stable. Keep existing quota policy.",
|
||||
severity = "info"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetLegacyStatus(decimal percentage)
|
||||
{
|
||||
return percentage switch
|
||||
{
|
||||
>= 100m => "exceeded",
|
||||
>= 90m => "critical",
|
||||
>= 75m => "warning",
|
||||
_ => "healthy"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToLegacyCategory(string quotaId)
|
||||
{
|
||||
if (quotaId.Contains("gateway", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "api";
|
||||
}
|
||||
|
||||
if (quotaId.Contains("jobs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "jobs";
|
||||
}
|
||||
|
||||
if (quotaId.Contains("storage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "storage";
|
||||
}
|
||||
|
||||
return "license";
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
@@ -488,6 +885,16 @@ public static class PlatformEndpoints
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed record LegacyQuotaItem(
|
||||
string Category,
|
||||
decimal Current,
|
||||
decimal Limit,
|
||||
decimal Percentage,
|
||||
string Status,
|
||||
string Trend,
|
||||
decimal TrendPercentage,
|
||||
string LastUpdated);
|
||||
|
||||
private sealed record SearchQuery(
|
||||
[FromQuery(Name = "q")] string? Query,
|
||||
string? Sources,
|
||||
|
||||
@@ -73,26 +73,34 @@ builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
|
||||
|
||||
// Read collections directly from IConfiguration to work around
|
||||
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
|
||||
var authoritySection = builder.Configuration.GetSection("Platform:Authority");
|
||||
|
||||
var audiences = authoritySection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in bootstrapOptions.Authority.Audiences)
|
||||
foreach (var audience in audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
var requiredScopes = authoritySection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
resourceOptions.RequiredScopes.Clear();
|
||||
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
|
||||
foreach (var scope in requiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
var requiredTenants = authoritySection.GetSection("RequiredTenants").Get<string[]>() ?? [];
|
||||
resourceOptions.RequiredTenants.Clear();
|
||||
foreach (var tenant in bootstrapOptions.Authority.RequiredTenants)
|
||||
foreach (var tenant in requiredTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
var bypassNetworks = authoritySection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
resourceOptions.BypassNetworks.Clear();
|
||||
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
|
||||
foreach (var network in bypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ public sealed class PlatformAnalyticsQueryExecutor : IPlatformAnalyticsQueryExec
|
||||
SUM(total_vulns) - SUM(vex_mitigated) AS net_exposure,
|
||||
SUM(kev_vulns) AS kev_vulns
|
||||
FROM analytics.daily_vulnerability_counts
|
||||
WHERE snapshot_date >= CURRENT_DATE - make_interval(days => @days)
|
||||
AND (@environment IS NULL OR environment = @environment)
|
||||
WHERE snapshot_date >= CURRENT_DATE - (@days::int * INTERVAL '1 day')
|
||||
AND (@environment::text IS NULL OR environment = @environment::text)
|
||||
GROUP BY snapshot_date, environment
|
||||
ORDER BY environment, snapshot_date;
|
||||
""";
|
||||
@@ -134,8 +134,8 @@ public sealed class PlatformAnalyticsQueryExecutor : IPlatformAnalyticsQueryExec
|
||||
SUM(total_components) AS total_components,
|
||||
SUM(unique_suppliers) AS unique_suppliers
|
||||
FROM analytics.daily_component_counts
|
||||
WHERE snapshot_date >= CURRENT_DATE - make_interval(days => @days)
|
||||
AND (@environment IS NULL OR environment = @environment)
|
||||
WHERE snapshot_date >= CURRENT_DATE - (@days::int * INTERVAL '1 day')
|
||||
AND (@environment::text IS NULL OR environment = @environment::text)
|
||||
GROUP BY snapshot_date, environment
|
||||
ORDER BY environment, snapshot_date;
|
||||
""";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Export;
|
||||
@@ -15,7 +16,7 @@ internal static class ProfileExportEndpoints
|
||||
public static IEndpointRouteBuilder MapProfileExport(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/profiles/export")
|
||||
.RequireAuthorization()
|
||||
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRead })))
|
||||
.WithTags("Profile Export/Import");
|
||||
|
||||
group.MapPost("/", ExportProfiles)
|
||||
@@ -30,7 +31,7 @@ internal static class ProfileExportEndpoints
|
||||
.Produces<FileContentHttpResult>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
endpoints.MapPost("/api/risk/profiles/import", ImportProfiles)
|
||||
.RequireAuthorization()
|
||||
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyEdit })))
|
||||
.WithName("ImportProfiles")
|
||||
.WithSummary("Import risk profiles from a signed bundle.")
|
||||
.WithTags("Profile Export/Import")
|
||||
@@ -38,7 +39,7 @@ internal static class ProfileExportEndpoints
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
endpoints.MapPost("/api/risk/profiles/verify", VerifyBundle)
|
||||
.RequireAuthorization()
|
||||
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRead })))
|
||||
.WithName("VerifyProfileBundle")
|
||||
.WithSummary("Verify the signature of a profile bundle without importing.")
|
||||
.WithTags("Profile Export/Import")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
@@ -15,7 +16,7 @@ internal static class RiskProfileEndpoints
|
||||
public static IEndpointRouteBuilder MapRiskProfiles(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/profiles")
|
||||
.RequireAuthorization()
|
||||
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRead })))
|
||||
.WithTags("Risk Profiles");
|
||||
|
||||
group.MapGet(string.Empty, ListProfiles)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.RiskProfile.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -19,14 +21,15 @@ internal static class RiskProfileSchemaEndpoints
|
||||
.WithTags("Schema Discovery")
|
||||
.Produces<string>(StatusCodes.Status200OK, contentType: JsonSchemaMediaType)
|
||||
.Produces(StatusCodes.Status304NotModified)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRead })));
|
||||
|
||||
endpoints.MapPost("/api/risk/schema/validate", ValidateProfile)
|
||||
.WithName("ValidateRiskProfile")
|
||||
.WithSummary("Validate a risk profile document against the schema.")
|
||||
.WithTags("Schema Validation")
|
||||
.Produces<RiskProfileValidationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRead })));
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.AirGap.Policy;
|
||||
@@ -289,7 +290,29 @@ builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
|
||||
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer",
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
// IConfiguration binder does not always clear default list values.
|
||||
// When local compose sets Audiences to an empty value, explicitly clear
|
||||
// the audience list so no-aud local tokens can be validated.
|
||||
var audiences = builder.Configuration
|
||||
.GetSection($"{PolicyEngineOptions.SectionName}:ResourceServer:Audiences")
|
||||
.Get<string[]>();
|
||||
if (audiences is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in audiences)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience.Trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Accept self-signed certificates when HTTPS metadata validation is disabled (dev/Docker)
|
||||
if (!bootstrap.Options.ResourceServer.RequireHttpsMetadata)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -26,6 +27,7 @@ using StellaOps.Policy.Snapshots;
|
||||
using StellaOps.Policy.ToolLattice;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@@ -200,7 +202,29 @@ builder.Services.AddSingleton<IToolAccessEvaluator, ToolAccessEvaluator>();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer",
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
// IConfiguration binder does not always clear default list values.
|
||||
// When local compose sets Audiences to an empty value, explicitly clear
|
||||
// the audience list so no-aud local tokens can be validated.
|
||||
var audiences = builder.Configuration
|
||||
.GetSection($"{PolicyGatewayOptions.SectionName}:ResourceServer:Audiences")
|
||||
.Get<string[]>();
|
||||
if (audiences is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in audiences)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience.Trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Accept self-signed certificates when HTTPS metadata validation is disabled (dev/Docker)
|
||||
if (!bootstrap.Options.ResourceServer.RequireHttpsMetadata)
|
||||
@@ -258,6 +282,11 @@ if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
|
||||
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
|
||||
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep DI graph valid when client credentials are disabled.
|
||||
builder.Services.AddSingleton<IStellaOpsTokenClient, DisabledStellaOpsTokenClient>();
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
|
||||
{
|
||||
@@ -295,6 +324,23 @@ app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
app.MapGet("/api/policy/quota", ([FromServices] TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var resetAt = now.Date.AddDays(1).ToString("O", CultureInfo.InvariantCulture);
|
||||
return Results.Ok(new
|
||||
{
|
||||
simulationsPerDay = 1000,
|
||||
simulationsUsed = 0,
|
||||
evaluationsPerDay = 5000,
|
||||
evaluationsUsed = 0,
|
||||
resetAt
|
||||
});
|
||||
})
|
||||
.WithTags("Policy Quota")
|
||||
.WithName("PolicyQuota.Get")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
var policyPacks = app.MapGroup("/api/policy/packs")
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class DisabledStellaOpsTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private const string DisabledMessage = "Policy Engine client credentials are disabled.";
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
|
||||
string username,
|
||||
string password,
|
||||
string? scope = null,
|
||||
IReadOnlyDictionary<string, string>? additionalParameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromException<StellaOpsTokenResult>(new InvalidOperationException(DisabledMessage));
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(
|
||||
string? scope = null,
|
||||
IReadOnlyDictionary<string, string>? additionalParameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromException<StellaOpsTokenResult>(new InvalidOperationException(DisabledMessage));
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromException<JsonWebKeySet>(new InvalidOperationException(DisabledMessage));
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -88,11 +88,22 @@ internal sealed class PolicyEngineTokenProvider
|
||||
}
|
||||
|
||||
var scopeString = BuildScopeClaim(options);
|
||||
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
|
||||
var expiresAt = result.ExpiresAtUtc;
|
||||
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
|
||||
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
|
||||
return cachedToken;
|
||||
try
|
||||
{
|
||||
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
|
||||
var expiresAt = result.ExpiresAtUtc;
|
||||
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
|
||||
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
|
||||
return cachedToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Unable to issue Policy Engine client credentials token for scopes '{Scopes}'.",
|
||||
scopeString);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -120,7 +120,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseResponseCompression();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseRateLimiter();
|
||||
|
||||
@@ -50,7 +50,6 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseHttpsRedirection();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
// Map exploit maturity endpoints
|
||||
|
||||
@@ -161,6 +161,22 @@ builder.TryAddStellaOpsLocalBinding("router");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("router");
|
||||
|
||||
// Force browser traffic onto HTTPS so auth (PKCE/DPoP/WebCrypto) always runs in a secure context.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (!context.Request.IsHttps &&
|
||||
context.Request.Host.HasValue &&
|
||||
!GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
var host = context.Request.Host.Host;
|
||||
var redirect = $"https://{host}{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}";
|
||||
context.Response.Redirect(redirect, permanent: false);
|
||||
return;
|
||||
}
|
||||
|
||||
await next().ConfigureAwait(false);
|
||||
});
|
||||
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
@@ -230,6 +246,15 @@ static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOption
|
||||
// (Authority uses a dev cert in Docker)
|
||||
if (!authOptions.Authority.RequireHttpsMetadata)
|
||||
{
|
||||
// Explicitly configure the named metadata client used by StellaOpsAuthorityConfigurationManager.
|
||||
// ConfigureHttpClientDefaults may not apply to named clients in all .NET versions.
|
||||
builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(clientBuilder =>
|
||||
{
|
||||
clientBuilder.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
|
||||
@@ -66,18 +66,18 @@
|
||||
},
|
||||
"Routes": [
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/evidence-packs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/audit-bundles" },
|
||||
{ "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" },
|
||||
@@ -85,18 +85,20 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/integrations", "TranslatesTo": "http://integrations.stella-ops.local/api/v1/integrations", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "https://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/authority/quotas", "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "http://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "http://authority.stella-ops.local/api/v1/trust", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" },
|
||||
@@ -110,6 +112,7 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/lineage", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/export", "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://exportcenter.stella-ops.local/v1/audit-bundles" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/triage", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/governance", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/determinization", "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization" },
|
||||
@@ -118,25 +121,26 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/sources", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sources" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/workflows", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/witnesses", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/witnesses" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/gate", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/gate", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk-budget", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/fix-verification", "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/compare", "TranslatesTo": "http://sbomservice.stella-ops.local/api/compare" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/change-traces", "TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/exceptions", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/exceptions", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/gateway/rate-limits", "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
|
||||
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
|
||||
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "http://authority.stella-ops.local/authority", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "http://authority.stella-ops.local/console", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/gateway", "TranslatesTo": "http://gateway.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
|
||||
@@ -149,7 +153,7 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "https://vexhub.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
|
||||
|
||||
@@ -406,20 +406,27 @@ if (bootstrapOptions.Authority.Enabled)
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(bootstrapOptions.Authority.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(bootstrapOptions.Authority.TokenClockSkewSeconds);
|
||||
|
||||
// Read collections directly from IConfiguration to work around
|
||||
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
|
||||
var authoritySection = builder.Configuration.GetSection("scanner:Authority");
|
||||
|
||||
var audiences = authoritySection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in bootstrapOptions.Authority.Audiences)
|
||||
foreach (var audience in audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
var requiredScopes = authoritySection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
resourceOptions.RequiredScopes.Clear();
|
||||
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
|
||||
foreach (var scope in requiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
var bypassNetworks = authoritySection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
resourceOptions.BypassNetworks.Clear();
|
||||
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
|
||||
foreach (var network in bypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Jobs;
|
||||
|
||||
using StellaOps.Scanner.Reachability.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// A job request for reachability evidence analysis.
|
||||
/// </summary>
|
||||
@@ -135,6 +137,11 @@ public sealed record ReachabilityJobOptions
|
||||
/// </summary>
|
||||
public bool UseHistoricalRuntimeData { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional runtime witness emission configuration for Layer 3 observations.
|
||||
/// </summary>
|
||||
public RuntimeWitnessEmissionRequest? RuntimeWitnessEmission { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default options for standard analysis.
|
||||
/// </summary>
|
||||
|
||||
@@ -378,7 +378,8 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
ImageDigest = job.ImageDigest,
|
||||
TargetSymbols = targetSymbols,
|
||||
Duration = job.Options.RuntimeObservationDuration ?? TimeSpan.FromMinutes(5),
|
||||
UseHistoricalData = true
|
||||
UseHistoricalData = job.Options.UseHistoricalRuntimeData,
|
||||
WitnessEmission = job.Options.RuntimeWitnessEmission
|
||||
};
|
||||
|
||||
var result = await _runtimeCollector.ObserveAsync(request, ct);
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.Signals.Ebpf.Schema;
|
||||
using StellaOps.Signals.Ebpf.Services;
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Runtime;
|
||||
|
||||
@@ -20,6 +23,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
{
|
||||
private readonly IRuntimeSignalCollector _signalCollector;
|
||||
private readonly IRuntimeObservationStore _observationStore;
|
||||
private readonly IRuntimeWitnessGenerator? _runtimeWitnessGenerator;
|
||||
private readonly ILogger<EbpfRuntimeReachabilityCollector> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -27,12 +31,14 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
IRuntimeSignalCollector signalCollector,
|
||||
IRuntimeObservationStore observationStore,
|
||||
ILogger<EbpfRuntimeReachabilityCollector> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IRuntimeWitnessGenerator? runtimeWitnessGenerator = null)
|
||||
{
|
||||
_signalCollector = signalCollector ?? throw new ArgumentNullException(nameof(signalCollector));
|
||||
_observationStore = observationStore ?? throw new ArgumentNullException(nameof(observationStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_runtimeWitnessGenerator = runtimeWitnessGenerator;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -47,6 +53,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
request.WitnessEmission?.Validate();
|
||||
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
@@ -114,8 +121,14 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
return null;
|
||||
}
|
||||
|
||||
var anyObserved = observations.Any(o => o.WasObserved);
|
||||
var layer3 = BuildLayer3FromObservations(observations, ObservationSource.Historical);
|
||||
var btfSelection = _signalCollector.GetBtfSelection();
|
||||
var witness = await TryGenerateRuntimeWitnessAsync(
|
||||
request,
|
||||
observations,
|
||||
ObservationSource.Historical,
|
||||
observedAt: _timeProvider.GetUtcNow(),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return new RuntimeReachabilityResult
|
||||
{
|
||||
@@ -124,7 +137,9 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
Observations = observations,
|
||||
ObservedAt = _timeProvider.GetUtcNow(),
|
||||
Duration = TimeSpan.Zero,
|
||||
Source = ObservationSource.Historical
|
||||
Source = ObservationSource.Historical,
|
||||
BtfSelection = btfSelection,
|
||||
Witness = witness,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,6 +156,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
ResolveSymbols = true,
|
||||
MaxEventsPerSecond = 5000
|
||||
};
|
||||
RuntimeSignalSummary? summary = null;
|
||||
|
||||
var handle = await _signalCollector.StartCollectionAsync(
|
||||
request.ContainerId, options, ct);
|
||||
@@ -167,7 +183,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
finally
|
||||
{
|
||||
// Always stop collection
|
||||
var summary = await _signalCollector.StopCollectionAsync(handle, ct);
|
||||
summary = await _signalCollector.StopCollectionAsync(handle, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Stopped eBPF signal collection: {TotalEvents} events, {UniqueSymbols} symbols observed",
|
||||
@@ -182,6 +198,12 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
|
||||
var layer3 = BuildLayer3FromObservations(observations, ObservationSource.Live);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
var witness = await TryGenerateRuntimeWitnessAsync(
|
||||
request,
|
||||
observations,
|
||||
ObservationSource.Live,
|
||||
observedAt: startTime,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return new RuntimeReachabilityResult
|
||||
{
|
||||
@@ -190,7 +212,9 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
Observations = observations,
|
||||
ObservedAt = startTime,
|
||||
Duration = duration,
|
||||
Source = ObservationSource.Live
|
||||
Source = ObservationSource.Live,
|
||||
BtfSelection = summary?.BtfSelection ?? _signalCollector.GetBtfSelection(),
|
||||
Witness = witness,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,7 +276,8 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
ObservedAt = startTime,
|
||||
Duration = TimeSpan.Zero,
|
||||
Error = reason,
|
||||
Source = ObservationSource.None
|
||||
Source = ObservationSource.None,
|
||||
BtfSelection = _signalCollector.GetBtfSelection(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,9 +299,170 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
|
||||
ObservedAt = startTime,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime,
|
||||
Error = error,
|
||||
Source = ObservationSource.None
|
||||
Source = ObservationSource.None,
|
||||
BtfSelection = _signalCollector.GetBtfSelection(),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RuntimeWitnessResult?> TryGenerateRuntimeWitnessAsync(
|
||||
RuntimeObservationRequest request,
|
||||
IReadOnlyList<SymbolObservation> observations,
|
||||
ObservationSource source,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_runtimeWitnessGenerator is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var emission = request.WitnessEmission;
|
||||
if (emission is null || !emission.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtimeObservations = ToRuntimeObservations(
|
||||
observations,
|
||||
request.ContainerId,
|
||||
source,
|
||||
observedAt);
|
||||
|
||||
if (runtimeObservations.Count == 0)
|
||||
{
|
||||
return RuntimeWitnessResult.Failed(
|
||||
emission.ClaimId ?? string.Empty,
|
||||
"No runtime observations available for witness generation.");
|
||||
}
|
||||
|
||||
var claimId = string.IsNullOrWhiteSpace(emission.ClaimId)
|
||||
? ClaimIdGenerator.Generate(
|
||||
request.ImageDigest,
|
||||
ComputeRuntimePathHash(runtimeObservations))
|
||||
: emission.ClaimId!;
|
||||
|
||||
var witnessRequest = new RuntimeWitnessRequest
|
||||
{
|
||||
ClaimId = claimId,
|
||||
ArtifactDigest = request.ImageDigest,
|
||||
ComponentPurl = emission.ComponentPurl,
|
||||
VulnerabilityId = emission.VulnerabilityId,
|
||||
Observations = runtimeObservations,
|
||||
Symbolization = emission.Symbolization,
|
||||
PublishToRekor = emission.PublishToRekor,
|
||||
SigningOptions = emission.SigningOptions
|
||||
};
|
||||
|
||||
var result = await _runtimeWitnessGenerator.GenerateAsync(witnessRequest, ct).ConfigureAwait(false);
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Runtime witness generation failed for container {ContainerId}: {Error}",
|
||||
request.ContainerId,
|
||||
result.ErrorMessage);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or FormatException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Runtime witness generation configuration invalid for container {ContainerId}",
|
||||
request.ContainerId);
|
||||
return RuntimeWitnessResult.Failed(
|
||||
emission.ClaimId ?? string.Empty,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RuntimeObservation> ToRuntimeObservations(
|
||||
IReadOnlyList<SymbolObservation> observations,
|
||||
string containerId,
|
||||
ObservationSource source,
|
||||
DateTimeOffset fallbackObservedAt)
|
||||
{
|
||||
return observations
|
||||
.Where(static observation => observation.WasObserved && observation.ObservationCount > 0)
|
||||
.OrderBy(static observation => observation.Symbol, StringComparer.Ordinal)
|
||||
.ThenBy(static observation => observation.FirstObservedAt ?? DateTimeOffset.MinValue)
|
||||
.ThenBy(static observation => observation.LastObservedAt ?? DateTimeOffset.MinValue)
|
||||
.Select(observation =>
|
||||
{
|
||||
var observedAt = observation.LastObservedAt
|
||||
?? observation.FirstObservedAt
|
||||
?? fallbackObservedAt;
|
||||
|
||||
var durationMicros = observation.FirstObservedAt.HasValue
|
||||
&& observation.LastObservedAt.HasValue
|
||||
&& observation.LastObservedAt >= observation.FirstObservedAt
|
||||
? (long?)(observation.LastObservedAt.Value - observation.FirstObservedAt.Value).TotalMilliseconds * 1000L
|
||||
: null;
|
||||
|
||||
return new RuntimeObservation
|
||||
{
|
||||
ObservedAt = observedAt,
|
||||
ObservationCount = Math.Max(1, observation.ObservationCount),
|
||||
StackSampleHash = ComputeStackSampleHash(observation),
|
||||
ProcessId = null,
|
||||
ContainerId = containerId,
|
||||
PodName = null,
|
||||
Namespace = null,
|
||||
SourceType = source == ObservationSource.Live
|
||||
? RuntimeObservationSourceType.Tetragon
|
||||
: RuntimeObservationSourceType.Custom,
|
||||
ObservationId = ComputeObservationId(observation),
|
||||
DurationMicroseconds = durationMicros
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string ComputeRuntimePathHash(IReadOnlyList<RuntimeObservation> observations)
|
||||
{
|
||||
var seed = string.Join(
|
||||
"\n",
|
||||
observations
|
||||
.OrderBy(static observation => observation.ObservedAt)
|
||||
.ThenBy(static observation => observation.ObservationId ?? string.Empty, StringComparer.Ordinal)
|
||||
.Select(static observation => $"{observation.StackSampleHash}|{observation.ObservationCount}|{observation.ObservedAt:O}"));
|
||||
|
||||
return ComputeSha256Hex(seed);
|
||||
}
|
||||
|
||||
private static string ComputeObservationId(SymbolObservation observation)
|
||||
{
|
||||
var input = string.Join("|",
|
||||
observation.Symbol,
|
||||
observation.FirstObservedAt?.ToUniversalTime().ToString("O") ?? string.Empty,
|
||||
observation.LastObservedAt?.ToUniversalTime().ToString("O") ?? string.Empty,
|
||||
observation.ObservationCount.ToString(),
|
||||
string.Join(";", observation.Paths
|
||||
.OrderBy(static path => string.Join(">", path.Symbols), StringComparer.Ordinal)
|
||||
.Select(static path => $"{string.Join(">", path.Symbols)}:{path.Count}")));
|
||||
|
||||
return $"obs:{ComputeSha256Hex(input)}";
|
||||
}
|
||||
|
||||
private static string ComputeStackSampleHash(SymbolObservation observation)
|
||||
{
|
||||
var seed = string.Join("|",
|
||||
observation.Symbol,
|
||||
observation.ObservationCount.ToString(),
|
||||
string.Join(";", observation.Paths
|
||||
.OrderBy(static path => string.Join(">", path.Symbols), StringComparer.Ordinal)
|
||||
.Select(static path => $"{string.Join(">", path.Symbols)}:{path.Count}")));
|
||||
|
||||
return $"sha256:{ComputeSha256Hex(seed)}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string input)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// Sprint: EVID-001-004 - Runtime Reachability Collection
|
||||
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.Signals.Ebpf.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Runtime;
|
||||
|
||||
@@ -84,6 +86,72 @@ public sealed record RuntimeObservationRequest
|
||||
/// Time window for historical data lookup.
|
||||
/// </summary>
|
||||
public TimeSpan HistoricalWindow { get; init; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Optional runtime witness emission settings.
|
||||
/// When provided and enabled, collector attempts witness generation from observed symbols.
|
||||
/// </summary>
|
||||
public RuntimeWitnessEmissionRequest? WitnessEmission { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional witness emission settings for runtime observation requests.
|
||||
/// </summary>
|
||||
public sealed record RuntimeWitnessEmissionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables runtime witness generation for this observation request.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional claim ID linking runtime witness to static claim.
|
||||
/// When omitted, the collector generates a deterministic claim ID from runtime observations.
|
||||
/// </summary>
|
||||
public string? ClaimId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL associated with observed runtime symbols.
|
||||
/// </summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional vulnerability identifier for witness context.
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic symbolization tuple required for runtime witnesses.
|
||||
/// </summary>
|
||||
public required WitnessSymbolization Symbolization { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether witness generation should publish to Rekor when configured.
|
||||
/// </summary>
|
||||
public bool PublishToRekor { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Signing options for runtime witness generation.
|
||||
/// </summary>
|
||||
public RuntimeWitnessSigningOptions SigningOptions { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Validates witness emission configuration.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ComponentPurl))
|
||||
{
|
||||
throw new ArgumentException("ComponentPurl is required for runtime witness emission.", nameof(ComponentPurl));
|
||||
}
|
||||
|
||||
Symbolization.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -125,6 +193,16 @@ public sealed record RuntimeReachabilityResult
|
||||
/// Source of the data (live, historical, none).
|
||||
/// </summary>
|
||||
public ObservationSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic BTF source metadata used by runtime collector.
|
||||
/// </summary>
|
||||
public RuntimeBtfSelection? BtfSelection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime witness generation result, if witness emission was requested.
|
||||
/// </summary>
|
||||
public RuntimeWitnessResult? Witness { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Binary;
|
||||
using StellaOps.Scanner.Reachability.Jobs;
|
||||
using StellaOps.Scanner.Reachability.Runtime;
|
||||
using StellaOps.Scanner.Reachability.Services;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.Scanner.Reachability.Vex;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
@@ -56,6 +58,18 @@ public static class ServiceCollectionExtensions
|
||||
// Runtime Collection (optional - requires eBPF infrastructure; null default for environments without eBPF)
|
||||
services.TryAddSingleton<IRuntimeReachabilityCollector, NullRuntimeReachabilityCollector>();
|
||||
|
||||
// Runtime Witness Generation (deterministic runtime witness profile)
|
||||
services.TryAddSingleton<IWitnessDsseSigner, WitnessDsseSigner>();
|
||||
services.TryAddSingleton<IRuntimeWitnessSigningKeyProvider, NullRuntimeWitnessSigningKeyProvider>();
|
||||
services.TryAddSingleton<IRuntimeWitnessStorage>(sp =>
|
||||
{
|
||||
var cas = sp.GetService<IFileContentAddressableStore>();
|
||||
return cas is null
|
||||
? new NullRuntimeWitnessStorage()
|
||||
: new CasRuntimeWitnessStorage(cas);
|
||||
});
|
||||
services.TryAddSingleton<IRuntimeWitnessGenerator, RuntimeWitnessGenerator>();
|
||||
|
||||
// Binary Patch Verification (requires Ghidra infrastructure; null default for environments without Ghidra)
|
||||
services.TryAddSingleton<IBinaryPatchVerifier, NullBinaryPatchVerifier>();
|
||||
|
||||
@@ -82,6 +96,16 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<IEvidenceStorageService, NullEvidenceStorageService>();
|
||||
services.TryAddScoped<IReachabilityEvidenceJobExecutor, ReachabilityEvidenceJobExecutor>();
|
||||
services.TryAddSingleton<IRuntimeReachabilityCollector, NullRuntimeReachabilityCollector>();
|
||||
services.TryAddSingleton<IWitnessDsseSigner, WitnessDsseSigner>();
|
||||
services.TryAddSingleton<IRuntimeWitnessSigningKeyProvider, NullRuntimeWitnessSigningKeyProvider>();
|
||||
services.TryAddSingleton<IRuntimeWitnessStorage>(sp =>
|
||||
{
|
||||
var cas = sp.GetService<IFileContentAddressableStore>();
|
||||
return cas is null
|
||||
? new NullRuntimeWitnessStorage()
|
||||
: new CasRuntimeWitnessStorage(cas);
|
||||
});
|
||||
services.TryAddSingleton<IRuntimeWitnessGenerator, RuntimeWitnessGenerator>();
|
||||
services.TryAddSingleton<IBinaryPatchVerifier, NullBinaryPatchVerifier>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -151,6 +151,11 @@ public interface IRuntimeWitnessContextProvider
|
||||
/// </summary>
|
||||
string? GetVulnerabilityId(RuntimeObservation observation);
|
||||
|
||||
/// <summary>
|
||||
/// Gets deterministic symbolization metadata for the observation.
|
||||
/// </summary>
|
||||
WitnessSymbolization? GetSymbolization(RuntimeObservation observation);
|
||||
|
||||
/// <summary>
|
||||
/// Gets signing options.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves signing keys for runtime witness generation.
|
||||
/// </summary>
|
||||
public interface IRuntimeWitnessSigningKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to resolve a signing key for the provided options.
|
||||
/// </summary>
|
||||
bool TryResolveSigningKey(
|
||||
RuntimeWitnessSigningOptions options,
|
||||
out EnvelopeKey? signingKey,
|
||||
out string? errorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default signing key provider used when no signing key source is configured.
|
||||
/// </summary>
|
||||
public sealed class NullRuntimeWitnessSigningKeyProvider : IRuntimeWitnessSigningKeyProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool TryResolveSigningKey(
|
||||
RuntimeWitnessSigningOptions options,
|
||||
out EnvelopeKey? signingKey,
|
||||
out string? errorMessage)
|
||||
{
|
||||
signingKey = null;
|
||||
errorMessage = "Runtime witness signing key is not configured.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Persists runtime witness artifacts for replay and audit.
|
||||
/// </summary>
|
||||
public interface IRuntimeWitnessStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores runtime witness artifacts and returns a primary artifact URI.
|
||||
/// </summary>
|
||||
Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage request containing canonical payload and envelope artifacts.
|
||||
/// </summary>
|
||||
/// <param name="Witness">Runtime witness document.</param>
|
||||
/// <param name="PayloadBytes">Canonical witness JSON bytes.</param>
|
||||
/// <param name="EnvelopeBytes">Serialized DSSE envelope bytes.</param>
|
||||
public sealed record RuntimeWitnessStorageRequest(
|
||||
PathWitness Witness,
|
||||
byte[] PayloadBytes,
|
||||
byte[] EnvelopeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// No-op witness storage implementation.
|
||||
/// </summary>
|
||||
public sealed class NullRuntimeWitnessStorage : IRuntimeWitnessStorage
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores runtime witness artifacts in CAS using deterministic SHA-256 keys.
|
||||
/// </summary>
|
||||
public sealed class CasRuntimeWitnessStorage : IRuntimeWitnessStorage
|
||||
{
|
||||
private readonly IFileContentAddressableStore _cas;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CAS-backed runtime witness storage.
|
||||
/// </summary>
|
||||
public CasRuntimeWitnessStorage(IFileContentAddressableStore cas)
|
||||
{
|
||||
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var witnessSha = ComputeSha256Hex(request.PayloadBytes);
|
||||
var envelopeSha = ComputeSha256Hex(request.EnvelopeBytes);
|
||||
|
||||
await PutIfMissingAsync(witnessSha, request.PayloadBytes, ct).ConfigureAwait(false);
|
||||
await PutIfMissingAsync(envelopeSha, request.EnvelopeBytes, ct).ConfigureAwait(false);
|
||||
|
||||
return $"cas://runtime-witness/dsse/{envelopeSha}";
|
||||
}
|
||||
|
||||
private async Task PutIfMissingAsync(string sha256, byte[] content, CancellationToken ct)
|
||||
{
|
||||
var existing = await _cas.TryGetAsync(sha256, ct).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var stream = new MemoryStream(content, writable: false);
|
||||
await _cas.PutAsync(new FileCasPutRequest(sha256, stream, leaveOpen: false), ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,36 @@ public sealed record PathWitness
|
||||
/// </summary>
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<RuntimeObservation>? Observations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic symbolization tuple used to reproduce runtime frames.
|
||||
/// Required for runtime/confirmed witnesses.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolization")]
|
||||
public WitnessSymbolization? Symbolization { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates deterministic symbolization requirements for runtime witnesses.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If required symbolization inputs are missing.</exception>
|
||||
public void ValidateDeterministicSymbolization()
|
||||
{
|
||||
var requiresRuntimeSymbolization = ObservationType != ObservationType.Static
|
||||
|| (Observations is not null && Observations.Count > 0);
|
||||
|
||||
if (!requiresRuntimeSymbolization)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Symbolization is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Runtime witness is missing required symbolization tuple.");
|
||||
}
|
||||
|
||||
Symbolization.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -337,3 +367,127 @@ public sealed record WitnessEvidence
|
||||
[JsonPropertyName("build_id")]
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic symbolization tuple required for runtime witness replay.
|
||||
/// </summary>
|
||||
public sealed record WitnessSymbolization
|
||||
{
|
||||
/// <summary>
|
||||
/// Build ID of the observed userspace binary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("build_id")]
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI to debug artifact for symbolization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("debug_artifact_uri")]
|
||||
public string? DebugArtifactUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI to symbol table material for symbolization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol_table_uri")]
|
||||
public string? SymbolTableUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbolizer identity, version, and digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolizer")]
|
||||
public required WitnessSymbolizer Symbolizer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// libc variant used during symbolization (for example glibc or musl).
|
||||
/// </summary>
|
||||
[JsonPropertyName("libc_variant")]
|
||||
public required string LibcVariant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the sysroot used by symbolization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sysroot_digest")]
|
||||
public required string SysrootDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates required deterministic symbolization fields.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If required fields are missing.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BuildId))
|
||||
{
|
||||
throw new InvalidOperationException("Symbolization requires non-empty build_id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DebugArtifactUri)
|
||||
&& string.IsNullOrWhiteSpace(SymbolTableUri))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Symbolization requires at least one of debug_artifact_uri or symbol_table_uri.");
|
||||
}
|
||||
|
||||
if (Symbolizer is null)
|
||||
{
|
||||
throw new InvalidOperationException("Symbolization requires symbolizer metadata.");
|
||||
}
|
||||
|
||||
Symbolizer.Validate();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(LibcVariant))
|
||||
{
|
||||
throw new InvalidOperationException("Symbolization requires non-empty libc_variant.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SysrootDigest))
|
||||
{
|
||||
throw new InvalidOperationException("Symbolization requires non-empty sysroot_digest.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity details for the symbolizer tool used to resolve frames.
|
||||
/// </summary>
|
||||
public sealed record WitnessSymbolizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbolizer tool name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbolizer tool version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the symbolizer binary or package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates required symbolizer identity fields.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If required fields are missing.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new InvalidOperationException("Symbolization requires symbolizer.name.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Version))
|
||||
{
|
||||
throw new InvalidOperationException("Symbolization requires symbolizer.version.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Digest))
|
||||
{
|
||||
throw new InvalidOperationException("Symbolization requires symbolizer.digest.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Canonical.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Generates DSSE-signed runtime witnesses from runtime observations.
|
||||
/// </summary>
|
||||
public sealed class RuntimeWitnessGenerator : IRuntimeWitnessGenerator
|
||||
{
|
||||
private const int MaxPathSteps = 32;
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
private readonly IWitnessDsseSigner _signer;
|
||||
private readonly IRuntimeWitnessSigningKeyProvider _signingKeyProvider;
|
||||
private readonly IRuntimeWitnessStorage _storage;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a runtime witness generator.
|
||||
/// </summary>
|
||||
public RuntimeWitnessGenerator(
|
||||
IWitnessDsseSigner signer,
|
||||
IRuntimeWitnessSigningKeyProvider signingKeyProvider,
|
||||
IRuntimeWitnessStorage storage,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_signingKeyProvider = signingKeyProvider ?? throw new ArgumentNullException(nameof(signingKeyProvider));
|
||||
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuntimeWitnessResult> GenerateAsync(
|
||||
RuntimeWitnessRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
request.Validate();
|
||||
|
||||
if (!_signingKeyProvider.TryResolveSigningKey(
|
||||
request.SigningOptions,
|
||||
out var signingKey,
|
||||
out var keyError)
|
||||
|| signingKey is null)
|
||||
{
|
||||
return RuntimeWitnessResult.Failed(
|
||||
request.ClaimId,
|
||||
keyError ?? "No runtime witness signing key was resolved.");
|
||||
}
|
||||
|
||||
var canonicalObservations = CanonicalizeObservations(request.Observations);
|
||||
var witness = BuildRuntimeWitness(request, canonicalObservations);
|
||||
|
||||
var signResult = _signer.SignWitness(witness, signingKey, ct);
|
||||
if (!signResult.IsSuccess || signResult.Envelope is null || signResult.PayloadBytes is null)
|
||||
{
|
||||
return RuntimeWitnessResult.Failed(
|
||||
request.ClaimId,
|
||||
signResult.Error ?? "Runtime witness signing failed.");
|
||||
}
|
||||
|
||||
var envelopeBytes = DsseEnvelopeSerializer.Serialize(
|
||||
signResult.Envelope,
|
||||
new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false
|
||||
})
|
||||
.CompactJson ?? Array.Empty<byte>();
|
||||
|
||||
var casUri = await _storage.StoreAsync(
|
||||
new RuntimeWitnessStorageRequest(witness, signResult.PayloadBytes, envelopeBytes),
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return RuntimeWitnessResult.Successful(
|
||||
witness,
|
||||
envelopeBytes,
|
||||
casUri: casUri);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException or InvalidOperationException)
|
||||
{
|
||||
return RuntimeWitnessResult.Failed(request.ClaimId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateBatchAsync(
|
||||
BatchRuntimeWitnessRequest request,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
request.Validate();
|
||||
|
||||
var ordered = request.Requests
|
||||
.OrderBy(static r => r.ClaimId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var item in ordered)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await GenerateAsync(item, ct).ConfigureAwait(false);
|
||||
yield return result;
|
||||
|
||||
if (!result.Success && !request.ContinueOnError)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateFromStreamAsync(
|
||||
IAsyncEnumerable<RuntimeObservation> observations,
|
||||
IRuntimeWitnessContextProvider contextProvider,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observations);
|
||||
ArgumentNullException.ThrowIfNull(contextProvider);
|
||||
|
||||
var byClaim = new Dictionary<string, List<RuntimeObservation>>(StringComparer.Ordinal);
|
||||
|
||||
await foreach (var observation in observations.WithCancellation(ct).ConfigureAwait(false))
|
||||
{
|
||||
var claimId = contextProvider.GetClaimId(observation);
|
||||
if (!byClaim.TryGetValue(claimId, out var buffer))
|
||||
{
|
||||
buffer = new List<RuntimeObservation>();
|
||||
byClaim[claimId] = buffer;
|
||||
}
|
||||
|
||||
buffer.Add(observation);
|
||||
}
|
||||
|
||||
foreach (var claim in byClaim.OrderBy(static kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var first = claim.Value[0];
|
||||
var request = new RuntimeWitnessRequest
|
||||
{
|
||||
ClaimId = claim.Key,
|
||||
ArtifactDigest = contextProvider.GetArtifactDigest(),
|
||||
ComponentPurl = contextProvider.GetComponentPurl(first),
|
||||
VulnerabilityId = contextProvider.GetVulnerabilityId(first),
|
||||
Observations = claim.Value,
|
||||
Symbolization = contextProvider.GetSymbolization(first),
|
||||
SigningOptions = contextProvider.GetSigningOptions()
|
||||
};
|
||||
|
||||
yield return await GenerateAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private PathWitness BuildRuntimeWitness(
|
||||
RuntimeWitnessRequest request,
|
||||
IReadOnlyList<RuntimeObservation> canonicalObservations)
|
||||
{
|
||||
var (claimArtifactDigest, claimPathHash) = ClaimIdGenerator.Parse(request.ClaimId);
|
||||
|
||||
var observedAt = canonicalObservations.Count > 0
|
||||
? canonicalObservations[0].ObservedAt
|
||||
: _timeProvider.GetUtcNow();
|
||||
|
||||
var pathSteps = BuildPathSteps(canonicalObservations);
|
||||
var sink = pathSteps[^1];
|
||||
var entrypoint = pathSteps[0];
|
||||
var nodeHashes = BuildNodeHashes(canonicalObservations);
|
||||
var runtimeDigest = ComputeRuntimeDigest(canonicalObservations);
|
||||
|
||||
var witness = new PathWitness
|
||||
{
|
||||
WitnessId = string.Empty,
|
||||
Artifact = new WitnessArtifact
|
||||
{
|
||||
SbomDigest = request.ArtifactDigest,
|
||||
ComponentPurl = request.ComponentPurl
|
||||
},
|
||||
Vuln = new WitnessVuln
|
||||
{
|
||||
Id = request.VulnerabilityId ?? "runtime-observation",
|
||||
Source = request.VulnerabilityId is null ? "runtime" : "scanner",
|
||||
AffectedRange = "unknown"
|
||||
},
|
||||
Entrypoint = new WitnessEntrypoint
|
||||
{
|
||||
Kind = "runtime",
|
||||
Name = entrypoint.Symbol,
|
||||
SymbolId = entrypoint.SymbolId
|
||||
},
|
||||
Path = pathSteps,
|
||||
Sink = new WitnessSink
|
||||
{
|
||||
Symbol = sink.Symbol,
|
||||
SymbolId = sink.SymbolId,
|
||||
SinkType = "runtime-observed"
|
||||
},
|
||||
Evidence = new WitnessEvidence
|
||||
{
|
||||
CallgraphDigest = $"sha256:{runtimeDigest}",
|
||||
AnalysisConfigDigest = $"sha256:{ComputeSigningOptionsDigest(request.SigningOptions)}",
|
||||
BuildId = request.Symbolization?.BuildId
|
||||
},
|
||||
ObservedAt = observedAt,
|
||||
PredicateType = RuntimeWitnessPredicateTypes.RuntimeWitnessCanonical,
|
||||
ObservationType = ObservationType.Runtime,
|
||||
ClaimId = request.ClaimId,
|
||||
Observations = canonicalObservations,
|
||||
Symbolization = request.Symbolization,
|
||||
PathHash = claimPathHash,
|
||||
NodeHashes = nodeHashes,
|
||||
EvidenceUris = BuildEvidenceUris(request, claimArtifactDigest)
|
||||
};
|
||||
|
||||
var witnessId = ComputeWitnessId(witness);
|
||||
return witness with { WitnessId = witnessId };
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PathStep> BuildPathSteps(IReadOnlyList<RuntimeObservation> canonicalObservations)
|
||||
{
|
||||
var list = new List<PathStep>(Math.Min(MaxPathSteps, canonicalObservations.Count));
|
||||
|
||||
for (var i = 0; i < canonicalObservations.Count && i < MaxPathSteps; i++)
|
||||
{
|
||||
var observation = canonicalObservations[i];
|
||||
var symbolId = BuildStepSymbolId(observation, i);
|
||||
|
||||
list.Add(new PathStep
|
||||
{
|
||||
Symbol = BuildStepSymbol(observation, i),
|
||||
SymbolId = symbolId
|
||||
});
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
list.Add(new PathStep
|
||||
{
|
||||
Symbol = "runtime-observation",
|
||||
SymbolId = "runtime:observation:0000"
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildNodeHashes(IReadOnlyList<RuntimeObservation> observations)
|
||||
{
|
||||
var hashes = observations
|
||||
.Select(static o => o.StackSampleHash)
|
||||
.Where(static h => !string.IsNullOrWhiteSpace(h))
|
||||
.Select(static h => h!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (hashes.Count > 0)
|
||||
{
|
||||
return hashes;
|
||||
}
|
||||
|
||||
return [$"sha256:{ComputeRuntimeDigest(observations)}"];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildEvidenceUris(RuntimeWitnessRequest request, string claimArtifactDigest)
|
||||
{
|
||||
var uris = new List<string>
|
||||
{
|
||||
$"claim:{request.ClaimId}",
|
||||
$"artifact:{claimArtifactDigest}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Symbolization?.DebugArtifactUri))
|
||||
{
|
||||
uris.Add(request.Symbolization.DebugArtifactUri!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Symbolization?.SymbolTableUri))
|
||||
{
|
||||
uris.Add(request.Symbolization.SymbolTableUri!);
|
||||
}
|
||||
|
||||
return uris
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string BuildStepSymbol(RuntimeObservation observation, int index)
|
||||
{
|
||||
var id = observation.ObservationId;
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return $"runtime:{id}";
|
||||
}
|
||||
|
||||
var stack = observation.StackSampleHash;
|
||||
if (!string.IsNullOrWhiteSpace(stack))
|
||||
{
|
||||
return $"runtime:{stack}";
|
||||
}
|
||||
|
||||
return $"runtime:frame:{index:D4}";
|
||||
}
|
||||
|
||||
private static string BuildStepSymbolId(RuntimeObservation observation, int index)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(observation.ObservationId))
|
||||
{
|
||||
return $"runtime:obs:{observation.ObservationId!.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(observation.StackSampleHash))
|
||||
{
|
||||
return $"runtime:stack:{observation.StackSampleHash!.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
return $"runtime:observation:{index:D4}";
|
||||
}
|
||||
|
||||
private static string ComputeWitnessId(PathWitness witness)
|
||||
{
|
||||
var canonical = new
|
||||
{
|
||||
witness.WitnessSchema,
|
||||
witness.Artifact,
|
||||
witness.Vuln,
|
||||
witness.Entrypoint,
|
||||
witness.Path,
|
||||
witness.Sink,
|
||||
witness.Evidence,
|
||||
witness.ObservedAt,
|
||||
witness.PathHash,
|
||||
witness.NodeHashes,
|
||||
witness.EvidenceUris,
|
||||
witness.PredicateType,
|
||||
witness.ObservationType,
|
||||
witness.ClaimId,
|
||||
witness.Observations,
|
||||
witness.Symbolization
|
||||
};
|
||||
|
||||
var canonicalBytes = CanonJson.Canonicalize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(canonicalBytes);
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
return $"{WitnessSchema.WitnessIdPrefix}sha256:{hex}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RuntimeObservation> CanonicalizeObservations(
|
||||
IReadOnlyList<RuntimeObservation> observations)
|
||||
{
|
||||
return observations
|
||||
.Select(static observation => observation with
|
||||
{
|
||||
ObservedAt = observation.ObservedAt.ToUniversalTime(),
|
||||
ObservationCount = Math.Max(1, observation.ObservationCount),
|
||||
StackSampleHash = NormalizeOptionalHash(observation.StackSampleHash),
|
||||
ContainerId = NormalizeOptionalText(observation.ContainerId),
|
||||
PodName = NormalizeOptionalText(observation.PodName),
|
||||
Namespace = NormalizeOptionalText(observation.Namespace),
|
||||
ObservationId = NormalizeOptionalText(observation.ObservationId)
|
||||
})
|
||||
.OrderBy(static o => o.ObservedAt)
|
||||
.ThenBy(static o => o.ObservationId ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static o => o.StackSampleHash ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static o => o.ProcessId ?? int.MinValue)
|
||||
.ThenBy(static o => o.ContainerId ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static o => o.Namespace ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static o => o.PodName ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static o => o.SourceType)
|
||||
.ThenBy(static o => o.DurationMicroseconds ?? long.MinValue)
|
||||
.ThenBy(static o => o.ObservationCount)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeOptionalText(string? value)
|
||||
{
|
||||
return value is null ? string.Empty : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalHash(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeRuntimeDigest(IReadOnlyList<RuntimeObservation> canonicalObservations)
|
||||
{
|
||||
var lines = canonicalObservations
|
||||
.Select(static observation => string.Join("|",
|
||||
observation.ObservedAt.ToUniversalTime().ToString("O"),
|
||||
observation.ObservationId ?? string.Empty,
|
||||
observation.StackSampleHash ?? string.Empty,
|
||||
observation.ObservationCount.ToString(),
|
||||
observation.ProcessId?.ToString() ?? string.Empty,
|
||||
observation.ContainerId ?? string.Empty,
|
||||
observation.Namespace ?? string.Empty,
|
||||
observation.PodName ?? string.Empty,
|
||||
((int)observation.SourceType).ToString(),
|
||||
observation.DurationMicroseconds?.ToString() ?? string.Empty))
|
||||
.ToList();
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(string.Join('\n', lines));
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSigningOptionsDigest(RuntimeWitnessSigningOptions options)
|
||||
{
|
||||
var canonical = string.Join('|',
|
||||
options.KeyId ?? string.Empty,
|
||||
options.UseKeyless.ToString(),
|
||||
options.Algorithm,
|
||||
options.Timeout.TotalMilliseconds.ToString("F0"));
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@ public sealed record RuntimeWitnessRequest
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RuntimeObservation> Observations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic symbolization tuple required for replayable runtime witnesses.
|
||||
/// </summary>
|
||||
public WitnessSymbolization? Symbolization { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID if observing a vulnerable path.
|
||||
/// </summary>
|
||||
@@ -70,6 +75,11 @@ public sealed record RuntimeWitnessRequest
|
||||
|
||||
if (Observations == null || Observations.Count == 0)
|
||||
throw new ArgumentException("At least one observation is required.", nameof(Observations));
|
||||
|
||||
if (Symbolization is null)
|
||||
throw new ArgumentException("Symbolization is required for runtime witness generation.", nameof(Symbolization));
|
||||
|
||||
Symbolization.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
|
||||
try
|
||||
{
|
||||
// Enforce deterministic runtime symbolization requirements before signing.
|
||||
witness.ValidateDeterministicSymbolization();
|
||||
|
||||
// Serialize witness to canonical JSON bytes
|
||||
var payloadBytes = CanonJson.Canonicalize(witness, CanonicalJsonOptions);
|
||||
|
||||
@@ -106,6 +109,9 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
return WitnessVerifyResult.Failure($"Unsupported witness schema: {witness.WitnessSchema}");
|
||||
}
|
||||
|
||||
// Runtime witnesses must carry deterministic symbolization inputs.
|
||||
witness.ValidateDeterministicSymbolization();
|
||||
|
||||
// Find signature matching the public key
|
||||
var matchingSignature = envelope.Signatures.FirstOrDefault(
|
||||
s => string.Equals(s.KeyId, publicKey.KeyId, StringComparison.Ordinal));
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Reachability.Runtime;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Signals.Ebpf.Schema;
|
||||
using StellaOps.Signals.Ebpf.Services;
|
||||
using StellaOps.TestKit;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Evidence;
|
||||
@@ -88,6 +90,8 @@ public sealed class RuntimeReachabilityCollectorTests
|
||||
Assert.Equal(ObservationSource.Historical, result.Source);
|
||||
Assert.Single(result.Observations);
|
||||
Assert.True(result.Observations[0].WasObserved);
|
||||
Assert.NotNull(result.BtfSelection);
|
||||
Assert.Equal("kernel", result.BtfSelection!.SourceKind);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -217,11 +221,88 @@ public sealed class RuntimeReachabilityCollectorTests
|
||||
Assert.NotNull(result.Error);
|
||||
Assert.Equal(GatingOutcome.Unknown, result.Layer3.Outcome);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ObserveAsync_WithWitnessEmission_InvokesRuntimeWitnessGenerator()
|
||||
{
|
||||
var observations = new List<SymbolObservation>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Symbol = "target_sink",
|
||||
WasObserved = true,
|
||||
ObservationCount = 2,
|
||||
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-3),
|
||||
LastObservedAt = DateTimeOffset.UtcNow.AddMinutes(-1)
|
||||
}
|
||||
};
|
||||
|
||||
_observationStore.SetObservations("container-wit", observations);
|
||||
var witnessGenerator = new MockRuntimeWitnessGenerator();
|
||||
var collector = new EbpfRuntimeReachabilityCollector(
|
||||
_signalCollector,
|
||||
_observationStore,
|
||||
NullLogger<EbpfRuntimeReachabilityCollector>.Instance,
|
||||
_timeProvider,
|
||||
witnessGenerator);
|
||||
|
||||
var request = new RuntimeObservationRequest
|
||||
{
|
||||
ContainerId = "container-wit",
|
||||
ImageDigest = "sha256:img123",
|
||||
TargetSymbols = ["target_sink"],
|
||||
UseHistoricalData = true,
|
||||
WitnessEmission = new RuntimeWitnessEmissionRequest
|
||||
{
|
||||
Enabled = true,
|
||||
ComponentPurl = "pkg:oci/demo@sha256:img123",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
Symbolization = new WitnessSymbolization
|
||||
{
|
||||
BuildId = "gnu-build-id:test",
|
||||
DebugArtifactUri = "cas://symbols/test.debug",
|
||||
Symbolizer = new WitnessSymbolizer
|
||||
{
|
||||
Name = "llvm-symbolizer",
|
||||
Version = "18.1.7",
|
||||
Digest = "sha256:symbolizer"
|
||||
},
|
||||
LibcVariant = "glibc",
|
||||
SysrootDigest = "sha256:sysroot"
|
||||
},
|
||||
SigningOptions = new RuntimeWitnessSigningOptions
|
||||
{
|
||||
KeyId = "runtime-signing-key",
|
||||
UseKeyless = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await collector.ObserveAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Witness);
|
||||
Assert.True(result.Witness!.Success);
|
||||
Assert.NotNull(witnessGenerator.LastRequest);
|
||||
Assert.Equal(request.ImageDigest, witnessGenerator.LastRequest!.ArtifactDigest);
|
||||
Assert.Equal(request.WitnessEmission!.ComponentPurl, witnessGenerator.LastRequest.ComponentPurl);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MockSignalCollector : IRuntimeSignalCollector
|
||||
{
|
||||
private bool _isSupported = true;
|
||||
private readonly RuntimeBtfSelection _btfSelection = new()
|
||||
{
|
||||
SourceKind = "kernel",
|
||||
SourcePath = "/sys/kernel/btf/vmlinux",
|
||||
SourceDigest = "sha256:test",
|
||||
SelectionReason = "kernel_btf_present",
|
||||
KernelRelease = "6.8.0-test",
|
||||
KernelArch = "x86_64",
|
||||
};
|
||||
|
||||
private readonly RuntimeSignalOptions _defaultOptions = new()
|
||||
{
|
||||
TargetSymbols = [],
|
||||
@@ -232,6 +313,8 @@ internal sealed class MockSignalCollector : IRuntimeSignalCollector
|
||||
|
||||
public bool IsSupported() => _isSupported;
|
||||
|
||||
public RuntimeBtfSelection GetBtfSelection() => _btfSelection;
|
||||
|
||||
public IReadOnlyList<ProbeType> GetSupportedProbeTypes() => [ProbeType.Uprobe, ProbeType.Uretprobe];
|
||||
|
||||
public Task<SignalCollectionHandle> StartCollectionAsync(
|
||||
@@ -309,3 +392,96 @@ internal sealed class MockObservationStore : IRuntimeObservationStore
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MockRuntimeWitnessGenerator : IRuntimeWitnessGenerator
|
||||
{
|
||||
public RuntimeWitnessRequest? LastRequest { get; private set; }
|
||||
|
||||
public Task<RuntimeWitnessResult> GenerateAsync(RuntimeWitnessRequest request, CancellationToken ct = default)
|
||||
{
|
||||
LastRequest = request;
|
||||
var witness = new PathWitness
|
||||
{
|
||||
WitnessId = "wit:runtime:test",
|
||||
Artifact = new WitnessArtifact
|
||||
{
|
||||
SbomDigest = request.ArtifactDigest,
|
||||
ComponentPurl = request.ComponentPurl
|
||||
},
|
||||
Vuln = new WitnessVuln
|
||||
{
|
||||
Id = request.VulnerabilityId ?? "runtime",
|
||||
Source = "runtime",
|
||||
AffectedRange = "unknown"
|
||||
},
|
||||
Entrypoint = new WitnessEntrypoint
|
||||
{
|
||||
Kind = "runtime",
|
||||
Name = "runtime-entry",
|
||||
SymbolId = "runtime:entry"
|
||||
},
|
||||
Path =
|
||||
[
|
||||
new PathStep
|
||||
{
|
||||
Symbol = "runtime-entry",
|
||||
SymbolId = "runtime:entry"
|
||||
}
|
||||
],
|
||||
Sink = new WitnessSink
|
||||
{
|
||||
Symbol = "runtime-sink",
|
||||
SymbolId = "runtime:sink",
|
||||
SinkType = "runtime-observed"
|
||||
},
|
||||
Evidence = new WitnessEvidence
|
||||
{
|
||||
CallgraphDigest = "sha256:test",
|
||||
BuildId = request.Symbolization?.BuildId
|
||||
},
|
||||
ObservedAt = request.Observations[0].ObservedAt,
|
||||
ObservationType = ObservationType.Runtime,
|
||||
PredicateType = RuntimeWitnessPredicateTypes.RuntimeWitnessCanonical,
|
||||
ClaimId = request.ClaimId,
|
||||
Observations = request.Observations,
|
||||
Symbolization = request.Symbolization
|
||||
};
|
||||
|
||||
return Task.FromResult(RuntimeWitnessResult.Successful(
|
||||
witness,
|
||||
Encoding.UTF8.GetBytes("{}"),
|
||||
casUri: "cas://runtime-witness/dsse/mock"));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateBatchAsync(
|
||||
BatchRuntimeWitnessRequest request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
foreach (var item in request.Requests)
|
||||
{
|
||||
yield return await GenerateAsync(item, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateFromStreamAsync(
|
||||
IAsyncEnumerable<RuntimeObservation> observations,
|
||||
IRuntimeWitnessContextProvider contextProvider,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
await foreach (var observation in observations.WithCancellation(ct))
|
||||
{
|
||||
var request = new RuntimeWitnessRequest
|
||||
{
|
||||
ClaimId = contextProvider.GetClaimId(observation),
|
||||
ArtifactDigest = contextProvider.GetArtifactDigest(),
|
||||
ComponentPurl = contextProvider.GetComponentPurl(observation),
|
||||
VulnerabilityId = contextProvider.GetVulnerabilityId(observation),
|
||||
Observations = [observation],
|
||||
Symbolization = contextProvider.GetSymbolization(observation) ?? throw new InvalidOperationException(),
|
||||
SigningOptions = contextProvider.GetSigningOptions()
|
||||
};
|
||||
|
||||
yield return await GenerateAsync(request, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for deterministic runtime witness generation.
|
||||
/// </summary>
|
||||
public sealed class RuntimeWitnessGeneratorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithValidRequest_ReturnsSignedWitnessAndStoresArtifacts()
|
||||
{
|
||||
var signingKey = CreateTestKey();
|
||||
var signer = new WitnessDsseSigner();
|
||||
var keyProvider = new StaticSigningKeyProvider(signingKey);
|
||||
var storage = new RecordingStorage("cas://runtime-witness/dsse/test-envelope");
|
||||
var sut = new RuntimeWitnessGenerator(signer, keyProvider, storage);
|
||||
|
||||
var request = CreateRequest(
|
||||
claimId: "claim:artifact123:pathabcdef123456",
|
||||
observations:
|
||||
[
|
||||
CreateObservation("obs-b", "sha256:bbb", DateTimeOffset.Parse("2026-02-16T10:00:02Z")),
|
||||
CreateObservation("obs-a", "sha256:aaa", DateTimeOffset.Parse("2026-02-16T10:00:01Z"))
|
||||
]);
|
||||
|
||||
var result = await sut.GenerateAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Witness);
|
||||
Assert.NotNull(result.EnvelopeBytes);
|
||||
Assert.Equal("cas://runtime-witness/dsse/test-envelope", result.CasUri);
|
||||
Assert.Equal("pathabcdef123456", result.Witness!.PathHash);
|
||||
Assert.NotNull(storage.LastRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithEquivalentObservationSets_ProducesStableEnvelopeBytes()
|
||||
{
|
||||
var signingKey = CreateTestKey();
|
||||
var signer = new WitnessDsseSigner();
|
||||
var keyProvider = new StaticSigningKeyProvider(signingKey);
|
||||
var sut = new RuntimeWitnessGenerator(signer, keyProvider, new NullRuntimeWitnessStorage());
|
||||
|
||||
var ordered = new[]
|
||||
{
|
||||
CreateObservation("obs-a", "sha256:aaa", DateTimeOffset.Parse("2026-02-16T10:00:01Z")),
|
||||
CreateObservation("obs-b", "sha256:bbb", DateTimeOffset.Parse("2026-02-16T10:00:02Z"))
|
||||
};
|
||||
|
||||
var reversed = new[] { ordered[1], ordered[0] };
|
||||
|
||||
var requestA = CreateRequest("claim:artifact123:pathabcdef123456", ordered);
|
||||
var requestB = CreateRequest("claim:artifact123:pathabcdef123456", reversed);
|
||||
|
||||
var resultA = await sut.GenerateAsync(requestA, TestContext.Current.CancellationToken);
|
||||
var resultB = await sut.GenerateAsync(requestB, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(resultA.Success);
|
||||
Assert.True(resultB.Success);
|
||||
Assert.NotNull(resultA.EnvelopeBytes);
|
||||
Assert.NotNull(resultB.EnvelopeBytes);
|
||||
Assert.Equal(resultA.Witness!.WitnessId, resultB.Witness!.WitnessId);
|
||||
Assert.Equal(resultA.EnvelopeBytes, resultB.EnvelopeBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GenerateFromStreamAsync_GroupsByClaimIdAndEmitsDeterministicOrder()
|
||||
{
|
||||
var signingKey = CreateTestKey();
|
||||
var signer = new WitnessDsseSigner();
|
||||
var keyProvider = new StaticSigningKeyProvider(signingKey);
|
||||
var sut = new RuntimeWitnessGenerator(signer, keyProvider, new NullRuntimeWitnessStorage());
|
||||
|
||||
var observations = StreamObservations(
|
||||
CreateObservation("obs-1", "sha256:111", DateTimeOffset.Parse("2026-02-16T10:01:00Z"), containerId: "c2"),
|
||||
CreateObservation("obs-2", "sha256:222", DateTimeOffset.Parse("2026-02-16T10:02:00Z"), containerId: "c1"));
|
||||
|
||||
var provider = new TestContextProvider();
|
||||
|
||||
var results = new List<RuntimeWitnessResult>();
|
||||
await foreach (var result in sut.GenerateFromStreamAsync(observations, provider, TestContext.Current.CancellationToken))
|
||||
{
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, static result => Assert.True(result.Success));
|
||||
Assert.Equal("claim:artifact123:c1", results[0].ClaimId);
|
||||
Assert.Equal("claim:artifact123:c2", results[1].ClaimId);
|
||||
}
|
||||
|
||||
private static RuntimeWitnessRequest CreateRequest(
|
||||
string claimId,
|
||||
IReadOnlyList<RuntimeObservation> observations)
|
||||
{
|
||||
return new RuntimeWitnessRequest
|
||||
{
|
||||
ClaimId = claimId,
|
||||
ArtifactDigest = "sha256:artifact123",
|
||||
ComponentPurl = "pkg:oci/demo@sha256:artifact123",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
Observations = observations,
|
||||
Symbolization = CreateSymbolization(),
|
||||
SigningOptions = new RuntimeWitnessSigningOptions
|
||||
{
|
||||
KeyId = "runtime-signing-key",
|
||||
UseKeyless = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeObservation CreateObservation(
|
||||
string observationId,
|
||||
string stackHash,
|
||||
DateTimeOffset observedAt,
|
||||
string containerId = "container-a")
|
||||
{
|
||||
return new RuntimeObservation
|
||||
{
|
||||
ObservedAt = observedAt,
|
||||
ObservationCount = 1,
|
||||
StackSampleHash = stackHash,
|
||||
ProcessId = 100,
|
||||
ContainerId = containerId,
|
||||
PodName = "pod-a",
|
||||
Namespace = "default",
|
||||
SourceType = RuntimeObservationSourceType.Tetragon,
|
||||
ObservationId = observationId
|
||||
};
|
||||
}
|
||||
|
||||
private static WitnessSymbolization CreateSymbolization()
|
||||
{
|
||||
return new WitnessSymbolization
|
||||
{
|
||||
BuildId = "gnu-build-id:runtime-test",
|
||||
DebugArtifactUri = "cas://symbols/runtime-test.debug",
|
||||
Symbolizer = new WitnessSymbolizer
|
||||
{
|
||||
Name = "llvm-symbolizer",
|
||||
Version = "18.1.7",
|
||||
Digest = "sha256:symbolizer"
|
||||
},
|
||||
LibcVariant = "glibc",
|
||||
SysrootDigest = "sha256:sysroot"
|
||||
};
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<RuntimeObservation> StreamObservations(params RuntimeObservation[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
private static EnvelopeKey CreateTestKey()
|
||||
{
|
||||
var generator = new Ed25519KeyPairGenerator();
|
||||
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
|
||||
var keyPair = generator.GenerateKeyPair();
|
||||
|
||||
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
|
||||
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
|
||||
|
||||
var privateKey = new byte[64];
|
||||
privateParams.Encode(privateKey, 0);
|
||||
var publicKey = publicParams.GetEncoded();
|
||||
Array.Copy(publicKey, 0, privateKey, 32, 32);
|
||||
|
||||
return EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "runtime-signing-key");
|
||||
}
|
||||
|
||||
private sealed class StaticSigningKeyProvider : IRuntimeWitnessSigningKeyProvider
|
||||
{
|
||||
private readonly EnvelopeKey _key;
|
||||
|
||||
public StaticSigningKeyProvider(EnvelopeKey key)
|
||||
{
|
||||
_key = key;
|
||||
}
|
||||
|
||||
public bool TryResolveSigningKey(
|
||||
RuntimeWitnessSigningOptions options,
|
||||
out EnvelopeKey? signingKey,
|
||||
out string? errorMessage)
|
||||
{
|
||||
signingKey = _key;
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingStorage : IRuntimeWitnessStorage
|
||||
{
|
||||
private readonly string _uri;
|
||||
|
||||
public RecordingStorage(string uri)
|
||||
{
|
||||
_uri = uri;
|
||||
}
|
||||
|
||||
public RuntimeWitnessStorageRequest? LastRequest { get; private set; }
|
||||
|
||||
public Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult<string?>(_uri);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestContextProvider : IRuntimeWitnessContextProvider
|
||||
{
|
||||
public string GetClaimId(RuntimeObservation observation)
|
||||
{
|
||||
return $"claim:artifact123:{observation.ContainerId}";
|
||||
}
|
||||
|
||||
public string GetArtifactDigest()
|
||||
{
|
||||
return "sha256:artifact123";
|
||||
}
|
||||
|
||||
public string GetComponentPurl(RuntimeObservation observation)
|
||||
{
|
||||
return "pkg:oci/demo@sha256:artifact123";
|
||||
}
|
||||
|
||||
public string? GetVulnerabilityId(RuntimeObservation observation)
|
||||
{
|
||||
return "CVE-2026-0001";
|
||||
}
|
||||
|
||||
public WitnessSymbolization? GetSymbolization(RuntimeObservation observation)
|
||||
{
|
||||
return CreateSymbolization();
|
||||
}
|
||||
|
||||
public RuntimeWitnessSigningOptions GetSigningOptions()
|
||||
{
|
||||
return new RuntimeWitnessSigningOptions
|
||||
{
|
||||
KeyId = "runtime-signing-key",
|
||||
UseKeyless = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed random generator for deterministic key generation in tests.
|
||||
/// </summary>
|
||||
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
|
||||
{
|
||||
private byte _value = 0x42;
|
||||
|
||||
public void AddSeedMaterial(byte[] seed) { }
|
||||
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
|
||||
public void AddSeedMaterial(long seed) { }
|
||||
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
|
||||
public void NextBytes(byte[] bytes, int start, int len)
|
||||
{
|
||||
for (var i = start; i < start + len; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
|
||||
public void NextBytes(Span<byte> bytes)
|
||||
{
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for runtime witness requests.
|
||||
/// </summary>
|
||||
public sealed class RuntimeWitnessRequestTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_WithoutSymbolization_ThrowsArgumentException()
|
||||
{
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Symbolization = null
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => request.Validate());
|
||||
Assert.Contains("Symbolization", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_WithoutDebugArtifactAndSymbolTable_ThrowsInvalidOperationException()
|
||||
{
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Symbolization = new WitnessSymbolization
|
||||
{
|
||||
BuildId = "gnu-build-id:abc123",
|
||||
DebugArtifactUri = null,
|
||||
SymbolTableUri = null,
|
||||
Symbolizer = new WitnessSymbolizer
|
||||
{
|
||||
Name = "llvm-symbolizer",
|
||||
Version = "18.1.7",
|
||||
Digest = "sha256:symdigest"
|
||||
},
|
||||
LibcVariant = "glibc",
|
||||
SysrootDigest = "sha256:sysroot"
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => request.Validate());
|
||||
Assert.Contains("debug_artifact_uri", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_WithValidSymbolization_DoesNotThrow()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
request.Validate();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BatchValidate_ValidRequest_DoesNotThrow()
|
||||
{
|
||||
var batch = new BatchRuntimeWitnessRequest
|
||||
{
|
||||
Requests = [CreateValidRequest()]
|
||||
};
|
||||
|
||||
batch.Validate();
|
||||
}
|
||||
|
||||
private static RuntimeWitnessRequest CreateValidRequest()
|
||||
{
|
||||
return new RuntimeWitnessRequest
|
||||
{
|
||||
ClaimId = "claim:artifact:path",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
ComponentPurl = "pkg:docker/example/app@1.0.0",
|
||||
Observations =
|
||||
[
|
||||
new RuntimeObservation
|
||||
{
|
||||
ObservedAt = new DateTimeOffset(2026, 2, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
ObservationCount = 2,
|
||||
SourceType = RuntimeObservationSourceType.Tetragon
|
||||
}
|
||||
],
|
||||
Symbolization = new WitnessSymbolization
|
||||
{
|
||||
BuildId = "gnu-build-id:abc123",
|
||||
DebugArtifactUri = "cas://symbols/by-build-id/gnu-build-id:abc123/artifact.debug",
|
||||
SymbolTableUri = null,
|
||||
Symbolizer = new WitnessSymbolizer
|
||||
{
|
||||
Name = "llvm-symbolizer",
|
||||
Version = "18.1.7",
|
||||
Digest = "sha256:symdigest"
|
||||
},
|
||||
LibcVariant = "glibc",
|
||||
SysrootDigest = "sha256:sysroot"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,42 @@ public class WitnessDsseSignerTests
|
||||
Assert.NotEmpty(result.PayloadBytes!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignWitness_RuntimeWitnessWithoutSymbolization_ReturnsFails()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateRuntimeWitness(includeSymbolization: false);
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
// Act
|
||||
var result = signer.SignWitness(witness, key, TestCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Contains("symbolization", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignWitness_RuntimeWitnessWithSymbolization_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateRuntimeWitness(includeSymbolization: true);
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
// Act
|
||||
var result = signer.SignWitness(witness, key, TestCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess, result.Error);
|
||||
Assert.NotNull(result.Envelope);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithValidSignature_ReturnsSuccess()
|
||||
@@ -304,6 +340,46 @@ public class WitnessDsseSignerTests
|
||||
};
|
||||
}
|
||||
|
||||
private static PathWitness CreateRuntimeWitness(bool includeSymbolization)
|
||||
{
|
||||
var witness = CreateTestWitness() with
|
||||
{
|
||||
ObservationType = ObservationType.Runtime,
|
||||
Observations =
|
||||
[
|
||||
new RuntimeObservation
|
||||
{
|
||||
ObservedAt = new DateTimeOffset(2025, 12, 19, 12, 30, 0, TimeSpan.Zero),
|
||||
ObservationCount = 3,
|
||||
SourceType = RuntimeObservationSourceType.Tetragon
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (!includeSymbolization)
|
||||
{
|
||||
return witness;
|
||||
}
|
||||
|
||||
return witness with
|
||||
{
|
||||
Symbolization = new WitnessSymbolization
|
||||
{
|
||||
BuildId = "gnu-build-id:abcd1234",
|
||||
DebugArtifactUri = "cas://symbols/by-build-id/gnu-build-id:abcd1234/artifact.debug",
|
||||
SymbolTableUri = null,
|
||||
Symbolizer = new WitnessSymbolizer
|
||||
{
|
||||
Name = "llvm-symbolizer",
|
||||
Version = "18.1.7",
|
||||
Digest = "sha256:symbolizer123"
|
||||
},
|
||||
LibcVariant = "glibc",
|
||||
SysrootDigest = "sha256:sysroot123"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed random generator for deterministic key generation in tests.
|
||||
/// </summary>
|
||||
|
||||
@@ -175,22 +175,30 @@ if (authorityOptions.Enabled)
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authorityOptions.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authorityOptions.TokenClockSkewSeconds);
|
||||
|
||||
foreach (var audience in authorityOptions.Audiences)
|
||||
// Read collections directly from IConfiguration to work around
|
||||
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
|
||||
var authSection = builder.Configuration.GetSection("Scheduler:Authority");
|
||||
|
||||
var cfgAudiences = authSection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
foreach (var audience in cfgAudiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var scope in authorityOptions.RequiredScopes)
|
||||
var cfgScopes = authSection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
foreach (var scope in cfgScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var tenant in authorityOptions.RequiredTenants)
|
||||
var cfgTenants = authSection.GetSection("RequiredTenants").Get<string[]>() ?? [];
|
||||
foreach (var tenant in cfgTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
foreach (var network in authorityOptions.BypassNetworks)
|
||||
var cfgBypassNetworks = authSection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
foreach (var network in cfgBypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-HOTLIST-SIGNALS-0001 | DONE | Hotlist apply for `src/Signals/StellaOps.Signals/StellaOps.Signals.csproj`; audit tracker updated. |
|
||||
| MWD-001 | DONE | Implemented deterministic BTF fallback selection and metadata emission for runtime eBPF collection (`source_kind`, `source_path`, `source_digest`, `selection_reason`); verified with Signals and Scanner tests. |
|
||||
|
||||
@@ -119,7 +119,8 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<RuntimeSignalCollector>>();
|
||||
var probeLoader = sp.GetRequiredService<IEbpfProbeLoader>();
|
||||
return new RuntimeSignalCollector(logger, probeLoader);
|
||||
var btfSelector = RuntimeBtfSourceSelector.CreateDefault(options.BtfSelectionOptions);
|
||||
return new RuntimeSignalCollector(logger, probeLoader, btfSelector);
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -187,4 +188,9 @@ public sealed class EbpfEvidenceOptions
|
||||
/// Collector options.
|
||||
/// </summary>
|
||||
public RuntimeEvidenceCollectorOptions CollectorOptions { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Runtime BTF selection options.
|
||||
/// </summary>
|
||||
public RuntimeBtfSelectionOptions BtfSelectionOptions { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ public interface IRuntimeSignalCollector
|
||||
/// <returns>True if eBPF probes can be loaded.</returns>
|
||||
bool IsSupported();
|
||||
|
||||
/// <summary>
|
||||
/// Gets deterministic BTF source metadata used for runtime collection.
|
||||
/// </summary>
|
||||
RuntimeBtfSelection GetBtfSelection();
|
||||
|
||||
/// <summary>
|
||||
/// Gets available probe types on this system.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// <copyright file="RuntimeBtfSourceSelector.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Signals.Ebpf.Schema;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Signals.Ebpf.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for deterministic BTF source selection.
|
||||
/// </summary>
|
||||
public sealed record RuntimeBtfSelectionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Candidate full-kernel BTF (vmlinux) paths in deterministic order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ExternalVmlinuxPaths { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Candidate split-BTF root directories in deterministic order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SplitBtfDirectories { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a deterministic BTF source for runtime eBPF collection.
|
||||
/// </summary>
|
||||
public sealed class RuntimeBtfSourceSelector
|
||||
{
|
||||
private const string KernelBtfPath = "/sys/kernel/btf/vmlinux";
|
||||
|
||||
private static readonly string[] DefaultSplitBtfDirectories =
|
||||
[
|
||||
"/var/lib/stellaops/btf/split",
|
||||
"/usr/share/stellaops/btf/split",
|
||||
"/usr/lib/stellaops/btf/split",
|
||||
];
|
||||
|
||||
private readonly RuntimeBtfSelectionOptions _options;
|
||||
private readonly Func<bool> _isLinuxPlatform;
|
||||
private readonly Func<string, bool> _fileExists;
|
||||
private readonly Func<string, byte[]> _readAllBytes;
|
||||
private readonly Func<string> _kernelReleaseProvider;
|
||||
private readonly Func<string> _kernelArchProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a selector with explicit environment probes.
|
||||
/// </summary>
|
||||
public RuntimeBtfSourceSelector(
|
||||
RuntimeBtfSelectionOptions? options = null,
|
||||
Func<bool>? isLinuxPlatform = null,
|
||||
Func<string, bool>? fileExists = null,
|
||||
Func<string, byte[]>? readAllBytes = null,
|
||||
Func<string>? kernelReleaseProvider = null,
|
||||
Func<string>? kernelArchProvider = null)
|
||||
{
|
||||
_options = options ?? new RuntimeBtfSelectionOptions();
|
||||
_isLinuxPlatform = isLinuxPlatform ?? (() => RuntimeInformation.IsOSPlatform(OSPlatform.Linux));
|
||||
_fileExists = fileExists ?? File.Exists;
|
||||
_readAllBytes = readAllBytes ?? File.ReadAllBytes;
|
||||
_kernelReleaseProvider = kernelReleaseProvider ?? GetKernelRelease;
|
||||
_kernelArchProvider = kernelArchProvider ?? GetKernelArch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a selector using host runtime probes.
|
||||
/// </summary>
|
||||
public static RuntimeBtfSourceSelector CreateDefault(RuntimeBtfSelectionOptions? options = null)
|
||||
=> new(options: options);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the BTF source with deterministic precedence.
|
||||
/// </summary>
|
||||
public RuntimeBtfSelection Resolve()
|
||||
{
|
||||
var kernelRelease = _kernelReleaseProvider();
|
||||
var kernelArch = _kernelArchProvider();
|
||||
|
||||
if (!_isLinuxPlatform())
|
||||
{
|
||||
return new RuntimeBtfSelection
|
||||
{
|
||||
SourceKind = "unsupported",
|
||||
SourcePath = null,
|
||||
SourceDigest = null,
|
||||
SelectionReason = "platform_not_linux",
|
||||
KernelRelease = kernelRelease,
|
||||
KernelArch = kernelArch,
|
||||
};
|
||||
}
|
||||
|
||||
if (TryCreateSelection(
|
||||
sourceKind: "kernel",
|
||||
sourcePath: KernelBtfPath,
|
||||
selectionReason: "kernel_btf_present",
|
||||
kernelRelease,
|
||||
kernelArch,
|
||||
out var kernelSelection))
|
||||
{
|
||||
return kernelSelection;
|
||||
}
|
||||
|
||||
foreach (var candidate in NormalizePaths(_options.ExternalVmlinuxPaths))
|
||||
{
|
||||
if (TryCreateSelection(
|
||||
sourceKind: "external-vmlinux",
|
||||
sourcePath: candidate,
|
||||
selectionReason: "external_vmlinux_configured",
|
||||
kernelRelease,
|
||||
kernelArch,
|
||||
out var externalSelection))
|
||||
{
|
||||
return externalSelection;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in BuildSplitBtfCandidates(kernelRelease, kernelArch))
|
||||
{
|
||||
if (TryCreateSelection(
|
||||
sourceKind: "split-btf",
|
||||
sourcePath: candidate,
|
||||
selectionReason: "split_btf_fallback",
|
||||
kernelRelease,
|
||||
kernelArch,
|
||||
out var splitSelection))
|
||||
{
|
||||
return splitSelection;
|
||||
}
|
||||
}
|
||||
|
||||
return new RuntimeBtfSelection
|
||||
{
|
||||
SourceKind = "unavailable",
|
||||
SourcePath = null,
|
||||
SourceDigest = null,
|
||||
SelectionReason = "no_btf_source_found",
|
||||
KernelRelease = kernelRelease,
|
||||
KernelArch = kernelArch,
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<string> NormalizePaths(IEnumerable<string>? paths)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (paths is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = path.Trim();
|
||||
if (seen.Add(trimmed))
|
||||
{
|
||||
yield return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> BuildSplitBtfCandidates(string kernelRelease, string kernelArch)
|
||||
{
|
||||
var splitRoots = NormalizePaths(
|
||||
_options.SplitBtfDirectories.Concat(DefaultSplitBtfDirectories));
|
||||
|
||||
foreach (var root in splitRoots)
|
||||
{
|
||||
var releaseArchDir = Path.Combine(root, kernelRelease, kernelArch);
|
||||
yield return Path.Combine(releaseArchDir, "vmlinux.btf");
|
||||
yield return Path.Combine(root, kernelRelease, $"{kernelArch}.btf");
|
||||
yield return Path.Combine(root, kernelRelease, "vmlinux.btf");
|
||||
yield return Path.Combine(root, $"{kernelRelease}.btf");
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryCreateSelection(
|
||||
string sourceKind,
|
||||
string sourcePath,
|
||||
string selectionReason,
|
||||
string kernelRelease,
|
||||
string kernelArch,
|
||||
out RuntimeBtfSelection selection)
|
||||
{
|
||||
selection = null!;
|
||||
|
||||
if (!_fileExists(sourcePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(_readAllBytes(sourcePath)));
|
||||
selection = new RuntimeBtfSelection
|
||||
{
|
||||
SourceKind = sourceKind,
|
||||
SourcePath = sourcePath,
|
||||
SourceDigest = digest,
|
||||
SelectionReason = selectionReason,
|
||||
KernelRelease = kernelRelease,
|
||||
KernelArch = kernelArch,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetKernelRelease()
|
||||
{
|
||||
const string releasePath = "/proc/sys/kernel/osrelease";
|
||||
if (_fileExists(releasePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var release = Encoding.UTF8.GetString(_readAllBytes(releasePath)).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(release))
|
||||
{
|
||||
return release;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to runtime description.
|
||||
}
|
||||
}
|
||||
|
||||
return RuntimeInformation.OSDescription.Trim();
|
||||
}
|
||||
|
||||
private static string GetKernelArch()
|
||||
{
|
||||
return RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x86_64",
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.Arm => "arm",
|
||||
_ => RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using StellaOps.Reachability.Core;
|
||||
using StellaOps.Signals.Ebpf.Probes;
|
||||
using StellaOps.Signals.Ebpf.Schema;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
@@ -27,16 +26,20 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
|
||||
private readonly IEbpfProbeLoader _probeLoader;
|
||||
private readonly ConcurrentDictionary<Guid, CollectionSession> _activeSessions;
|
||||
private readonly bool _isSupported;
|
||||
private readonly RuntimeBtfSelection _btfSelection;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeSignalCollector(
|
||||
ILogger<RuntimeSignalCollector> logger,
|
||||
IEbpfProbeLoader probeLoader)
|
||||
IEbpfProbeLoader probeLoader,
|
||||
RuntimeBtfSourceSelector? btfSourceSelector = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_probeLoader = probeLoader;
|
||||
_activeSessions = new ConcurrentDictionary<Guid, CollectionSession>();
|
||||
_isSupported = CheckEbpfSupport();
|
||||
var selector = btfSourceSelector ?? RuntimeBtfSourceSelector.CreateDefault();
|
||||
_btfSelection = selector.Resolve();
|
||||
_isSupported = !string.IsNullOrWhiteSpace(_btfSelection.SourcePath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -51,7 +54,7 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
|
||||
if (!_isSupported)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
"eBPF is not supported on this platform. Linux 5.8+ with BTF enabled is required.");
|
||||
$"eBPF is not supported on this platform. Selection reason: {_btfSelection.SelectionReason}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -180,6 +183,7 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
|
||||
ObservedNodeHashes = observedNodeHashes,
|
||||
ObservedPathHashes = observedPathHashes,
|
||||
CombinedPathHash = combinedPathHash,
|
||||
BtfSelection = _btfSelection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +223,9 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported() => _isSupported;
|
||||
|
||||
/// <inheritdoc />
|
||||
public RuntimeBtfSelection GetBtfSelection() => _btfSelection;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ProbeType> GetSupportedProbeTypes()
|
||||
{
|
||||
@@ -256,18 +263,6 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static bool CheckEbpfSupport()
|
||||
{
|
||||
// eBPF is only supported on Linux 5.8+ with BTF
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for BTF support by looking for /sys/kernel/btf/vmlinux
|
||||
return File.Exists("/sys/kernel/btf/vmlinux");
|
||||
}
|
||||
|
||||
private async Task ProcessEventsAsync(CollectionSession session, CancellationToken ct)
|
||||
{
|
||||
var rateLimiter = new RateLimiter(session.Options.MaxEventsPerSecond);
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Signals.Ebpf.Probes;
|
||||
using StellaOps.Signals.Ebpf.Schema;
|
||||
using StellaOps.Signals.Ebpf.Services;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
@@ -36,6 +37,86 @@ public sealed class RuntimeSignalCollectorTests
|
||||
Assert.True(isSupported == false || Environment.OSVersion.Platform == PlatformID.Unix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBtfSelection_PrefersKernelBtfOverConfiguredFallback()
|
||||
{
|
||||
var external = "/opt/stellaops/btf/vmlinux-6.8.0-test";
|
||||
var collector = CreateCollectorWithFiles(
|
||||
CreateFileMap("/sys/kernel/btf/vmlinux", external),
|
||||
new RuntimeBtfSelectionOptions
|
||||
{
|
||||
ExternalVmlinuxPaths = [external],
|
||||
});
|
||||
|
||||
var selection = collector.GetBtfSelection();
|
||||
|
||||
Assert.True(collector.IsSupported());
|
||||
Assert.Equal("kernel", selection.SourceKind);
|
||||
Assert.Equal("/sys/kernel/btf/vmlinux", selection.SourcePath);
|
||||
Assert.Equal("kernel_btf_present", selection.SelectionReason);
|
||||
Assert.StartsWith("sha256:", selection.SourceDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBtfSelection_UsesExternalVmlinuxWhenKernelBtfMissing()
|
||||
{
|
||||
var external = "/opt/stellaops/btf/vmlinux-6.8.0-test";
|
||||
var collector = CreateCollectorWithFiles(
|
||||
CreateFileMap(external),
|
||||
new RuntimeBtfSelectionOptions
|
||||
{
|
||||
ExternalVmlinuxPaths = [external],
|
||||
});
|
||||
|
||||
var selection = collector.GetBtfSelection();
|
||||
|
||||
Assert.True(collector.IsSupported());
|
||||
Assert.Equal("external-vmlinux", selection.SourceKind);
|
||||
Assert.Equal(external, selection.SourcePath);
|
||||
Assert.Equal("external_vmlinux_configured", selection.SelectionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBtfSelection_UsesSplitBtfFallbackWhenConfigured()
|
||||
{
|
||||
var splitRoot = "/var/lib/stellaops/btf/split";
|
||||
var splitCandidate = Path.Combine(splitRoot, "6.8.0-test", "x86_64", "vmlinux.btf");
|
||||
var collector = CreateCollectorWithFiles(
|
||||
CreateFileMap(splitCandidate),
|
||||
new RuntimeBtfSelectionOptions
|
||||
{
|
||||
SplitBtfDirectories = [splitRoot],
|
||||
});
|
||||
|
||||
var selection = collector.GetBtfSelection();
|
||||
|
||||
Assert.True(collector.IsSupported());
|
||||
Assert.Equal("split-btf", selection.SourceKind);
|
||||
Assert.Equal(splitCandidate, selection.SourcePath);
|
||||
Assert.Equal("split_btf_fallback", selection.SelectionReason);
|
||||
Assert.Equal("6.8.0-test", selection.KernelRelease);
|
||||
Assert.Equal("x86_64", selection.KernelArch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopCollectionAsync_EmitsBtfSelectionMetadata()
|
||||
{
|
||||
var external = "/opt/stellaops/btf/vmlinux-6.8.0-test";
|
||||
var collector = CreateCollectorWithFiles(
|
||||
CreateFileMap(external),
|
||||
new RuntimeBtfSelectionOptions
|
||||
{
|
||||
ExternalVmlinuxPaths = [external],
|
||||
});
|
||||
|
||||
var handle = await collector.StartCollectionAsync("container-1", new RuntimeSignalOptions());
|
||||
var summary = await collector.StopCollectionAsync(handle);
|
||||
|
||||
Assert.NotNull(summary.BtfSelection);
|
||||
Assert.Equal("external-vmlinux", summary.BtfSelection!.SourceKind);
|
||||
Assert.Equal(external, summary.BtfSelection.SourcePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedProbeTypes_ReturnsEmptyOnUnsupportedPlatform()
|
||||
{
|
||||
@@ -206,4 +287,33 @@ public sealed class RuntimeSignalCollectorTests
|
||||
|
||||
public IReadOnlyList<ProbeType> GetSupportedProbeTypes() => [];
|
||||
}
|
||||
|
||||
private static RuntimeSignalCollector CreateCollectorWithFiles(
|
||||
IReadOnlyDictionary<string, byte[]> files,
|
||||
RuntimeBtfSelectionOptions options)
|
||||
{
|
||||
var selector = new RuntimeBtfSourceSelector(
|
||||
options,
|
||||
isLinuxPlatform: () => true,
|
||||
fileExists: path => files.ContainsKey(path),
|
||||
readAllBytes: path => files[path],
|
||||
kernelReleaseProvider: () => "6.8.0-test",
|
||||
kernelArchProvider: () => "x86_64");
|
||||
|
||||
return new RuntimeSignalCollector(
|
||||
NullLogger<RuntimeSignalCollector>.Instance,
|
||||
new MockProbeLoader(),
|
||||
selector);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, byte[]> CreateFileMap(params string[] paths)
|
||||
{
|
||||
var map = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
map[path] = Encoding.UTF8.GetBytes($"btf:{path}");
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -37,7 +37,6 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Map endpoints
|
||||
app.MapTimelineEndpoints();
|
||||
|
||||
@@ -56,7 +56,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -66,7 +66,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
// Add rate limiting middleware
|
||||
|
||||
@@ -41,7 +41,6 @@ app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.MapGet("/v1/vulns", ([AsParameters] VulnFilter filter) =>
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('Workflow: Navigation Sidebar', () => {
|
||||
|
||||
// Verify nav links exist (at least some expected labels)
|
||||
const navText = await nav.first().innerText();
|
||||
const expectedSections = ['Security', 'Policy', 'Operations'];
|
||||
const expectedSections = ['Security', 'Evidence', 'Operations', 'Settings'];
|
||||
for (const section of expectedSections) {
|
||||
expect(navText.toLowerCase()).toContain(section.toLowerCase());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user