compose and authority fixes. finish sprints.

This commit is contained in:
master
2026-02-17 21:59:47 +02:00
parent fb46a927ad
commit 49cdebe2f1
187 changed files with 23189 additions and 1439 deletions

View File

@@ -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");

View File

@@ -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();

View File

@@ -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
};
}
}

View File

@@ -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. |

View File

@@ -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; }
}

View File

@@ -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. |

View File

@@ -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);
}
}

View File

@@ -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. |

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}

View File

@@ -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
};
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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. |

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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. |

View File

@@ -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)
};
}
}

View File

@@ -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" };
}
}

View File

@@ -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);
}
}

View File

@@ -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. |

View File

@@ -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
});
}
}

View File

@@ -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. |

View File

@@ -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" },
};
}
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -63,7 +63,6 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -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>

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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
};
}

View File

@@ -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]
};
}

View File

@@ -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
```

View File

@@ -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",

View File

@@ -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; }
}
}

View File

@@ -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"
]
}
];
}
}

View File

@@ -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]

View File

@@ -122,7 +122,6 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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>();

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -50,7 +50,6 @@ if (app.Environment.IsDevelopment())
}
app.UseStellaOpsCors();
app.UseHttpsRedirection();
// Map endpoints
app.MapOpsMemoryEndpoints();

View File

@@ -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>();

View File

@@ -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);
}
}

View File

@@ -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..." },
},
},
};
}
}

View File

@@ -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);
}

View File

@@ -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") },
},
};
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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;
""";

View File

@@ -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")

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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");

View File

@@ -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;
}

View File

@@ -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
{

View File

@@ -120,7 +120,6 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseResponseCompression();
app.UseStellaOpsCors();
app.UseRateLimiter();

View File

@@ -50,7 +50,6 @@ if (app.Environment.IsDevelopment())
}
app.UseStellaOpsCors();
app.UseHttpsRedirection();
app.TryUseStellaRouter(routerOptions);
// Map exploit maturity endpoints

View File

@@ -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

View File

@@ -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" },

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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));

View File

@@ -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);
}
}
}

View File

@@ -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++;
}
}
}
}

View File

@@ -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"
}
};
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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. |

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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(),
};
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -41,7 +41,6 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -37,7 +37,6 @@ if (app.Environment.IsDevelopment())
}
app.UseStellaOpsCors();
app.UseHttpsRedirection();
// Map endpoints
app.MapTimelineEndpoints();

View File

@@ -56,7 +56,6 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -66,7 +66,6 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseSerilogRequestLogging();
// Add rate limiting middleware

View File

@@ -41,7 +41,6 @@ app.UseSwagger();
app.UseSwaggerUI();
app.UseStellaOpsCors();
app.UseHttpsRedirection();
app.MapGet("/v1/vulns", ([AsParameters] VulnFilter filter) =>
{

View File

@@ -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