Merge remote changes (theirs)

This commit is contained in:
Codex Assistant
2026-01-08 09:01:53 +02:00
4195 changed files with 249446 additions and 83444 deletions

View File

@@ -10,9 +10,9 @@
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/scanner/architecture.md`
- `docs/reachability/DELIVERY_GUIDE.md` (sections 5.55.9 for native/JS/PHP updates)
- `docs/reachability/purl-resolved-edges.md`
- `docs/reachability/patch-oracles.md`
- `docs/modules/reach-graph/guides/DELIVERY_GUIDE.md` (sections 5.55.9 for native/JS/PHP updates)
- `docs/modules/reach-graph/guides/purl-resolved-edges.md`
- `docs/modules/reach-graph/guides/patch-oracles.md`
- `docs/product-advisories/14-Dec-2025 - Smart-Diff Technical Reference.md` (for Smart-Diff predicates)
- Current sprint file (e.g., `docs/implplan/SPRINT_401_reachability_evidence_chain.md`).
@@ -193,9 +193,9 @@ See: `docs/implplan/SPRINT_3800_0000_0000_summary.md`
- `stella binary verify` - Verify attestation
### Documentation
- `docs/reachability/slice-schema.md` - Slice format specification
- `docs/reachability/cve-symbol-mapping.md` - CVE→symbol service design
- `docs/reachability/replay-verification.md` - Replay workflow guide
- `docs/modules/reach-graph/guides/slice-schema.md` - Slice format specification
- `docs/modules/reach-graph/guides/cve-symbol-mapping.md` - CVE→symbol service design
- `docs/modules/reach-graph/guides/replay-verification.md` - Replay workflow guide
## Engineering Rules
- Target `net10.0`; prefer latest C# preview allowed in repo.

View File

@@ -13,13 +13,9 @@ public sealed class ElfHardeningExtractor : IHardeningExtractor
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ElfHardeningExtractor"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public ElfHardeningExtractor(TimeProvider timeProvider)
public ElfHardeningExtractor(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}
// ELF magic bytes
@@ -607,7 +603,7 @@ public sealed class ElfHardeningExtractor : IHardeningExtractor
#endregion
private static BinaryHardeningFlags CreateResult(
private BinaryHardeningFlags CreateResult(
string path,
string digest,
List<HardeningFlag> flags,

View File

@@ -19,13 +19,9 @@ public sealed class MachoHardeningExtractor : IHardeningExtractor
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="MachoHardeningExtractor"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public MachoHardeningExtractor(TimeProvider timeProvider)
public MachoHardeningExtractor(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}
// Mach-O magic numbers
@@ -268,7 +264,7 @@ public sealed class MachoHardeningExtractor : IHardeningExtractor
: BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset, 4));
}
private static BinaryHardeningFlags CreateResult(
private BinaryHardeningFlags CreateResult(
string path,
string digest,
List<HardeningFlag> flags,

View File

@@ -21,13 +21,9 @@ public sealed class PeHardeningExtractor : IHardeningExtractor
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="PeHardeningExtractor"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public PeHardeningExtractor(TimeProvider timeProvider)
public PeHardeningExtractor(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}
// PE magic bytes: MZ (DOS header)
@@ -244,7 +240,7 @@ public sealed class PeHardeningExtractor : IHardeningExtractor
}
}
private static BinaryHardeningFlags CreateResult(
private BinaryHardeningFlags CreateResult(
string path,
string digest,
List<HardeningFlag> flags,

View File

@@ -16,8 +16,8 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
{
private readonly BuildIdIndexOptions _options;
private readonly ILogger<OfflineBuildIdIndex> _logger;
private readonly TimeProvider _timeProvider;
private readonly IDsseSigningService? _dsseSigningService;
private readonly TimeProvider _timeProvider;
private FrozenDictionary<string, BuildIdLookupResult> _index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
private bool _isLoaded;
@@ -32,17 +32,16 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
public OfflineBuildIdIndex(
IOptions<BuildIdIndexOptions> options,
ILogger<OfflineBuildIdIndex> logger,
TimeProvider timeProvider,
IDsseSigningService? dsseSigningService = null)
IDsseSigningService? dsseSigningService = null,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(timeProvider);
_options = options.Value;
_logger = logger;
_timeProvider = timeProvider;
_dsseSigningService = dsseSigningService;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />

View File

@@ -4,6 +4,7 @@ using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using StellaOps.Determinism;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
@@ -23,6 +24,7 @@ namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
{
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ConcurrentBag<RuntimeLoadEvent> _events = [];
private readonly object _stateLock = new();
private CaptureState _state = CaptureState.Idle;
@@ -35,12 +37,14 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
private int _redactedPaths;
/// <summary>
/// Initializes a new instance of the <see cref="LinuxEbpfCaptureAdapter"/> class.
/// Creates a new Linux eBPF capture adapter.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public LinuxEbpfCaptureAdapter(TimeProvider? timeProvider = null)
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
/// <param name="guidProvider">Optional GUID provider for deterministic session IDs.</param>
public LinuxEbpfCaptureAdapter(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
/// <inheritdoc />
@@ -162,7 +166,7 @@ public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
_events.Clear();
_droppedEvents = 0;
_redactedPaths = 0;
SessionId = Guid.NewGuid().ToString("N");
SessionId = _guidProvider.NewGuid().ToString("N");
_startTime = _timeProvider.GetUtcNow().UtcDateTime;
_captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

View File

@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
using StellaOps.Determinism;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
@@ -24,6 +25,7 @@ namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
{
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ConcurrentBag<RuntimeLoadEvent> _events = [];
private readonly object _stateLock = new();
private CaptureState _state = CaptureState.Idle;
@@ -36,12 +38,14 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
private int _redactedPaths;
/// <summary>
/// Initializes a new instance of the <see cref="MacOsDyldCaptureAdapter"/> class.
/// Creates a new macOS dyld capture adapter.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public MacOsDyldCaptureAdapter(TimeProvider? timeProvider = null)
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
/// <param name="guidProvider">Optional GUID provider for deterministic session IDs.</param>
public MacOsDyldCaptureAdapter(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
/// <inheritdoc />
@@ -166,7 +170,7 @@ public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
_events.Clear();
_droppedEvents = 0;
_redactedPaths = 0;
SessionId = Guid.NewGuid().ToString("N");
SessionId = _guidProvider.NewGuid().ToString("N");
_startTime = _timeProvider.GetUtcNow().UtcDateTime;
_captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

View File

@@ -48,11 +48,13 @@ public static class RuntimeEvidenceAggregator
/// <param name="runtimeEvidence">Runtime capture evidence.</param>
/// <param name="staticEdges">Static analysis dependency edges.</param>
/// <param name="heuristicEdges">Heuristic analysis edges.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
/// <returns>Merged evidence document.</returns>
public static MergedEvidence MergeWithStaticAnalysis(
RuntimeEvidence runtimeEvidence,
IEnumerable<Observations.NativeObservationDeclaredEdge> staticEdges,
IEnumerable<Observations.NativeObservationHeuristicEdge> heuristicEdges)
IEnumerable<Observations.NativeObservationHeuristicEdge> heuristicEdges,
TimeProvider? timeProvider = null)
{
var staticList = staticEdges.ToList();
var heuristicList = heuristicEdges.ToList();
@@ -140,6 +142,7 @@ public static class RuntimeEvidenceAggregator
}
}
var tp = timeProvider ?? TimeProvider.System;
return new MergedEvidence(
ConfirmedEdges: confirmedEdges,
StaticOnlyEdges: staticOnlyEdges,
@@ -148,7 +151,7 @@ public static class RuntimeEvidenceAggregator
TotalRuntimeEvents: runtimeEvidence.Sessions.Sum(s => s.Events.Count),
TotalDroppedEvents: runtimeEvidence.Sessions.Sum(s => s.TotalEventsDropped),
CaptureStartTime: runtimeEvidence.Sessions.Min(s => s.StartTime),
CaptureEndTime: runtimeEvidence.Sessions.Max(s => s.EndTime ?? DateTime.UtcNow));
CaptureEndTime: runtimeEvidence.Sessions.Max(s => s.EndTime ?? tp.GetUtcNow().UtcDateTime));
}
/// <summary>

View File

@@ -273,8 +273,8 @@ public sealed record CollapsedStack
/// Parses a collapsed stack line.
/// Format: "container@digest;buildid=xxx;func;... count"
/// </summary>
/// <param name=\"line\">The collapsed stack line to parse.</param>
/// <param name=\"timeProvider\">Optional time provider for deterministic timestamps.</param>
/// <param name="line">The collapsed stack line to parse.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public static CollapsedStack? Parse(string line, TimeProvider? timeProvider = null)
{
if (string.IsNullOrWhiteSpace(line))
@@ -307,7 +307,8 @@ public sealed record CollapsedStack
}
}
var now = timeProvider?.GetUtcNow().UtcDateTime ?? DateTime.UtcNow;
var tp = timeProvider ?? TimeProvider.System;
var now = tp.GetUtcNow().UtcDateTime;
return new CollapsedStack
{
ContainerIdentifier = container,

View File

@@ -4,6 +4,7 @@ using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Principal;
using System.Text.RegularExpressions;
using StellaOps.Determinism;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
@@ -22,6 +23,7 @@ namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
{
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ConcurrentBag<RuntimeLoadEvent> _events = [];
private readonly object _stateLock = new();
private CaptureState _state = CaptureState.Idle;
@@ -36,12 +38,14 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
private int _redactedPaths;
/// <summary>
/// Initializes a new instance of the <see cref="WindowsEtwCaptureAdapter"/> class.
/// Creates a new Windows ETW capture adapter.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public WindowsEtwCaptureAdapter(TimeProvider? timeProvider = null)
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
/// <param name="guidProvider">Optional GUID provider for deterministic session IDs.</param>
public WindowsEtwCaptureAdapter(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
/// <inheritdoc />
@@ -156,7 +160,7 @@ public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
_events.Clear();
_droppedEvents = 0;
_redactedPaths = 0;
SessionId = Guid.NewGuid().ToString("N");
SessionId = _guidProvider.NewGuid().ToString("N");
_startTime = _timeProvider.GetUtcNow().UtcDateTime;
_captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

View File

@@ -15,6 +15,7 @@
<ItemGroup>
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Determinism.Abstractions\\StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -12,8 +12,7 @@ public sealed record BunPackagesResponse
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
= DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("packages")]
public IReadOnlyList<BunPackageArtifact> Packages { get; init; }

View File

@@ -0,0 +1,141 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Response for GET /scans/{scanId}/layers endpoint.
/// </summary>
public sealed record LayerListResponseDto
{
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("layers")]
public required IReadOnlyList<LayerSummaryDto> Layers { get; init; }
}
/// <summary>
/// Summary of a single layer.
/// </summary>
public sealed record LayerSummaryDto
{
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("order")]
public required int Order { get; init; }
[JsonPropertyName("hasSbom")]
public required bool HasSbom { get; init; }
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Response for GET /scans/{scanId}/composition-recipe endpoint.
/// </summary>
public sealed record CompositionRecipeResponseDto
{
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("createdAt")]
public required string CreatedAt { get; init; }
[JsonPropertyName("recipe")]
public required CompositionRecipeDto Recipe { get; init; }
}
/// <summary>
/// The composition recipe.
/// </summary>
public sealed record CompositionRecipeDto
{
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("generatorName")]
public required string GeneratorName { get; init; }
[JsonPropertyName("generatorVersion")]
public required string GeneratorVersion { get; init; }
[JsonPropertyName("layers")]
public required IReadOnlyList<CompositionRecipeLayerDto> Layers { get; init; }
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
[JsonPropertyName("aggregatedSbomDigests")]
public required AggregatedSbomDigestsDto AggregatedSbomDigests { get; init; }
}
/// <summary>
/// A layer in the composition recipe.
/// </summary>
public sealed record CompositionRecipeLayerDto
{
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("order")]
public required int Order { get; init; }
[JsonPropertyName("fragmentDigest")]
public required string FragmentDigest { get; init; }
[JsonPropertyName("sbomDigests")]
public required LayerSbomDigestsDto SbomDigests { get; init; }
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Digests for a layer's SBOMs.
/// </summary>
public sealed record LayerSbomDigestsDto
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public required string Spdx { get; init; }
}
/// <summary>
/// Digests for aggregated SBOMs.
/// </summary>
public sealed record AggregatedSbomDigestsDto
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public string? Spdx { get; init; }
}
/// <summary>
/// Result of composition recipe verification.
/// </summary>
public sealed record CompositionRecipeVerificationResponseDto
{
[JsonPropertyName("valid")]
public required bool Valid { get; init; }
[JsonPropertyName("merkleRootMatch")]
public required bool MerkleRootMatch { get; init; }
[JsonPropertyName("layerDigestsMatch")]
public required bool LayerDigestsMatch { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
}

View File

@@ -243,6 +243,71 @@ internal sealed record ScanCompletedEventPayload : OrchestratorEventPayload
[JsonPropertyName("report")]
[JsonPropertyOrder(10)]
public ReportDocumentDto Report { get; init; } = new();
/// <summary>
/// VEX gate evaluation summary (Sprint: SPRINT_20260106_003_002, Task: T024).
/// </summary>
[JsonPropertyName("vexGate")]
[JsonPropertyOrder(11)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public VexGateSummaryPayload? VexGate { get; init; }
}
/// <summary>
/// VEX gate evaluation summary for scan completion events.
/// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service, Task: T024
/// </summary>
internal sealed record VexGateSummaryPayload
{
/// <summary>
/// Total findings evaluated by the gate.
/// </summary>
[JsonPropertyName("totalFindings")]
[JsonPropertyOrder(0)]
public int TotalFindings { get; init; }
/// <summary>
/// Findings that passed (cleared by VEX evidence).
/// </summary>
[JsonPropertyName("passed")]
[JsonPropertyOrder(1)]
public int Passed { get; init; }
/// <summary>
/// Findings with warnings (partial evidence).
/// </summary>
[JsonPropertyName("warned")]
[JsonPropertyOrder(2)]
public int Warned { get; init; }
/// <summary>
/// Findings that were blocked (require attention).
/// </summary>
[JsonPropertyName("blocked")]
[JsonPropertyOrder(3)]
public int Blocked { get; init; }
/// <summary>
/// Whether the gate was bypassed for this scan.
/// </summary>
[JsonPropertyName("bypassed")]
[JsonPropertyOrder(4)]
public bool Bypassed { get; init; }
/// <summary>
/// Policy version used for evaluation.
/// </summary>
[JsonPropertyName("policyVersion")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyVersion { get; init; }
/// <summary>
/// When the gate evaluation was performed.
/// </summary>
[JsonPropertyName("evaluatedAt")]
[JsonPropertyOrder(6)]
public DateTimeOffset EvaluatedAt { get; init; }
}
internal sealed record ReportDeltaPayload

View File

@@ -67,50 +67,65 @@ public sealed record PolicyPreviewFindingDto
public sealed record PolicyPreviewVerdictDto
{
[JsonPropertyName("findingId")]
[JsonPropertyOrder(0)]
public string? FindingId { get; init; }
[JsonPropertyName("reachability")]
[JsonPropertyOrder(1)]
public string? Reachability { get; init; }
[JsonPropertyName("score")]
[JsonPropertyOrder(2)]
public double? Score { get; init; }
[JsonPropertyName("sourceTrust")]
[JsonPropertyOrder(3)]
public string? SourceTrust { get; init; }
[JsonPropertyName("status")]
[JsonPropertyOrder(4)]
public string? Status { get; init; }
[JsonPropertyName("ruleName")]
[JsonPropertyOrder(5)]
public string? RuleName { get; init; }
[JsonPropertyName("ruleAction")]
[JsonPropertyOrder(6)]
public string? RuleAction { get; init; }
[JsonPropertyName("notes")]
[JsonPropertyOrder(7)]
public string? Notes { get; init; }
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("configVersion")]
[JsonPropertyOrder(8)]
public string? ConfigVersion { get; init; }
[JsonPropertyName("inputs")]
[JsonPropertyOrder(9)]
public IReadOnlyDictionary<string, double>? Inputs { get; init; }
[JsonPropertyName("quietedBy")]
[JsonPropertyOrder(10)]
public string? QuietedBy { get; init; }
[JsonPropertyName("quiet")]
[JsonPropertyOrder(11)]
public bool? Quiet { get; init; }
[JsonPropertyName("unknownConfidence")]
[JsonPropertyOrder(12)]
public double? UnknownConfidence { get; init; }
[JsonPropertyName("confidenceBand")]
[JsonPropertyOrder(13)]
public string? ConfidenceBand { get; init; }
[JsonPropertyName("unknownAgeDays")]
[JsonPropertyOrder(14)]
public double? UnknownAgeDays { get; init; }
[JsonPropertyName("sourceTrust")]
public string? SourceTrust { get; init; }
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
public sealed record PolicyPreviewPolicyDto

View File

@@ -0,0 +1,322 @@
// -----------------------------------------------------------------------------
// RationaleContracts.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-020 - Integrate VerdictRationaleRenderer into Scanner.WebService
// Description: DTOs for verdict rationale endpoint responses.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Response for verdict rationale request.
/// </summary>
public sealed record VerdictRationaleResponseDto
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("finding_id")]
public required string FindingId { get; init; }
/// <summary>
/// Unique rationale ID (content-addressed).
/// </summary>
[JsonPropertyName("rationale_id")]
public required string RationaleId { get; init; }
/// <summary>
/// Schema version.
/// </summary>
[JsonPropertyName("schema_version")]
public string SchemaVersion { get; init; } = "1.0";
/// <summary>
/// Line 1: Evidence summary.
/// </summary>
[JsonPropertyName("evidence")]
public required RationaleEvidenceDto Evidence { get; init; }
/// <summary>
/// Line 2: Policy clause that triggered the decision.
/// </summary>
[JsonPropertyName("policy_clause")]
public required RationalePolicyClauseDto PolicyClause { get; init; }
/// <summary>
/// Line 3: Attestations and proofs.
/// </summary>
[JsonPropertyName("attestations")]
public required RationaleAttestationsDto Attestations { get; init; }
/// <summary>
/// Line 4: Final decision with recommendation.
/// </summary>
[JsonPropertyName("decision")]
public required RationaleDecisionDto Decision { get; init; }
/// <summary>
/// When the rationale was generated.
/// </summary>
[JsonPropertyName("generated_at")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Input digests for reproducibility verification.
/// </summary>
[JsonPropertyName("input_digests")]
public required RationaleInputDigestsDto InputDigests { get; init; }
}
/// <summary>
/// Line 1: Evidence summary DTO.
/// </summary>
public sealed record RationaleEvidenceDto
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("component_purl")]
public required string ComponentPurl { get; init; }
/// <summary>
/// Component version.
/// </summary>
[JsonPropertyName("component_version")]
public string? ComponentVersion { get; init; }
/// <summary>
/// Vulnerable function (if reachability analyzed).
/// </summary>
[JsonPropertyName("vulnerable_function")]
public string? VulnerableFunction { get; init; }
/// <summary>
/// Entry point from which vulnerable code is reachable.
/// </summary>
[JsonPropertyName("entry_point")]
public string? EntryPoint { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Line 2: Policy clause DTO.
/// </summary>
public sealed record RationalePolicyClauseDto
{
/// <summary>
/// Policy clause ID.
/// </summary>
[JsonPropertyName("clause_id")]
public required string ClauseId { get; init; }
/// <summary>
/// Rule description.
/// </summary>
[JsonPropertyName("rule_description")]
public required string RuleDescription { get; init; }
/// <summary>
/// Conditions that matched.
/// </summary>
[JsonPropertyName("conditions")]
public required IReadOnlyList<string> Conditions { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Line 3: Attestations DTO.
/// </summary>
public sealed record RationaleAttestationsDto
{
/// <summary>
/// Path witness reference.
/// </summary>
[JsonPropertyName("path_witness")]
public RationaleAttestationRefDto? PathWitness { get; init; }
/// <summary>
/// VEX statement references.
/// </summary>
[JsonPropertyName("vex_statements")]
public IReadOnlyList<RationaleAttestationRefDto>? VexStatements { get; init; }
/// <summary>
/// Provenance attestation reference.
/// </summary>
[JsonPropertyName("provenance")]
public RationaleAttestationRefDto? Provenance { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Attestation reference DTO.
/// </summary>
public sealed record RationaleAttestationRefDto
{
/// <summary>
/// Attestation ID.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Attestation type.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Content digest.
/// </summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
/// <summary>
/// Summary description.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
}
/// <summary>
/// Line 4: Decision DTO.
/// </summary>
public sealed record RationaleDecisionDto
{
/// <summary>
/// Final verdict (Affected, Not Affected, etc.).
/// </summary>
[JsonPropertyName("verdict")]
public required string Verdict { get; init; }
/// <summary>
/// Risk score (0-1).
/// </summary>
[JsonPropertyName("score")]
public double? Score { get; init; }
/// <summary>
/// Recommended action.
/// </summary>
[JsonPropertyName("recommendation")]
public required string Recommendation { get; init; }
/// <summary>
/// Mitigation guidance.
/// </summary>
[JsonPropertyName("mitigation")]
public RationaleMitigationDto? Mitigation { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Mitigation guidance DTO.
/// </summary>
public sealed record RationaleMitigationDto
{
/// <summary>
/// Recommended action.
/// </summary>
[JsonPropertyName("action")]
public required string Action { get; init; }
/// <summary>
/// Additional details.
/// </summary>
[JsonPropertyName("details")]
public string? Details { get; init; }
}
/// <summary>
/// Input digests for reproducibility.
/// </summary>
public sealed record RationaleInputDigestsDto
{
/// <summary>
/// Verdict attestation digest.
/// </summary>
[JsonPropertyName("verdict_digest")]
public required string VerdictDigest { get; init; }
/// <summary>
/// Policy snapshot digest.
/// </summary>
[JsonPropertyName("policy_digest")]
public string? PolicyDigest { get; init; }
/// <summary>
/// Evidence bundle digest.
/// </summary>
[JsonPropertyName("evidence_digest")]
public string? EvidenceDigest { get; init; }
}
/// <summary>
/// Request for rationale in specific format.
/// </summary>
public sealed record RationaleFormatRequestDto
{
/// <summary>
/// Desired format: json, markdown, plaintext.
/// </summary>
[JsonPropertyName("format")]
public string Format { get; init; } = "json";
}
/// <summary>
/// Plain text rationale response.
/// </summary>
public sealed record RationalePlainTextResponseDto
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("finding_id")]
public required string FindingId { get; init; }
/// <summary>
/// Rationale ID.
/// </summary>
[JsonPropertyName("rationale_id")]
public required string RationaleId { get; init; }
/// <summary>
/// Format of the content.
/// </summary>
[JsonPropertyName("format")]
public required string Format { get; init; }
/// <summary>
/// Rendered content.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
}

View File

@@ -12,8 +12,7 @@ public sealed record RubyPackagesResponse
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
= DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("packages")]
public IReadOnlyList<RubyPackageArtifact> Packages { get; init; }

View File

@@ -0,0 +1,319 @@
// -----------------------------------------------------------------------------
// SecretDetectionConfigContracts.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-005 - Create Settings CRUD API endpoints
// Description: API contracts for secret detection configuration.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
// ============================================================================
// Settings DTOs
// ============================================================================
/// <summary>
/// Request to get or update secret detection settings.
/// </summary>
public sealed record SecretDetectionSettingsDto
{
/// <summary>Whether secret detection is enabled.</summary>
public bool Enabled { get; init; }
/// <summary>Revelation policy configuration.</summary>
public required RevelationPolicyDto RevelationPolicy { get; init; }
/// <summary>Enabled rule categories.</summary>
public IReadOnlyList<string> EnabledRuleCategories { get; init; } = [];
/// <summary>Disabled rule IDs.</summary>
public IReadOnlyList<string> DisabledRuleIds { get; init; } = [];
/// <summary>Alert settings.</summary>
public required SecretAlertSettingsDto AlertSettings { get; init; }
/// <summary>Maximum file size to scan (bytes).</summary>
public long MaxFileSizeBytes { get; init; }
/// <summary>File extensions to exclude.</summary>
public IReadOnlyList<string> ExcludedFileExtensions { get; init; } = [];
/// <summary>Path patterns to exclude (glob).</summary>
public IReadOnlyList<string> ExcludedPaths { get; init; } = [];
/// <summary>Whether to scan binary files.</summary>
public bool ScanBinaryFiles { get; init; }
/// <summary>Whether to require signed rule bundles.</summary>
public bool RequireSignedRuleBundles { get; init; }
}
/// <summary>
/// Response containing settings with metadata.
/// </summary>
public sealed record SecretDetectionSettingsResponseDto
{
/// <summary>Tenant ID.</summary>
public Guid TenantId { get; init; }
/// <summary>Settings data.</summary>
public required SecretDetectionSettingsDto Settings { get; init; }
/// <summary>Version for optimistic concurrency.</summary>
public int Version { get; init; }
/// <summary>When settings were last updated.</summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>Who last updated settings.</summary>
public required string UpdatedBy { get; init; }
}
/// <summary>
/// Revelation policy configuration.
/// </summary>
public sealed record RevelationPolicyDto
{
/// <summary>Default masking policy.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public SecretRevelationPolicyType DefaultPolicy { get; init; }
/// <summary>Export masking policy.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public SecretRevelationPolicyType ExportPolicy { get; init; }
/// <summary>Roles allowed to see full secrets.</summary>
public IReadOnlyList<string> FullRevealRoles { get; init; } = [];
/// <summary>Characters to reveal at start/end for partial.</summary>
public int PartialRevealChars { get; init; }
/// <summary>Maximum mask characters.</summary>
public int MaxMaskChars { get; init; }
}
/// <summary>
/// Revelation policy types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SecretRevelationPolicyType
{
/// <summary>Fully masked (e.g., [REDACTED]).</summary>
FullMask = 0,
/// <summary>Partially revealed (e.g., AKIA****WXYZ).</summary>
PartialReveal = 1,
/// <summary>Full value shown (audit logged).</summary>
FullReveal = 2
}
/// <summary>
/// Alert settings configuration.
/// </summary>
public sealed record SecretAlertSettingsDto
{
/// <summary>Whether alerting is enabled.</summary>
public bool Enabled { get; init; }
/// <summary>Minimum severity to trigger alerts.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public SecretSeverityType MinimumAlertSeverity { get; init; }
/// <summary>Alert destinations.</summary>
public IReadOnlyList<SecretAlertDestinationDto> Destinations { get; init; } = [];
/// <summary>Maximum alerts per scan.</summary>
public int MaxAlertsPerScan { get; init; }
/// <summary>Deduplication window in minutes.</summary>
public int DeduplicationWindowMinutes { get; init; }
/// <summary>Include file path in alerts.</summary>
public bool IncludeFilePath { get; init; }
/// <summary>Include masked value in alerts.</summary>
public bool IncludeMaskedValue { get; init; }
/// <summary>Include image reference in alerts.</summary>
public bool IncludeImageRef { get; init; }
/// <summary>Custom alert message prefix.</summary>
public string? AlertMessagePrefix { get; init; }
}
/// <summary>
/// Secret severity levels.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SecretSeverityType
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3
}
/// <summary>
/// Alert channel types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AlertChannelType
{
Slack = 0,
Teams = 1,
Email = 2,
Webhook = 3,
PagerDuty = 4
}
/// <summary>
/// Alert destination configuration.
/// </summary>
public sealed record SecretAlertDestinationDto
{
/// <summary>Destination ID.</summary>
public Guid Id { get; init; }
/// <summary>Destination name.</summary>
public required string Name { get; init; }
/// <summary>Channel type.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public AlertChannelType ChannelType { get; init; }
/// <summary>Channel identifier (webhook URL, email, channel ID).</summary>
public required string ChannelId { get; init; }
/// <summary>Severity filter (if empty, uses MinimumAlertSeverity).</summary>
public IReadOnlyList<SecretSeverityType>? SeverityFilter { get; init; }
/// <summary>Rule category filter (if empty, alerts for all).</summary>
public IReadOnlyList<string>? RuleCategoryFilter { get; init; }
/// <summary>Whether this destination is active.</summary>
public bool IsActive { get; init; }
}
// ============================================================================
// Exception Pattern DTOs
// ============================================================================
/// <summary>
/// Request to create or update an exception pattern.
/// </summary>
public sealed record SecretExceptionPatternDto
{
/// <summary>Human-readable name.</summary>
public required string Name { get; init; }
/// <summary>Description of why this exception exists.</summary>
public required string Description { get; init; }
/// <summary>Regex pattern to match secret value.</summary>
public required string ValuePattern { get; init; }
/// <summary>Rule IDs this applies to (empty = all).</summary>
public IReadOnlyList<string> ApplicableRuleIds { get; init; } = [];
/// <summary>File path glob pattern.</summary>
public string? FilePathGlob { get; init; }
/// <summary>Business justification (required).</summary>
public required string Justification { get; init; }
/// <summary>Expiration date (null = permanent).</summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>Whether this exception is active.</summary>
public bool IsActive { get; init; }
}
/// <summary>
/// Response containing exception pattern with metadata.
/// </summary>
public sealed record SecretExceptionPatternResponseDto
{
/// <summary>Exception ID.</summary>
public Guid Id { get; init; }
/// <summary>Tenant ID.</summary>
public Guid TenantId { get; init; }
/// <summary>Exception data.</summary>
public required SecretExceptionPatternDto Pattern { get; init; }
/// <summary>Number of times matched.</summary>
public long MatchCount { get; init; }
/// <summary>Last match time.</summary>
public DateTimeOffset? LastMatchedAt { get; init; }
/// <summary>Creation time.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Creator.</summary>
public required string CreatedBy { get; init; }
/// <summary>Last update time.</summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>Last updater.</summary>
public string? UpdatedBy { get; init; }
}
/// <summary>
/// List response for exception patterns.
/// </summary>
public sealed record SecretExceptionPatternListResponseDto
{
/// <summary>Exception patterns.</summary>
public required IReadOnlyList<SecretExceptionPatternResponseDto> Patterns { get; init; }
/// <summary>Total count.</summary>
public int TotalCount { get; init; }
}
// ============================================================================
// Update Request DTOs
// ============================================================================
/// <summary>
/// Request to update settings with optimistic concurrency.
/// </summary>
public sealed record UpdateSecretDetectionSettingsRequestDto
{
/// <summary>Settings to apply.</summary>
public required SecretDetectionSettingsDto Settings { get; init; }
/// <summary>Expected version (for optimistic concurrency).</summary>
public int ExpectedVersion { get; init; }
}
/// <summary>
/// Available rule categories response.
/// </summary>
public sealed record RuleCategoriesResponseDto
{
/// <summary>All available categories.</summary>
public required IReadOnlyList<RuleCategoryDto> Categories { get; init; }
}
/// <summary>
/// Rule category information.
/// </summary>
public sealed record RuleCategoryDto
{
/// <summary>Category ID.</summary>
public required string Id { get; init; }
/// <summary>Display name.</summary>
public required string Name { get; init; }
/// <summary>Description.</summary>
public required string Description { get; init; }
/// <summary>Number of rules in this category.</summary>
public int RuleCount { get; init; }
}

View File

@@ -12,8 +12,7 @@ public sealed record SurfacePointersDto
[JsonPropertyName("generatedAt")]
[JsonPropertyOrder(1)]
public DateTimeOffset GeneratedAt { get; init; }
= DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("manifestDigest")]
[JsonPropertyOrder(2)]

View File

@@ -0,0 +1,264 @@
// -----------------------------------------------------------------------------
// VexGateContracts.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: DTOs for VEX gate results API endpoints.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Response for GET /scans/{scanId}/gate-results.
/// </summary>
public sealed record VexGateResultsResponse
{
/// <summary>
/// Scan identifier.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Summary of gate evaluation results.
/// </summary>
[JsonPropertyName("gateSummary")]
public required VexGateSummaryDto GateSummary { get; init; }
/// <summary>
/// Individual gated findings.
/// </summary>
[JsonPropertyName("gatedFindings")]
public required IReadOnlyList<GatedFindingDto> GatedFindings { get; init; }
/// <summary>
/// Policy version used for evaluation.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Whether gate was bypassed for this scan.
/// </summary>
[JsonPropertyName("bypassed")]
public bool Bypassed { get; init; }
}
/// <summary>
/// Summary of VEX gate evaluation.
/// </summary>
public sealed record VexGateSummaryDto
{
/// <summary>
/// Total number of findings evaluated.
/// </summary>
[JsonPropertyName("totalFindings")]
public int TotalFindings { get; init; }
/// <summary>
/// Number of findings that passed (cleared by VEX evidence).
/// </summary>
[JsonPropertyName("passed")]
public int Passed { get; init; }
/// <summary>
/// Number of findings with warnings (partial evidence).
/// </summary>
[JsonPropertyName("warned")]
public int Warned { get; init; }
/// <summary>
/// Number of findings blocked (requires attention).
/// </summary>
[JsonPropertyName("blocked")]
public int Blocked { get; init; }
/// <summary>
/// When the evaluation was performed (UTC ISO-8601).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Percentage of findings that passed.
/// </summary>
[JsonPropertyName("passRate")]
public double PassRate => TotalFindings > 0 ? (double)Passed / TotalFindings : 0;
/// <summary>
/// Percentage of findings that were blocked.
/// </summary>
[JsonPropertyName("blockRate")]
public double BlockRate => TotalFindings > 0 ? (double)Blocked / TotalFindings : 0;
}
/// <summary>
/// A finding with its gate evaluation result.
/// </summary>
public sealed record GatedFindingDto
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("findingId")]
public required string FindingId { get; init; }
/// <summary>
/// CVE or vulnerability identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Gate decision: Pass, Warn, or Block.
/// </summary>
[JsonPropertyName("decision")]
public required string Decision { get; init; }
/// <summary>
/// Human-readable explanation of the decision.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// ID of the policy rule that matched.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// Supporting evidence for the decision.
/// </summary>
[JsonPropertyName("evidence")]
public required GateEvidenceDto Evidence { get; init; }
/// <summary>
/// References to VEX statements that contributed to this decision.
/// </summary>
[JsonPropertyName("contributingStatements")]
public IReadOnlyList<VexStatementRefDto>? ContributingStatements { get; init; }
}
/// <summary>
/// Evidence supporting a gate decision.
/// </summary>
public sealed record GateEvidenceDto
{
/// <summary>
/// VEX status from vendor or authoritative source.
/// </summary>
[JsonPropertyName("vendorStatus")]
public string? VendorStatus { get; init; }
/// <summary>
/// Justification type from VEX statement.
/// </summary>
[JsonPropertyName("justification")]
public string? Justification { get; init; }
/// <summary>
/// Whether the vulnerable code is reachable.
/// </summary>
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
/// <summary>
/// Whether compensating controls mitigate the vulnerability.
/// </summary>
[JsonPropertyName("hasCompensatingControl")]
public bool HasCompensatingControl { get; init; }
/// <summary>
/// Confidence score in the gate decision (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidenceScore")]
public double ConfidenceScore { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
/// <summary>
/// Whether the vulnerability is exploitable.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
/// <summary>
/// Backport hints detected.
/// </summary>
[JsonPropertyName("backportHints")]
public IReadOnlyList<string>? BackportHints { get; init; }
}
/// <summary>
/// Reference to a VEX statement.
/// </summary>
public sealed record VexStatementRefDto
{
/// <summary>
/// Statement identifier.
/// </summary>
[JsonPropertyName("statementId")]
public required string StatementId { get; init; }
/// <summary>
/// Issuer identifier.
/// </summary>
[JsonPropertyName("issuerId")]
public required string IssuerId { get; init; }
/// <summary>
/// VEX status declared in the statement.
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// When the statement was issued (UTC ISO-8601).
/// </summary>
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight of this statement (0.0 to 1.0).
/// </summary>
[JsonPropertyName("trustWeight")]
public double TrustWeight { get; init; }
}
/// <summary>
/// Query parameters for filtering gate results.
/// </summary>
public sealed record VexGateResultsQuery
{
/// <summary>
/// Filter by gate decision (Pass, Warn, Block).
/// </summary>
public string? Decision { get; init; }
/// <summary>
/// Filter by minimum confidence score.
/// </summary>
public double? MinConfidence { get; init; }
/// <summary>
/// Maximum number of results to return.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Offset for pagination.
/// </summary>
public int? Offset { get; init; }
}

View File

@@ -22,6 +22,7 @@ public sealed class TriageController : ControllerBase
private readonly IUnifiedEvidenceService _evidenceService;
private readonly IReplayCommandService _replayService;
private readonly IEvidenceBundleExporter _bundleExporter;
private readonly IFindingRationaleService _rationaleService;
private readonly ILogger<TriageController> _logger;
public TriageController(
@@ -29,12 +30,14 @@ public sealed class TriageController : ControllerBase
IUnifiedEvidenceService evidenceService,
IReplayCommandService replayService,
IEvidenceBundleExporter bundleExporter,
IFindingRationaleService rationaleService,
ILogger<TriageController> logger)
{
_gatingService = gatingService ?? throw new ArgumentNullException(nameof(gatingService));
_evidenceService = evidenceService ?? throw new ArgumentNullException(nameof(evidenceService));
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
_bundleExporter = bundleExporter ?? throw new ArgumentNullException(nameof(bundleExporter));
_rationaleService = rationaleService ?? throw new ArgumentNullException(nameof(rationaleService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -365,6 +368,70 @@ public sealed class TriageController : ControllerBase
return Ok(result);
}
/// <summary>
/// Get structured verdict rationale for a finding.
/// </summary>
/// <remarks>
/// Returns a 4-line structured rationale:
/// 1. Evidence: CVE, component, reachability
/// 2. Policy clause: Rule that triggered the decision
/// 3. Attestations: Path witness, VEX statements, provenance
/// 4. Decision: Verdict, score, recommendation
/// </remarks>
/// <param name="findingId">Finding identifier.</param>
/// <param name="format">Output format: json (default), plaintext, markdown.</param>
/// <param name="ct">Cancellation token.</param>
/// <response code="200">Rationale retrieved.</response>
/// <response code="404">Finding not found.</response>
[HttpGet("findings/{findingId}/rationale")]
[ProducesResponseType(typeof(VerdictRationaleResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFindingRationaleAsync(
[FromRoute] string findingId,
[FromQuery] string format = "json",
CancellationToken ct = default)
{
_logger.LogDebug("Getting rationale for finding {FindingId} in format {Format}", findingId, format);
switch (format.ToLowerInvariant())
{
case "plaintext":
case "text":
var plainText = await _rationaleService.GetRationalePlainTextAsync(findingId, ct)
.ConfigureAwait(false);
if (plainText is null)
{
return NotFound(new { error = "Finding not found", findingId });
}
return Ok(plainText);
case "markdown":
case "md":
var markdown = await _rationaleService.GetRationaleMarkdownAsync(findingId, ct)
.ConfigureAwait(false);
if (markdown is null)
{
return NotFound(new { error = "Finding not found", findingId });
}
return Ok(markdown);
case "json":
default:
var rationale = await _rationaleService.GetRationaleAsync(findingId, ct)
.ConfigureAwait(false);
if (rationale is null)
{
return NotFound(new { error = "Finding not found", findingId });
}
// Set ETag for caching
Response.Headers.ETag = $"\"{rationale.RationaleId}\"";
Response.Headers.CacheControl = "private, max-age=300";
return Ok(rationale);
}
}
}
/// <summary>

View File

@@ -0,0 +1,143 @@
// -----------------------------------------------------------------------------
// VexGateController.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: API controller for VEX gate results and policy configuration.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Controllers;
/// <summary>
/// Controller for VEX gate results and policy operations.
/// </summary>
[ApiController]
[Route("api/v1/scans")]
[Produces("application/json")]
public sealed class VexGateController : ControllerBase
{
private readonly IVexGateQueryService _gateQueryService;
private readonly ILogger<VexGateController> _logger;
public VexGateController(
IVexGateQueryService gateQueryService,
ILogger<VexGateController> logger)
{
_gateQueryService = gateQueryService ?? throw new ArgumentNullException(nameof(gateQueryService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Get VEX gate evaluation results for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="decision">Filter by gate decision (Pass, Warn, Block).</param>
/// <param name="minConfidence">Filter by minimum confidence score.</param>
/// <param name="limit">Maximum number of results.</param>
/// <param name="offset">Offset for pagination.</param>
/// <response code="200">Gate results retrieved successfully.</response>
/// <response code="404">Scan not found.</response>
[HttpGet("{scanId}/gate-results")]
[ProducesResponseType(typeof(VexGateResultsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetGateResultsAsync(
[FromRoute] string scanId,
[FromQuery] string? decision = null,
[FromQuery] double? minConfidence = null,
[FromQuery] int? limit = null,
[FromQuery] int? offset = null,
CancellationToken ct = default)
{
_logger.LogDebug(
"Getting VEX gate results for scan {ScanId} (decision={Decision}, minConfidence={MinConfidence})",
scanId, decision, minConfidence);
var query = new VexGateResultsQuery
{
Decision = decision,
MinConfidence = minConfidence,
Limit = limit,
Offset = offset
};
var results = await _gateQueryService.GetGateResultsAsync(scanId, query, ct).ConfigureAwait(false);
if (results is null)
{
return NotFound(new { error = "Scan not found or gate results not available", scanId });
}
return Ok(results);
}
/// <summary>
/// Get the current VEX gate policy configuration.
/// </summary>
/// <param name="tenantId">Optional tenant identifier.</param>
/// <response code="200">Policy retrieved successfully.</response>
[HttpGet("gate-policy")]
[ProducesResponseType(typeof(VexGatePolicyDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetPolicyAsync(
[FromQuery] string? tenantId = null,
CancellationToken ct = default)
{
_logger.LogDebug("Getting VEX gate policy (tenantId={TenantId})", tenantId);
var policy = await _gateQueryService.GetPolicyAsync(tenantId, ct).ConfigureAwait(false);
return Ok(policy);
}
/// <summary>
/// Get gate results summary (counts only) for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <response code="200">Summary retrieved successfully.</response>
/// <response code="404">Scan not found.</response>
[HttpGet("{scanId}/gate-summary")]
[ProducesResponseType(typeof(VexGateSummaryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetGateSummaryAsync(
[FromRoute] string scanId,
CancellationToken ct = default)
{
_logger.LogDebug("Getting VEX gate summary for scan {ScanId}", scanId);
var results = await _gateQueryService.GetGateResultsAsync(scanId, null, ct).ConfigureAwait(false);
if (results is null)
{
return NotFound(new { error = "Scan not found or gate results not available", scanId });
}
return Ok(results.GateSummary);
}
/// <summary>
/// Get blocked findings only for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <response code="200">Blocked findings retrieved successfully.</response>
/// <response code="404">Scan not found.</response>
[HttpGet("{scanId}/gate-blocked")]
[ProducesResponseType(typeof(IReadOnlyList<GatedFindingDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetBlockedFindingsAsync(
[FromRoute] string scanId,
CancellationToken ct = default)
{
_logger.LogDebug("Getting blocked findings for scan {ScanId}", scanId);
var query = new VexGateResultsQuery { Decision = "Block" };
var results = await _gateQueryService.GetGateResultsAsync(scanId, query, ct).ConfigureAwait(false);
if (results is null)
{
return NotFound(new { error = "Scan not found or gate results not available", scanId });
}
return Ok(results.GatedFindings);
}
}

View File

@@ -151,6 +151,7 @@ public static class EpssEndpoints
private static async Task<IResult> GetHistory(
[FromRoute] string cveId,
[FromServices] IEpssProvider epssProvider,
[FromServices] TimeProvider timeProvider,
[FromQuery] string? startDate = null,
[FromQuery] string? endDate = null,
[FromQuery] int days = 30,
@@ -183,7 +184,7 @@ public static class EpssEndpoints
else
{
// Default to last N days
end = DateOnly.FromDateTime(DateTime.UtcNow);
end = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
start = end.AddDays(-days);
}
@@ -213,6 +214,7 @@ public static class EpssEndpoints
/// </summary>
private static async Task<IResult> GetStatus(
[FromServices] IEpssProvider epssProvider,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
@@ -222,7 +224,7 @@ public static class EpssEndpoints
{
Available = isAvailable,
LatestModelDate = modelDate?.ToString("yyyy-MM-dd"),
LastCheckedUtc = DateTimeOffset.UtcNow
LastCheckedUtc = timeProvider.GetUtcNow()
});
}
}

View File

@@ -60,6 +60,7 @@ internal static class EvidenceEndpoints
string scanId,
string findingId,
IEvidenceCompositionService evidenceService,
TimeProvider timeProvider,
HttpContext context,
CancellationToken cancellationToken)
{
@@ -108,7 +109,7 @@ internal static class EvidenceEndpoints
}
else if (evidence.Freshness.ExpiresAt.HasValue)
{
var timeUntilExpiry = evidence.Freshness.ExpiresAt.Value - DateTimeOffset.UtcNow;
var timeUntilExpiry = evidence.Freshness.ExpiresAt.Value - timeProvider.GetUtcNow();
if (timeUntilExpiry <= TimeSpan.FromDays(1))
{
context.Response.Headers["X-Evidence-Warning"] = "near-expiry";

View File

@@ -0,0 +1,336 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for per-layer SBOM access and composition recipes.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
internal static class LayerSbomEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static void MapLayerSbomEndpoints(this RouteGroupBuilder scansGroup)
{
ArgumentNullException.ThrowIfNull(scansGroup);
// GET /scans/{scanId}/layers - List layers with SBOM info
scansGroup.MapGet("/{scanId}/layers", HandleListLayersAsync)
.WithName("scanner.scans.layers.list")
.WithTags("Scans", "Layers")
.Produces<LayerListResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/layers/{layerDigest}/sbom - Get per-layer SBOM
scansGroup.MapGet("/{scanId}/layers/{layerDigest}/sbom", HandleGetLayerSbomAsync)
.WithName("scanner.scans.layers.sbom")
.WithTags("Scans", "Layers", "SBOM")
.Produces(StatusCodes.Status200OK, contentType: "application/json")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/composition-recipe - Get composition recipe
scansGroup.MapGet("/{scanId}/composition-recipe", HandleGetCompositionRecipeAsync)
.WithName("scanner.scans.composition-recipe")
.WithTags("Scans", "SBOM")
.Produces<CompositionRecipeResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// POST /scans/{scanId}/composition-recipe/verify - Verify composition recipe
scansGroup.MapPost("/{scanId}/composition-recipe/verify", HandleVerifyCompositionRecipeAsync)
.WithName("scanner.scans.composition-recipe.verify")
.WithTags("Scans", "SBOM")
.Produces<CompositionRecipeVerificationResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleListLayersAsync(
string scanId,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var layers = await layerSbomService.GetLayerSummariesAsync(parsed, cancellationToken).ConfigureAwait(false);
var response = new LayerListResponseDto
{
ScanId = scanId,
ImageDigest = snapshot.Target.Digest ?? string.Empty,
Layers = layers.Select(l => new LayerSummaryDto
{
Digest = l.LayerDigest,
Order = l.Order,
HasSbom = l.HasSbom,
ComponentCount = l.ComponentCount,
}).ToList(),
};
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleGetLayerSbomAsync(
string scanId,
string layerDigest,
string? format,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
if (string.IsNullOrWhiteSpace(layerDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid layer digest",
StatusCodes.Status400BadRequest,
detail: "Layer digest is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
// Normalize layer digest (URL decode if needed)
var normalizedDigest = Uri.UnescapeDataString(layerDigest);
// Determine format: cdx (default) or spdx
var sbomFormat = string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase)
? "spdx"
: "cdx";
var sbomBytes = await layerSbomService.GetLayerSbomAsync(
parsed,
normalizedDigest,
sbomFormat,
cancellationToken).ConfigureAwait(false);
if (sbomBytes is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Layer SBOM not found",
StatusCodes.Status404NotFound,
detail: $"SBOM for layer {normalizedDigest} could not be found.");
}
var contentType = sbomFormat == "spdx"
? "application/spdx+json; version=3.0.1"
: "application/vnd.cyclonedx+json; version=1.7";
var contentDigest = ComputeSha256(sbomBytes);
context.Response.Headers.ETag = $"\"{contentDigest}\"";
context.Response.Headers["X-StellaOps-Layer-Digest"] = normalizedDigest;
context.Response.Headers["X-StellaOps-Format"] = sbomFormat == "spdx" ? "spdx-3.0.1" : "cyclonedx-1.7";
context.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Bytes(sbomBytes, contentType);
}
private static async Task<IResult> HandleGetCompositionRecipeAsync(
string scanId,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var recipe = await layerSbomService.GetCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false);
if (recipe is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Composition recipe not found",
StatusCodes.Status404NotFound,
detail: "Composition recipe for this scan is not available.");
}
var response = new CompositionRecipeResponseDto
{
ScanId = scanId,
ImageDigest = snapshot.Target.Digest ?? string.Empty,
CreatedAt = recipe.CreatedAt,
Recipe = new CompositionRecipeDto
{
Version = recipe.Recipe.Version,
GeneratorName = recipe.Recipe.GeneratorName,
GeneratorVersion = recipe.Recipe.GeneratorVersion,
Layers = recipe.Recipe.Layers.Select(l => new CompositionRecipeLayerDto
{
Digest = l.Digest,
Order = l.Order,
FragmentDigest = l.FragmentDigest,
SbomDigests = new LayerSbomDigestsDto
{
CycloneDx = l.SbomDigests.CycloneDx,
Spdx = l.SbomDigests.Spdx,
},
ComponentCount = l.ComponentCount,
}).ToList(),
MerkleRoot = recipe.Recipe.MerkleRoot,
AggregatedSbomDigests = new AggregatedSbomDigestsDto
{
CycloneDx = recipe.Recipe.AggregatedSbomDigests.CycloneDx,
Spdx = recipe.Recipe.AggregatedSbomDigests.Spdx,
},
},
};
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleVerifyCompositionRecipeAsync(
string scanId,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var verificationResult = await layerSbomService.VerifyCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false);
if (verificationResult is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Composition recipe not found",
StatusCodes.Status404NotFound,
detail: "Composition recipe for this scan is not available for verification.");
}
var response = new CompositionRecipeVerificationResponseDto
{
Valid = verificationResult.Valid,
MerkleRootMatch = verificationResult.MerkleRootMatch,
LayerDigestsMatch = verificationResult.LayerDigestsMatch,
Errors = verificationResult.Errors.IsDefaultOrEmpty ? null : verificationResult.Errors.ToList(),
};
return Json(response, StatusCodes.Status200OK);
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
private static string ComputeSha256(byte[] bytes)
{
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -82,12 +82,14 @@ internal static class ScanEndpoints
// Register additional scan-related endpoints
scans.MapCallGraphEndpoints();
scans.MapSbomEndpoints();
scans.MapLayerSbomEndpoints();
scans.MapReachabilityEndpoints();
scans.MapReachabilityDriftScanEndpoints();
scans.MapExportEndpoints();
scans.MapEvidenceEndpoints();
scans.MapApprovalEndpoints();
scans.MapManifestEndpoints();
scans.MapLayerSbomEndpoints(); // Sprint: SPRINT_20260106_003_001
}
private static async Task<IResult> HandleSubmitAsync(

View File

@@ -1,591 +1,373 @@
// -----------------------------------------------------------------------------
// SecretDetectionSettingsEndpoints.cs
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-005 - Create Settings CRUD API endpoints
// Description: HTTP endpoints for secret detection configuration.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Core.Secrets.Configuration;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for secret detection settings management.
/// Endpoints for secret detection configuration.
/// Per SPRINT_20260104_006_BE.
/// </summary>
public static class SecretDetectionSettingsEndpoints
internal static class SecretDetectionSettingsEndpoints
{
/// <summary>
/// Maps secret detection settings endpoints.
/// </summary>
public static RouteGroupBuilder MapSecretDetectionSettingsEndpoints(this IEndpointRouteBuilder endpoints)
public static void MapSecretDetectionSettingsEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/secrets/config")
{
var group = endpoints.MapGroup("/api/v1/tenants/{tenantId:guid}/settings/secret-detection")
.WithTags("Secret Detection Settings")
.WithOpenApi();
ArgumentNullException.ThrowIfNull(apiGroup);
// Settings CRUD
group.MapGet("/", GetSettings)
.WithName("GetSecretDetectionSettings")
.WithSummary("Get secret detection settings for a tenant")
.Produces<SecretDetectionSettingsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
var settings = apiGroup.MapGroup($"{prefix}/settings")
.WithTags("Secret Detection Settings");
group.MapPut("/", UpdateSettings)
.WithName("UpdateSecretDetectionSettings")
.WithSummary("Update secret detection settings for a tenant")
.Produces<SecretDetectionSettingsResponse>(StatusCodes.Status200OK)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest);
var exceptions = apiGroup.MapGroup($"{prefix}/exceptions")
.WithTags("Secret Detection Exceptions");
group.MapPatch("/", PatchSettings)
.WithName("PatchSecretDetectionSettings")
.WithSummary("Partially update secret detection settings")
.Produces<SecretDetectionSettingsResponse>(StatusCodes.Status200OK)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest);
var rules = apiGroup.MapGroup($"{prefix}/rules")
.WithTags("Secret Detection Rules");
// Exceptions management
group.MapGet("/exceptions", GetExceptions)
.WithName("GetSecretDetectionExceptions")
.WithSummary("Get all exception patterns for a tenant");
// ====================================================================
// Settings Endpoints
// ====================================================================
group.MapPost("/exceptions", AddException)
.WithName("AddSecretDetectionException")
.WithSummary("Add a new exception pattern")
.Produces<SecretExceptionPatternResponse>(StatusCodes.Status201Created)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest);
// GET /v1/secrets/config/settings/{tenantId} - Get settings
settings.MapGet("/{tenantId:guid}", HandleGetSettingsAsync)
.WithName("scanner.secrets.settings.get")
.WithDescription("Get secret detection settings for a tenant.")
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretSettingsRead);
group.MapPut("/exceptions/{exceptionId:guid}", UpdateException)
.WithName("UpdateSecretDetectionException")
.WithSummary("Update an exception pattern")
.Produces<SecretExceptionPatternResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// POST /v1/secrets/config/settings/{tenantId} - Create default settings
settings.MapPost("/{tenantId:guid}", HandleCreateSettingsAsync)
.WithName("scanner.secrets.settings.create")
.WithDescription("Create default secret detection settings for a tenant.")
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.SecretSettingsWrite);
group.MapDelete("/exceptions/{exceptionId:guid}", RemoveException)
.WithName("RemoveSecretDetectionException")
.WithSummary("Remove an exception pattern")
// PUT /v1/secrets/config/settings/{tenantId} - Update settings
settings.MapPut("/{tenantId:guid}", HandleUpdateSettingsAsync)
.WithName("scanner.secrets.settings.update")
.WithDescription("Update secret detection settings for a tenant.")
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.SecretSettingsWrite);
// ====================================================================
// Exception Pattern Endpoints
// ====================================================================
// GET /v1/secrets/config/exceptions/{tenantId} - List exception patterns
exceptions.MapGet("/{tenantId:guid}", HandleListExceptionsAsync)
.WithName("scanner.secrets.exceptions.list")
.WithDescription("List secret exception patterns for a tenant.")
.Produces<SecretExceptionPatternListResponseDto>(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.SecretExceptionsRead);
// GET /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Get exception pattern
exceptions.MapGet("/{tenantId:guid}/{exceptionId:guid}", HandleGetExceptionAsync)
.WithName("scanner.secrets.exceptions.get")
.WithDescription("Get a specific secret exception pattern.")
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretExceptionsRead);
// POST /v1/secrets/config/exceptions/{tenantId} - Create exception pattern
exceptions.MapPost("/{tenantId:guid}", HandleCreateExceptionAsync)
.WithName("scanner.secrets.exceptions.create")
.WithDescription("Create a new secret exception pattern.")
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
// PUT /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Update exception pattern
exceptions.MapPut("/{tenantId:guid}/{exceptionId:guid}", HandleUpdateExceptionAsync)
.WithName("scanner.secrets.exceptions.update")
.WithDescription("Update a secret exception pattern.")
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
// DELETE /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Delete exception pattern
exceptions.MapDelete("/{tenantId:guid}/{exceptionId:guid}", HandleDeleteExceptionAsync)
.WithName("scanner.secrets.exceptions.delete")
.WithDescription("Delete a secret exception pattern.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
// Alert destinations
group.MapGet("/alert-destinations", GetAlertDestinations)
.WithName("GetSecretAlertDestinations")
.WithSummary("Get all alert destinations for a tenant");
// ====================================================================
// Rule Catalog Endpoints
// ====================================================================
group.MapPost("/alert-destinations", AddAlertDestination)
.WithName("AddSecretAlertDestination")
.WithSummary("Add a new alert destination")
.Produces<SecretAlertDestinationResponse>(StatusCodes.Status201Created);
group.MapDelete("/alert-destinations/{destinationId:guid}", RemoveAlertDestination)
.WithName("RemoveSecretAlertDestination")
.WithSummary("Remove an alert destination")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/alert-destinations/{destinationId:guid}/test", TestAlertDestination)
.WithName("TestSecretAlertDestination")
.WithSummary("Test an alert destination")
.Produces<AlertDestinationTestResultResponse>(StatusCodes.Status200OK);
// Rule categories
group.MapGet("/rule-categories", GetRuleCategories)
.WithName("GetSecretRuleCategories")
.WithSummary("Get available rule categories");
return group;
// GET /v1/secrets/config/rules/categories - Get available rule categories
rules.MapGet("/categories", HandleGetRuleCategoriesAsync)
.WithName("scanner.secrets.rules.categories")
.WithDescription("Get available secret detection rule categories.")
.Produces<RuleCategoriesResponseDto>(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.SecretSettingsRead);
}
private static async Task<Results<Ok<SecretDetectionSettingsResponse>, NotFound>> GetSettings(
[FromRoute] Guid tenantId,
[FromServices] ISecretDetectionSettingsRepository repository,
CancellationToken ct)
// ========================================================================
// Settings Handlers
// ========================================================================
private static async Task<IResult> HandleGetSettingsAsync(
Guid tenantId,
ISecretDetectionSettingsService service,
CancellationToken cancellationToken)
{
var settings = await repository.GetByTenantIdAsync(tenantId, ct);
var settings = await service.GetSettingsAsync(tenantId, cancellationToken);
if (settings is null)
return TypedResults.NotFound();
return TypedResults.Ok(SecretDetectionSettingsResponse.FromSettings(settings));
}
private static async Task<Results<Ok<SecretDetectionSettingsResponse>, BadRequest<ValidationProblemDetails>>> UpdateSettings(
[FromRoute] Guid tenantId,
[FromBody] UpdateSecretDetectionSettingsRequest request,
[FromServices] ISecretDetectionSettingsRepository repository,
[FromServices] TimeProvider timeProvider,
HttpContext httpContext,
CancellationToken ct)
{
var userId = httpContext.User.Identity?.Name ?? "anonymous";
var settings = new SecretDetectionSettings
{
TenantId = tenantId,
Enabled = request.Enabled,
RevelationPolicy = request.RevelationPolicy,
RevelationConfig = request.RevelationConfig ?? RevelationPolicyConfig.Default,
EnabledRuleCategories = [.. request.EnabledRuleCategories],
Exceptions = [], // Managed separately
AlertSettings = request.AlertSettings ?? SecretAlertSettings.Default,
UpdatedAt = timeProvider.GetUtcNow(),
UpdatedBy = userId
};
var updated = await repository.UpsertAsync(settings, ct);
return TypedResults.Ok(SecretDetectionSettingsResponse.FromSettings(updated));
}
private static async Task<Results<Ok<SecretDetectionSettingsResponse>, BadRequest<ValidationProblemDetails>, NotFound>> PatchSettings(
[FromRoute] Guid tenantId,
[FromBody] PatchSecretDetectionSettingsRequest request,
[FromServices] ISecretDetectionSettingsRepository repository,
[FromServices] TimeProvider timeProvider,
HttpContext httpContext,
CancellationToken ct)
{
var existing = await repository.GetByTenantIdAsync(tenantId, ct);
if (existing is null)
return TypedResults.NotFound();
var userId = httpContext.User.Identity?.Name ?? "anonymous";
var settings = existing with
{
Enabled = request.Enabled ?? existing.Enabled,
RevelationPolicy = request.RevelationPolicy ?? existing.RevelationPolicy,
RevelationConfig = request.RevelationConfig ?? existing.RevelationConfig,
EnabledRuleCategories = request.EnabledRuleCategories is not null
? [.. request.EnabledRuleCategories]
: existing.EnabledRuleCategories,
AlertSettings = request.AlertSettings ?? existing.AlertSettings,
UpdatedAt = timeProvider.GetUtcNow(),
UpdatedBy = userId
};
var updated = await repository.UpsertAsync(settings, ct);
return TypedResults.Ok(SecretDetectionSettingsResponse.FromSettings(updated));
}
private static async Task<Ok<IReadOnlyList<SecretExceptionPatternResponse>>> GetExceptions(
[FromRoute] Guid tenantId,
[FromServices] ISecretDetectionSettingsRepository repository,
CancellationToken ct)
{
var exceptions = await repository.GetExceptionsAsync(tenantId, ct);
return TypedResults.Ok<IReadOnlyList<SecretExceptionPatternResponse>>(
exceptions.Select(SecretExceptionPatternResponse.FromPattern).ToList());
}
private static async Task<Results<Created<SecretExceptionPatternResponse>, BadRequest<ValidationProblemDetails>>> AddException(
[FromRoute] Guid tenantId,
[FromBody] CreateSecretExceptionRequest request,
[FromServices] ISecretDetectionSettingsRepository repository,
[FromServices] TimeProvider timeProvider,
[FromServices] StellaOps.Determinism.IGuidProvider guidProvider,
HttpContext httpContext,
CancellationToken ct)
{
var userId = httpContext.User.Identity?.Name ?? "anonymous";
var now = timeProvider.GetUtcNow();
var exception = new SecretExceptionPattern
{
Id = guidProvider.NewGuid(),
Name = request.Name,
Description = request.Description,
Pattern = request.Pattern,
MatchType = request.MatchType,
ApplicableRuleIds = request.ApplicableRuleIds is not null ? [.. request.ApplicableRuleIds] : null,
FilePathGlob = request.FilePathGlob,
Justification = request.Justification,
ExpiresAt = request.ExpiresAt,
CreatedAt = now,
CreatedBy = userId,
IsActive = true
};
var errors = exception.Validate();
if (errors.Count > 0)
{
var problemDetails = new ValidationProblemDetails(
new Dictionary<string, string[]> { ["Pattern"] = errors.ToArray() });
return TypedResults.BadRequest(problemDetails);
return Results.NotFound(new
{
type = "not-found",
title = "Settings not found",
detail = $"No secret detection settings found for tenant '{tenantId}'."
});
}
var created = await repository.AddExceptionAsync(tenantId, exception, ct);
return TypedResults.Created(
$"/api/v1/tenants/{tenantId}/settings/secret-detection/exceptions/{created.Id}",
SecretExceptionPatternResponse.FromPattern(created));
return Results.Ok(settings);
}
private static async Task<Results<Ok<SecretExceptionPatternResponse>, NotFound, BadRequest<ValidationProblemDetails>>> UpdateException(
[FromRoute] Guid tenantId,
[FromRoute] Guid exceptionId,
[FromBody] UpdateSecretExceptionRequest request,
[FromServices] ISecretDetectionSettingsRepository repository,
[FromServices] TimeProvider timeProvider,
HttpContext httpContext,
CancellationToken ct)
private static async Task<IResult> HandleCreateSettingsAsync(
Guid tenantId,
ISecretDetectionSettingsService service,
HttpContext context,
CancellationToken cancellationToken)
{
var userId = httpContext.User.Identity?.Name ?? "anonymous";
var now = timeProvider.GetUtcNow();
var exception = new SecretExceptionPattern
// Check if settings already exist
var existing = await service.GetSettingsAsync(tenantId, cancellationToken);
if (existing is not null)
{
Id = exceptionId,
Name = request.Name,
Description = request.Description,
Pattern = request.Pattern,
MatchType = request.MatchType,
ApplicableRuleIds = request.ApplicableRuleIds is not null ? [.. request.ApplicableRuleIds] : null,
FilePathGlob = request.FilePathGlob,
Justification = request.Justification,
ExpiresAt = request.ExpiresAt,
CreatedAt = DateTimeOffset.MinValue, // Will be preserved by repository
CreatedBy = string.Empty, // Will be preserved by repository
ModifiedAt = now,
ModifiedBy = userId,
IsActive = request.IsActive
};
var errors = exception.Validate();
if (errors.Count > 0)
{
var problemDetails = new ValidationProblemDetails(
new Dictionary<string, string[]> { ["Pattern"] = errors.ToArray() });
return TypedResults.BadRequest(problemDetails);
return Results.Conflict(new
{
type = "conflict",
title = "Settings already exist",
detail = $"Secret detection settings already exist for tenant '{tenantId}'."
});
}
var updated = await repository.UpdateExceptionAsync(tenantId, exception, ct);
if (updated is null)
return TypedResults.NotFound();
var username = context.User.Identity?.Name ?? "system";
var settings = await service.CreateSettingsAsync(tenantId, username, cancellationToken);
return TypedResults.Ok(SecretExceptionPatternResponse.FromPattern(updated));
return Results.Created($"/v1/secrets/config/settings/{tenantId}", settings);
}
private static async Task<Results<NoContent, NotFound>> RemoveException(
[FromRoute] Guid tenantId,
[FromRoute] Guid exceptionId,
[FromServices] ISecretDetectionSettingsRepository repository,
CancellationToken ct)
{
var removed = await repository.RemoveExceptionAsync(tenantId, exceptionId, ct);
return removed ? TypedResults.NoContent() : TypedResults.NotFound();
}
private static async Task<Ok<IReadOnlyList<SecretAlertDestinationResponse>>> GetAlertDestinations(
[FromRoute] Guid tenantId,
[FromServices] ISecretDetectionSettingsRepository repository,
CancellationToken ct)
{
var settings = await repository.GetByTenantIdAsync(tenantId, ct);
var destinations = settings?.AlertSettings.Destinations ?? [];
return TypedResults.Ok<IReadOnlyList<SecretAlertDestinationResponse>>(
destinations.Select(SecretAlertDestinationResponse.FromDestination).ToList());
}
private static async Task<Results<Created<SecretAlertDestinationResponse>, BadRequest>> AddAlertDestination(
[FromRoute] Guid tenantId,
[FromBody] CreateAlertDestinationRequest request,
[FromServices] ISecretDetectionSettingsRepository repository,
[FromServices] TimeProvider timeProvider,
[FromServices] StellaOps.Determinism.IGuidProvider guidProvider,
CancellationToken ct)
{
var destination = new SecretAlertDestination
{
Id = guidProvider.NewGuid(),
Name = request.Name,
ChannelType = request.ChannelType,
ChannelId = request.ChannelId,
SeverityFilter = request.SeverityFilter is not null ? [.. request.SeverityFilter] : null,
RuleCategoryFilter = request.RuleCategoryFilter is not null ? [.. request.RuleCategoryFilter] : null,
Enabled = true,
CreatedAt = timeProvider.GetUtcNow()
};
var created = await repository.AddAlertDestinationAsync(tenantId, destination, ct);
return TypedResults.Created(
$"/api/v1/tenants/{tenantId}/settings/secret-detection/alert-destinations/{created.Id}",
SecretAlertDestinationResponse.FromDestination(created));
}
private static async Task<Results<NoContent, NotFound>> RemoveAlertDestination(
[FromRoute] Guid tenantId,
[FromRoute] Guid destinationId,
[FromServices] ISecretDetectionSettingsRepository repository,
CancellationToken ct)
{
var removed = await repository.RemoveAlertDestinationAsync(tenantId, destinationId, ct);
return removed ? TypedResults.NoContent() : TypedResults.NotFound();
}
private static async Task<Ok<AlertDestinationTestResultResponse>> TestAlertDestination(
[FromRoute] Guid tenantId,
[FromRoute] Guid destinationId,
[FromServices] ISecretDetectionSettingsRepository repository,
[FromServices] ISecretAlertService alertService,
[FromServices] TimeProvider timeProvider,
CancellationToken ct)
{
var result = await alertService.TestDestinationAsync(tenantId, destinationId, ct);
await repository.UpdateAlertDestinationTestResultAsync(tenantId, destinationId, result, ct);
return TypedResults.Ok(new AlertDestinationTestResultResponse
{
Success = result.Success,
TestedAt = result.TestedAt,
ErrorMessage = result.ErrorMessage,
ResponseTimeMs = result.ResponseTimeMs
});
}
private static Ok<RuleCategoriesResponse> GetRuleCategories()
{
return TypedResults.Ok(new RuleCategoriesResponse
{
Available = SecretDetectionSettings.AllRuleCategories,
Default = SecretDetectionSettings.DefaultRuleCategories
});
}
}
#region Request/Response Models
/// <summary>
/// Response containing secret detection settings.
/// </summary>
public sealed record SecretDetectionSettingsResponse
{
public Guid TenantId { get; init; }
public bool Enabled { get; init; }
public SecretRevelationPolicy RevelationPolicy { get; init; }
public RevelationPolicyConfig RevelationConfig { get; init; } = null!;
public IReadOnlyList<string> EnabledRuleCategories { get; init; } = [];
public int ExceptionCount { get; init; }
public SecretAlertSettings AlertSettings { get; init; } = null!;
public DateTimeOffset UpdatedAt { get; init; }
public string UpdatedBy { get; init; } = null!;
public static SecretDetectionSettingsResponse FromSettings(SecretDetectionSettings settings) => new()
{
TenantId = settings.TenantId,
Enabled = settings.Enabled,
RevelationPolicy = settings.RevelationPolicy,
RevelationConfig = settings.RevelationConfig,
EnabledRuleCategories = [.. settings.EnabledRuleCategories],
ExceptionCount = settings.Exceptions.Length,
AlertSettings = settings.AlertSettings,
UpdatedAt = settings.UpdatedAt,
UpdatedBy = settings.UpdatedBy
};
}
/// <summary>
/// Request to update secret detection settings.
/// </summary>
public sealed record UpdateSecretDetectionSettingsRequest
{
public bool Enabled { get; init; }
public SecretRevelationPolicy RevelationPolicy { get; init; }
public RevelationPolicyConfig? RevelationConfig { get; init; }
public IReadOnlyList<string> EnabledRuleCategories { get; init; } = [];
public SecretAlertSettings? AlertSettings { get; init; }
}
/// <summary>
/// Request to partially update secret detection settings.
/// </summary>
public sealed record PatchSecretDetectionSettingsRequest
{
public bool? Enabled { get; init; }
public SecretRevelationPolicy? RevelationPolicy { get; init; }
public RevelationPolicyConfig? RevelationConfig { get; init; }
public IReadOnlyList<string>? EnabledRuleCategories { get; init; }
public SecretAlertSettings? AlertSettings { get; init; }
}
/// <summary>
/// Response containing an exception pattern.
/// </summary>
public sealed record SecretExceptionPatternResponse
{
public Guid Id { get; init; }
public string Name { get; init; } = null!;
public string Description { get; init; } = null!;
public string Pattern { get; init; } = null!;
public SecretExceptionMatchType MatchType { get; init; }
public IReadOnlyList<string>? ApplicableRuleIds { get; init; }
public string? FilePathGlob { get; init; }
public string Justification { get; init; } = null!;
public DateTimeOffset? ExpiresAt { get; init; }
public bool IsActive { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string CreatedBy { get; init; } = null!;
public DateTimeOffset? ModifiedAt { get; init; }
public string? ModifiedBy { get; init; }
public static SecretExceptionPatternResponse FromPattern(SecretExceptionPattern pattern) => new()
{
Id = pattern.Id,
Name = pattern.Name,
Description = pattern.Description,
Pattern = pattern.Pattern,
MatchType = pattern.MatchType,
ApplicableRuleIds = pattern.ApplicableRuleIds is not null ? [.. pattern.ApplicableRuleIds] : null,
FilePathGlob = pattern.FilePathGlob,
Justification = pattern.Justification,
ExpiresAt = pattern.ExpiresAt,
IsActive = pattern.IsActive,
CreatedAt = pattern.CreatedAt,
CreatedBy = pattern.CreatedBy,
ModifiedAt = pattern.ModifiedAt,
ModifiedBy = pattern.ModifiedBy
};
}
/// <summary>
/// Request to create a new exception pattern.
/// </summary>
public sealed record CreateSecretExceptionRequest
{
public required string Name { get; init; }
public required string Description { get; init; }
public required string Pattern { get; init; }
public SecretExceptionMatchType MatchType { get; init; } = SecretExceptionMatchType.Regex;
public IReadOnlyList<string>? ApplicableRuleIds { get; init; }
public string? FilePathGlob { get; init; }
public required string Justification { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Request to update an exception pattern.
/// </summary>
public sealed record UpdateSecretExceptionRequest
{
public required string Name { get; init; }
public required string Description { get; init; }
public required string Pattern { get; init; }
public SecretExceptionMatchType MatchType { get; init; }
public IReadOnlyList<string>? ApplicableRuleIds { get; init; }
public string? FilePathGlob { get; init; }
public required string Justification { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public bool IsActive { get; init; } = true;
}
/// <summary>
/// Response containing an alert destination.
/// </summary>
public sealed record SecretAlertDestinationResponse
{
public Guid Id { get; init; }
public string Name { get; init; } = null!;
public AlertChannelType ChannelType { get; init; }
public string ChannelId { get; init; } = null!;
public IReadOnlyList<StellaOps.Scanner.Analyzers.Secrets.SecretSeverity>? SeverityFilter { get; init; }
public IReadOnlyList<string>? RuleCategoryFilter { get; init; }
public bool Enabled { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastTestedAt { get; init; }
public AlertDestinationTestResult? LastTestResult { get; init; }
public static SecretAlertDestinationResponse FromDestination(SecretAlertDestination destination) => new()
{
Id = destination.Id,
Name = destination.Name,
ChannelType = destination.ChannelType,
ChannelId = destination.ChannelId,
SeverityFilter = destination.SeverityFilter is not null ? [.. destination.SeverityFilter] : null,
RuleCategoryFilter = destination.RuleCategoryFilter is not null ? [.. destination.RuleCategoryFilter] : null,
Enabled = destination.Enabled,
CreatedAt = destination.CreatedAt,
LastTestedAt = destination.LastTestedAt,
LastTestResult = destination.LastTestResult
};
}
/// <summary>
/// Request to create an alert destination.
/// </summary>
public sealed record CreateAlertDestinationRequest
{
public required string Name { get; init; }
public required AlertChannelType ChannelType { get; init; }
public required string ChannelId { get; init; }
public IReadOnlyList<StellaOps.Scanner.Analyzers.Secrets.SecretSeverity>? SeverityFilter { get; init; }
public IReadOnlyList<string>? RuleCategoryFilter { get; init; }
}
/// <summary>
/// Response containing test result.
/// </summary>
public sealed record AlertDestinationTestResultResponse
{
public bool Success { get; init; }
public DateTimeOffset TestedAt { get; init; }
public string? ErrorMessage { get; init; }
public int? ResponseTimeMs { get; init; }
}
/// <summary>
/// Response containing available rule categories.
/// </summary>
public sealed record RuleCategoriesResponse
{
public IReadOnlyList<string> Available { get; init; } = [];
public IReadOnlyList<string> Default { get; init; } = [];
}
#endregion
/// <summary>
/// Service for testing and sending secret alerts.
/// </summary>
public interface ISecretAlertService
{
/// <summary>
/// Tests an alert destination.
/// </summary>
Task<AlertDestinationTestResult> TestDestinationAsync(
private static async Task<IResult> HandleUpdateSettingsAsync(
Guid tenantId,
Guid destinationId,
CancellationToken ct = default);
UpdateSecretDetectionSettingsRequestDto request,
ISecretDetectionSettingsService service,
HttpContext context,
CancellationToken cancellationToken)
{
var username = context.User.Identity?.Name ?? "system";
var (success, settings, error) = await service.UpdateSettingsAsync(
tenantId,
request.Settings,
request.ExpectedVersion,
username,
cancellationToken);
/// <summary>
/// Sends an alert for secret findings.
/// </summary>
Task SendAlertAsync(
if (!success)
{
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
{
return Results.NotFound(new
{
type = "not-found",
title = "Settings not found",
detail = error
});
}
if (error?.Contains("conflict", StringComparison.OrdinalIgnoreCase) == true)
{
return Results.Conflict(new
{
type = "conflict",
title = "Version conflict",
detail = error
});
}
return Results.BadRequest(new
{
type = "validation-error",
title = "Validation failed",
detail = error
});
}
return Results.Ok(settings);
}
// ========================================================================
// Exception Pattern Handlers
// ========================================================================
private static async Task<IResult> HandleListExceptionsAsync(
Guid tenantId,
SecretFindingAlertEvent alertEvent,
CancellationToken ct = default);
}
ISecretExceptionPatternService service,
bool includeInactive = false,
CancellationToken cancellationToken = default)
{
var patterns = await service.GetPatternsAsync(tenantId, includeInactive, cancellationToken);
return Results.Ok(patterns);
}
/// <summary>
/// Event representing a secret finding alert.
/// </summary>
public sealed record SecretFindingAlertEvent
{
public required Guid EventId { get; init; }
public required Guid TenantId { get; init; }
public required Guid ScanId { get; init; }
public required string ImageRef { get; init; }
public required StellaOps.Scanner.Analyzers.Secrets.SecretSeverity Severity { get; init; }
public required string RuleId { get; init; }
public required string RuleName { get; init; }
public required string RuleCategory { get; init; }
public required string FilePath { get; init; }
public required int LineNumber { get; init; }
public required string MaskedValue { get; init; }
public required DateTimeOffset DetectedAt { get; init; }
public required string ScanTriggeredBy { get; init; }
private static async Task<IResult> HandleGetExceptionAsync(
Guid tenantId,
Guid exceptionId,
ISecretExceptionPatternService service,
CancellationToken cancellationToken)
{
var pattern = await service.GetPatternAsync(exceptionId, cancellationToken);
/// <summary>
/// Deduplication key for rate limiting.
/// </summary>
public string DeduplicationKey => $"{TenantId}:{RuleId}:{FilePath}:{LineNumber}";
if (pattern is null || pattern.TenantId != tenantId)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
return Results.Ok(pattern);
}
private static async Task<IResult> HandleCreateExceptionAsync(
Guid tenantId,
SecretExceptionPatternDto request,
ISecretExceptionPatternService service,
HttpContext context,
CancellationToken cancellationToken)
{
var username = context.User.Identity?.Name ?? "system";
var (pattern, errors) = await service.CreatePatternAsync(tenantId, request, username, cancellationToken);
if (errors.Count > 0)
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Validation failed",
detail = string.Join("; ", errors),
errors
});
}
return Results.Created($"/v1/secrets/config/exceptions/{tenantId}/{pattern!.Id}", pattern);
}
private static async Task<IResult> HandleUpdateExceptionAsync(
Guid tenantId,
Guid exceptionId,
SecretExceptionPatternDto request,
ISecretExceptionPatternService service,
HttpContext context,
CancellationToken cancellationToken)
{
// Verify pattern belongs to tenant
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
if (existing is null || existing.TenantId != tenantId)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
var username = context.User.Identity?.Name ?? "system";
var (success, pattern, errors) = await service.UpdatePatternAsync(
exceptionId,
request,
username,
cancellationToken);
if (!success)
{
if (errors.Count > 0 && errors[0].Contains("not found", StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = errors[0]
});
}
return Results.BadRequest(new
{
type = "validation-error",
title = "Validation failed",
detail = string.Join("; ", errors),
errors
});
}
return Results.Ok(pattern);
}
private static async Task<IResult> HandleDeleteExceptionAsync(
Guid tenantId,
Guid exceptionId,
ISecretExceptionPatternService service,
CancellationToken cancellationToken)
{
// Verify pattern belongs to tenant
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
if (existing is null || existing.TenantId != tenantId)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
var deleted = await service.DeletePatternAsync(exceptionId, cancellationToken);
if (!deleted)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
return Results.NoContent();
}
// ========================================================================
// Rule Catalog Handlers
// ========================================================================
private static async Task<IResult> HandleGetRuleCategoriesAsync(
ISecretDetectionSettingsService service,
CancellationToken cancellationToken)
{
var categories = await service.GetRuleCategoriesAsync(cancellationToken);
return Results.Ok(categories);
}
}

View File

@@ -270,6 +270,7 @@ internal static class SmartDiffEndpoints
string candidateId,
ReviewRequest request,
IVexCandidateStore store,
TimeProvider timeProvider,
HttpContext httpContext,
CancellationToken ct = default)
{
@@ -282,7 +283,7 @@ internal static class SmartDiffEndpoints
var review = new VexCandidateReview(
Action: action,
Reviewer: reviewer,
ReviewedAt: DateTimeOffset.UtcNow,
ReviewedAt: timeProvider.GetUtcNow(),
Comment: request.Comment);
var success = await store.ReviewCandidateAsync(candidateId, review, ct);

View File

@@ -41,6 +41,7 @@ internal static class ProofBundleEndpoints
private static async Task<IResult> HandleGenerateProofBundleAsync(
[FromBody] ProofBundleRequest request,
[FromServices] IProofBundleGenerator bundleGenerator,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bundleGenerator);
@@ -67,7 +68,7 @@ internal static class ProofBundleEndpoints
{
PathId = request.PathId,
Bundle = bundle,
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = timeProvider.GetUtcNow()
};
return Results.Ok(response);

View File

@@ -50,6 +50,7 @@ internal static class TriageInboxEndpoints
[FromQuery] string? filter,
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] IFindingQueryService findingService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(groupingService);
@@ -77,7 +78,7 @@ internal static class TriageInboxEndpoints
FilteredPaths = filteredPaths.Count,
Filter = filter,
Paths = filteredPaths,
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = timeProvider.GetUtcNow()
};
return Results.Ok(response);

View File

@@ -55,6 +55,7 @@ internal static class UnknownsEndpoints
[FromQuery] int? limit,
IUnknownRepository repository,
IUnknownRanker ranker,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
// Validate and default pagination
@@ -95,9 +96,10 @@ internal static class UnknownsEndpoints
PageSize: pageSize);
var result = await repository.ListUnknownsAsync(query, cancellationToken);
var now = timeProvider.GetUtcNow();
return Results.Ok(new UnknownsListResponse(
Items: result.Items.Select(UnknownItemResponse.FromUnknownItem).ToList(),
Items: result.Items.Select(item => UnknownItemResponse.FromUnknownItem(item, now)).ToList(),
TotalCount: result.TotalCount,
Page: pageNum,
PageSize: pageSize,
@@ -195,7 +197,7 @@ public sealed record UnknownItemResponse(
ContainmentResponse? Containment,
DateTimeOffset CreatedAt)
{
public static UnknownItemResponse FromUnknownItem(UnknownItem item) => new(
public static UnknownItemResponse FromUnknownItem(UnknownItem item, DateTimeOffset now) => new(
Id: Guid.TryParse(item.Id, out var id) ? id : Guid.Empty,
SubjectRef: item.ArtifactPurl ?? item.ArtifactDigest,
Kind: string.Join(",", item.Reasons),
@@ -209,7 +211,7 @@ public sealed record UnknownItemResponse(
Containment: item.Containment != null
? new ContainmentResponse(item.Containment.Seccomp, item.Containment.Fs)
: null,
CreatedAt: DateTimeOffset.UtcNow); // Would come from Unknown.SysFrom
CreatedAt: now); // Would come from Unknown.SysFrom
}
/// <summary>

View File

@@ -120,6 +120,7 @@ internal static class WitnessEndpoints
private static async Task<IResult> HandleVerifyWitnessAsync(
Guid witnessId,
IWitnessRepository repository,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
@@ -161,10 +162,11 @@ internal static class WitnessEndpoints
}
// Record verification attempt
var now = timeProvider.GetUtcNow();
await repository.RecordVerificationAsync(new WitnessVerificationRecord
{
WitnessId = witnessId,
VerifiedAt = DateTimeOffset.UtcNow,
VerifiedAt = now,
VerifiedBy = "api",
VerificationStatus = verificationStatus,
VerificationError = verificationError
@@ -176,7 +178,7 @@ internal static class WitnessEndpoints
WitnessHash = witness.WitnessHash,
Status = verificationStatus,
Error = verificationError,
VerifiedAt = DateTimeOffset.UtcNow,
VerifiedAt = now,
IsSigned = !string.IsNullOrEmpty(witness.DsseEnvelope)
});
}

View File

@@ -25,25 +25,26 @@ public sealed class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdempotencyMiddleware> _logger;
private readonly TimeProvider _timeProvider;
public IdempotencyMiddleware(
RequestDelegate next,
ILogger<IdempotencyMiddleware> logger)
ILogger<IdempotencyMiddleware> logger,
TimeProvider timeProvider)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task InvokeAsync(
HttpContext context,
IIdempotencyKeyRepository repository,
IOptions<IdempotencyOptions> options,
TimeProvider timeProvider)
IOptions<IdempotencyOptions> options)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(timeProvider);
var opts = options.Value;
@@ -110,6 +111,7 @@ public sealed class IdempotencyMiddleware
var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync(context.RequestAborted)
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var idempotencyKey = new IdempotencyKeyRow
{
TenantId = tenantId,
@@ -118,8 +120,8 @@ public sealed class IdempotencyMiddleware
ResponseStatus = context.Response.StatusCode,
ResponseBody = responseBody,
ResponseHeaders = SerializeHeaders(context.Response.Headers),
CreatedAt = timeProvider.GetUtcNow(),
ExpiresAt = timeProvider.GetUtcNow().Add(opts.Window)
CreatedAt = now,
ExpiresAt = now.Add(opts.Window)
};
try

View File

@@ -35,6 +35,7 @@ using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Triage;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Policy.Explainability;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Determinism;
using StellaOps.Scanner.WebService.Endpoints;
@@ -139,8 +140,10 @@ builder.Services.AddSingleton<IAttestationChainVerifier, AttestationChainVerifie
builder.Services.AddSingleton<IHumanApprovalAttestationService, HumanApprovalAttestationService>();
builder.Services.AddScoped<ICallGraphIngestionService, CallGraphIngestionService>();
builder.Services.AddScoped<ISbomIngestionService, SbomIngestionService>();
builder.Services.AddScoped<ILayerSbomService, LayerSbomService>();
builder.Services.AddSingleton<ISbomUploadStore, InMemorySbomUploadStore>();
builder.Services.AddScoped<ISbomByosUploadService, SbomByosUploadService>();
builder.Services.AddSingleton<ILayerSbomService, LayerSbomService>(); // Sprint: SPRINT_20260106_003_001
builder.Services.AddSingleton<IPolicySnapshotRepository, InMemoryPolicySnapshotRepository>();
builder.Services.AddSingleton<IPolicyAuditRepository, InMemoryPolicyAuditRepository>();
builder.Services.AddSingleton<PolicySnapshotStore>();
@@ -155,6 +158,11 @@ builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
builder.Services.AddSingleton<IBaselineService, BaselineService>();
builder.Services.AddSingleton<IActionablesService, ActionablesService>();
builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiService>();
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
builder.Services.AddScoped<ISecretExceptionPatternService, SecretExceptionPatternService>();
builder.Services.AddDbContext<TriageDbContext>(options =>
options.UseNpgsql(bootstrapOptions.Storage.Dsn, npgsqlOptions =>
{
@@ -169,6 +177,10 @@ builder.Services.AddDbContext<TriageDbContext>(options =>
builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
// Verdict rationale rendering (Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer)
builder.Services.AddVerdictExplainability();
builder.Services.AddScoped<IFindingRationaleService, FindingRationaleService>();
// Register Storage.Repositories implementations for ManifestEndpoints
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository, TestManifestRepository>();
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository, TestProofBundleRepository>();
@@ -580,6 +592,7 @@ apiGroup.MapEpssEndpoints(); // Sprint: SPRINT_3410_0002_0001
apiGroup.MapTriageStatusEndpoints();
apiGroup.MapTriageInboxEndpoints();
apiGroup.MapProofBundleEndpoints();
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
if (resolvedOptions.Features.EnablePolicyPreview)
{

View File

@@ -26,4 +26,10 @@ internal static class ScannerPolicies
public const string SourcesRead = "scanner.sources.read";
public const string SourcesWrite = "scanner.sources.write";
public const string SourcesAdmin = "scanner.sources.admin";
// Secret detection settings policies
public const string SecretSettingsRead = "scanner.secrets.settings.read";
public const string SecretSettingsWrite = "scanner.secrets.settings.write";
public const string SecretExceptionsRead = "scanner.secrets.exceptions.read";
public const string SecretExceptionsWrite = "scanner.secrets.exceptions.write";
}

View File

@@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.Scoring;
@@ -28,7 +29,7 @@ public sealed class DeterministicScoringService : IScoringService
concelierSnapshotHash?.Trim() ?? string.Empty,
excititorSnapshotHash?.Trim() ?? string.Empty,
latticePolicyHash?.Trim() ?? string.Empty,
freezeTimestamp.ToUniversalTime().ToString("O"),
freezeTimestamp.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
Convert.ToHexStringLower(seed));
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(input));

View File

@@ -16,21 +16,21 @@ namespace StellaOps.Scanner.WebService.Services;
/// </summary>
public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
{
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="EvidenceBundleExporter"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamp generation.</param>
public EvidenceBundleExporter(TimeProvider timeProvider)
/// <param name="timeProvider">The time provider for deterministic timestamps. Defaults to system time if null.</param>
public EvidenceBundleExporter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -306,7 +306,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
return sb.ToString();
}
private static async Task PrepareEvidenceFilesAsync(
private async Task PrepareEvidenceFilesAsync(
UnifiedEvidenceResponseDto evidence,
List<(string path, MemoryStream stream, string contentType)> streams,
List<ArchiveFileEntry> entries,
@@ -472,7 +472,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
return sb.ToString();
}
private static string GenerateReadme(UnifiedEvidenceResponseDto evidence, List<ArchiveFileEntry> entries)
private string GenerateReadme(UnifiedEvidenceResponseDto evidence, List<ArchiveFileEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine("# StellaOps Evidence Bundle");
@@ -621,7 +621,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
}
}
private static async Task CreateTarGzArchiveAsync(
private async Task CreateTarGzArchiveAsync(
string findingId,
List<(string path, MemoryStream stream, string contentType)> files,
Stream outputStream,

View File

@@ -55,6 +55,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService
private readonly IScoreReplayService _replayService;
private readonly IOptions<FeedChangeRescoreOptions> _options;
private readonly ILogger<FeedChangeRescoreJob> _logger;
private readonly TimeProvider _timeProvider;
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.FeedChangeRescore");
private string? _lastConcelierSnapshot;
@@ -66,13 +67,15 @@ public sealed class FeedChangeRescoreJob : BackgroundService
IScanManifestRepository manifestRepository,
IScoreReplayService replayService,
IOptions<FeedChangeRescoreOptions> options,
ILogger<FeedChangeRescoreJob> logger)
ILogger<FeedChangeRescoreJob> logger,
TimeProvider? timeProvider = null)
{
_feedTracker = feedTracker ?? throw new ArgumentNullException(nameof(feedTracker));
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -221,7 +224,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService
FeedChangeRescoreOptions opts,
CancellationToken ct)
{
var cutoff = DateTimeOffset.UtcNow - opts.ScanAgeLimit;
var cutoff = _timeProvider.GetUtcNow() - opts.ScanAgeLimit;
// Find scans using the old snapshot hashes
var query = new AffectedScansQuery

View File

@@ -0,0 +1,449 @@
// -----------------------------------------------------------------------------
// FindingRationaleService.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-020 - Integrate VerdictRationaleRenderer into Scanner.WebService
// Description: Service implementation for generating verdict rationales.
// -----------------------------------------------------------------------------
using StellaOps.Policy.Explainability;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for generating structured verdict rationales for findings.
/// </summary>
internal sealed class FindingRationaleService : IFindingRationaleService
{
private readonly ITriageQueryService _triageQueryService;
private readonly IVerdictRationaleRenderer _rationaleRenderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FindingRationaleService> _logger;
public FindingRationaleService(
ITriageQueryService triageQueryService,
IVerdictRationaleRenderer rationaleRenderer,
TimeProvider timeProvider,
ILogger<FindingRationaleService> logger)
{
_triageQueryService = triageQueryService ?? throw new ArgumentNullException(nameof(triageQueryService));
_rationaleRenderer = rationaleRenderer ?? throw new ArgumentNullException(nameof(rationaleRenderer));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
if (finding is null)
{
_logger.LogDebug("Finding {FindingId} not found", findingId);
return null;
}
var input = BuildRationaleInput(finding);
var rationale = _rationaleRenderer.Render(input);
_logger.LogDebug("Generated rationale {RationaleId} for finding {FindingId}",
rationale.RationaleId, findingId);
return MapToDto(findingId, rationale);
}
public async Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
if (finding is null)
{
return null;
}
var input = BuildRationaleInput(finding);
var rationale = _rationaleRenderer.Render(input);
var plainText = _rationaleRenderer.RenderPlainText(rationale);
return new RationalePlainTextResponseDto
{
FindingId = findingId,
RationaleId = rationale.RationaleId,
Format = "plaintext",
Content = plainText
};
}
public async Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
if (finding is null)
{
return null;
}
var input = BuildRationaleInput(finding);
var rationale = _rationaleRenderer.Render(input);
var markdown = _rationaleRenderer.RenderMarkdown(rationale);
return new RationalePlainTextResponseDto
{
FindingId = findingId,
RationaleId = rationale.RationaleId,
Format = "markdown",
Content = markdown
};
}
private VerdictRationaleInput BuildRationaleInput(Scanner.Triage.Entities.TriageFinding finding)
{
// Extract version from PURL
var version = ExtractVersionFromPurl(finding.Purl);
// Build policy clause info from decisions
var policyDecision = finding.PolicyDecisions.FirstOrDefault();
var policyClauseId = policyDecision?.PolicyId ?? "default";
var policyRuleDescription = policyDecision?.Reason ?? "Default policy evaluation";
var policyConditions = new List<string>();
if (!string.IsNullOrEmpty(policyDecision?.Action))
{
policyConditions.Add($"action={policyDecision.Action}");
}
// Build reachability detail if available
ReachabilityDetail? reachability = null;
var reachabilityResult = finding.ReachabilityResults.FirstOrDefault();
if (reachabilityResult is not null && reachabilityResult.Reachable == Scanner.Triage.Entities.TriageReachability.Yes)
{
reachability = new ReachabilityDetail
{
VulnerableFunction = null, // Not tracked at entity level
EntryPoint = null,
PathSummary = $"Reachable (confidence: {reachabilityResult.Confidence}%)"
};
}
// Build attestation references
var pathWitness = BuildPathWitnessRef(finding);
var vexStatements = BuildVexStatementRefs(finding);
var provenance = BuildProvenanceRef(finding);
// Get risk score (normalize from entities)
var riskResult = finding.RiskResults.FirstOrDefault();
double? score = null;
if (riskResult is not null)
{
// Risk results track scores at entity level
score = 0.5; // Default moderate score when we have a risk result
}
// Determine verdict
var verdict = DetermineVerdict(finding);
var recommendation = DetermineRecommendation(finding);
var mitigation = BuildMitigationGuidance(finding);
return new VerdictRationaleInput
{
VerdictRef = new VerdictReference
{
AttestationId = finding.Id.ToString(),
ArtifactDigest = finding.ArtifactDigest ?? "unknown",
PolicyId = policyDecision?.PolicyId ?? "default",
Cve = finding.CveId,
ComponentPurl = finding.Purl
},
Cve = finding.CveId ?? "UNKNOWN",
Component = new ComponentIdentity
{
Purl = finding.Purl,
Name = ExtractNameFromPurl(finding.Purl),
Version = version,
Ecosystem = ExtractEcosystemFromPurl(finding.Purl)
},
Reachability = reachability,
PolicyClauseId = policyClauseId,
PolicyRuleDescription = policyRuleDescription,
PolicyConditions = policyConditions,
PathWitness = pathWitness,
VexStatements = vexStatements,
Provenance = provenance,
Verdict = verdict,
Score = score,
Recommendation = recommendation,
Mitigation = mitigation,
GeneratedAt = _timeProvider.GetUtcNow(),
VerdictDigest = ComputeVerdictDigest(finding),
PolicyDigest = null, // PolicyDecision doesn't have digest
EvidenceDigest = ComputeEvidenceDigest(finding)
};
}
private static VerdictRationaleResponseDto MapToDto(string findingId, VerdictRationale rationale)
{
return new VerdictRationaleResponseDto
{
FindingId = findingId,
RationaleId = rationale.RationaleId,
SchemaVersion = rationale.SchemaVersion,
Evidence = new RationaleEvidenceDto
{
Cve = rationale.Evidence.Cve,
ComponentPurl = rationale.Evidence.Component.Purl,
ComponentVersion = rationale.Evidence.Component.Version,
VulnerableFunction = rationale.Evidence.Reachability?.VulnerableFunction,
EntryPoint = rationale.Evidence.Reachability?.EntryPoint,
Text = rationale.Evidence.FormattedText
},
PolicyClause = new RationalePolicyClauseDto
{
ClauseId = rationale.PolicyClause.ClauseId,
RuleDescription = rationale.PolicyClause.RuleDescription,
Conditions = rationale.PolicyClause.Conditions,
Text = rationale.PolicyClause.FormattedText
},
Attestations = new RationaleAttestationsDto
{
PathWitness = rationale.Attestations.PathWitness is not null
? new RationaleAttestationRefDto
{
Id = rationale.Attestations.PathWitness.Id,
Type = rationale.Attestations.PathWitness.Type,
Digest = rationale.Attestations.PathWitness.Digest,
Summary = rationale.Attestations.PathWitness.Summary
}
: null,
VexStatements = rationale.Attestations.VexStatements?.Select(v => new RationaleAttestationRefDto
{
Id = v.Id,
Type = v.Type,
Digest = v.Digest,
Summary = v.Summary
}).ToList(),
Provenance = rationale.Attestations.Provenance is not null
? new RationaleAttestationRefDto
{
Id = rationale.Attestations.Provenance.Id,
Type = rationale.Attestations.Provenance.Type,
Digest = rationale.Attestations.Provenance.Digest,
Summary = rationale.Attestations.Provenance.Summary
}
: null,
Text = rationale.Attestations.FormattedText
},
Decision = new RationaleDecisionDto
{
Verdict = rationale.Decision.Verdict,
Score = rationale.Decision.Score,
Recommendation = rationale.Decision.Recommendation,
Mitigation = rationale.Decision.Mitigation is not null
? new RationaleMitigationDto
{
Action = rationale.Decision.Mitigation.Action,
Details = rationale.Decision.Mitigation.Details
}
: null,
Text = rationale.Decision.FormattedText
},
GeneratedAt = rationale.GeneratedAt,
InputDigests = new RationaleInputDigestsDto
{
VerdictDigest = rationale.InputDigests.VerdictDigest,
PolicyDigest = rationale.InputDigests.PolicyDigest,
EvidenceDigest = rationale.InputDigests.EvidenceDigest
}
};
}
private static string ExtractVersionFromPurl(string purl)
{
var atIndex = purl.LastIndexOf('@');
return atIndex > 0 ? purl[(atIndex + 1)..] : "unknown";
}
private static string? ExtractNameFromPurl(string purl)
{
// pkg:type/namespace/name@version or pkg:type/name@version
var atIndex = purl.LastIndexOf('@');
var withoutVersion = atIndex > 0 ? purl[..atIndex] : purl;
var lastSlash = withoutVersion.LastIndexOf('/');
return lastSlash > 0 ? withoutVersion[(lastSlash + 1)..] : null;
}
private static string? ExtractEcosystemFromPurl(string purl)
{
// pkg:type/...
if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var colonIndex = purl.IndexOf(':', 4);
var slashIndex = purl.IndexOf('/', 4);
var endIndex = colonIndex > 4 && (slashIndex < 0 || colonIndex < slashIndex)
? colonIndex
: slashIndex;
return endIndex > 4 ? purl[4..endIndex] : null;
}
private static AttestationReference? BuildPathWitnessRef(Scanner.Triage.Entities.TriageFinding finding)
{
var witness = finding.Attestations.FirstOrDefault(a =>
a.Type == "path-witness" || a.Type == "reachability");
if (witness is null)
{
return null;
}
return new AttestationReference
{
Id = witness.Id.ToString(),
Type = "path-witness",
Digest = witness.EnvelopeHash,
Summary = $"Path witness from {witness.Issuer ?? "unknown"}"
};
}
private static IReadOnlyList<AttestationReference>? BuildVexStatementRefs(Scanner.Triage.Entities.TriageFinding finding)
{
var vexRecords = finding.EffectiveVexRecords;
if (vexRecords.Count == 0)
{
return null;
}
return vexRecords.Select(v => new AttestationReference
{
Id = v.Id.ToString(),
Type = "vex",
Digest = v.DsseEnvelopeHash,
Summary = $"{v.Status}: from {v.SourceDomain}"
}).ToList();
}
private static AttestationReference? BuildProvenanceRef(Scanner.Triage.Entities.TriageFinding finding)
{
var provenance = finding.Attestations.FirstOrDefault(a =>
a.Type == "provenance" || a.Type == "slsa-provenance");
if (provenance is null)
{
return null;
}
return new AttestationReference
{
Id = provenance.Id.ToString(),
Type = "provenance",
Digest = provenance.EnvelopeHash,
Summary = $"Provenance from {provenance.Issuer ?? "unknown"}"
};
}
private static string DetermineVerdict(Scanner.Triage.Entities.TriageFinding finding)
{
// Check VEX status first
var vex = finding.EffectiveVexRecords.FirstOrDefault();
if (vex is not null)
{
return vex.Status switch
{
Scanner.Triage.Entities.TriageVexStatus.NotAffected => "Not Affected",
Scanner.Triage.Entities.TriageVexStatus.Affected => "Affected",
Scanner.Triage.Entities.TriageVexStatus.UnderInvestigation => "Under Investigation",
Scanner.Triage.Entities.TriageVexStatus.Unknown => "Unknown",
_ => "Unknown"
};
}
// Check if backport fixed
if (finding.IsBackportFixed)
{
return "Fixed (Backport)";
}
// Check if muted
if (finding.IsMuted)
{
return "Muted";
}
// Default based on status
return finding.Status switch
{
"resolved" => "Resolved",
"open" => "Affected",
_ => "Under Investigation"
};
}
private static string DetermineRecommendation(Scanner.Triage.Entities.TriageFinding finding)
{
// If there's a VEX not_affected, no action needed
var vex = finding.EffectiveVexRecords.FirstOrDefault(v =>
v.Status == Scanner.Triage.Entities.TriageVexStatus.NotAffected);
if (vex is not null)
{
return "No action required";
}
// If fixed version available, recommend upgrade
if (!string.IsNullOrEmpty(finding.FixedInVersion))
{
return $"Upgrade to version {finding.FixedInVersion}";
}
// If backport fixed
if (finding.IsBackportFixed)
{
return "Already patched via backport";
}
// Default recommendation
return "Review and apply appropriate mitigation";
}
private static MitigationGuidance? BuildMitigationGuidance(Scanner.Triage.Entities.TriageFinding finding)
{
if (!string.IsNullOrEmpty(finding.FixedInVersion))
{
return new MitigationGuidance
{
Action = "upgrade",
Details = $"Upgrade to {finding.FixedInVersion} or later"
};
}
if (finding.IsBackportFixed)
{
return new MitigationGuidance
{
Action = "verify-backport",
Details = "Verify backport patch is applied"
};
}
return null;
}
private static string ComputeVerdictDigest(Scanner.Triage.Entities.TriageFinding finding)
{
// Simple digest based on finding ID and last update
var input = $"{finding.Id}:{finding.UpdatedAt:O}";
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}
private static string ComputeEvidenceDigest(Scanner.Triage.Entities.TriageFinding finding)
{
// Simple digest based on evidence artifacts
var evidenceIds = string.Join("|", finding.EvidenceArtifacts.Select(e => e.Id.ToString()).OrderBy(x => x, StringComparer.Ordinal));
var input = $"{finding.Id}:{evidenceIds}";
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}
}

View File

@@ -18,16 +18,19 @@ public sealed class GatingReasonService : IGatingReasonService
{
private readonly TriageDbContext _dbContext;
private readonly ILogger<GatingReasonService> _logger;
private readonly TimeProvider _timeProvider;
// Default policy trust threshold (configurable in real implementation)
private const double DefaultPolicyTrustThreshold = 0.7;
public GatingReasonService(
TriageDbContext dbContext,
ILogger<GatingReasonService> logger)
ILogger<GatingReasonService> logger,
TimeProvider? timeProvider = null)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -227,7 +230,7 @@ public sealed class GatingReasonService : IGatingReasonService
/// <summary>
/// Computes a composite trust score for a VEX record.
/// </summary>
private static double ComputeVexTrustScore(TriageEffectiveVex vex)
private double ComputeVexTrustScore(TriageEffectiveVex vex)
{
// Weighted combination of trust factors
const double IssuerWeight = 0.4;
@@ -262,11 +265,11 @@ public sealed class GatingReasonService : IGatingReasonService
};
}
private static double GetRecencyTrust(DateTimeOffset? timestamp)
private double GetRecencyTrust(DateTimeOffset? timestamp)
{
if (timestamp is null) return 0.3;
var age = DateTimeOffset.UtcNow - timestamp.Value;
var age = _timeProvider.GetUtcNow() - timestamp.Value;
return age.TotalDays switch
{
<= 7 => 1.0, // Within a week

View File

@@ -0,0 +1,40 @@
// -----------------------------------------------------------------------------
// IFindingRationaleService.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-020 - Integrate VerdictRationaleRenderer into Scanner.WebService
// Description: Service interface for generating verdict rationales for findings.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for generating structured verdict rationales for findings.
/// </summary>
public interface IFindingRationaleService
{
/// <summary>
/// Get the structured rationale for a finding.
/// </summary>
/// <param name="findingId">Finding identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Rationale response or null if finding not found.</returns>
Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default);
/// <summary>
/// Get the rationale as plain text (4-line format).
/// </summary>
/// <param name="findingId">Finding identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Plain text response or null if finding not found.</returns>
Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default);
/// <summary>
/// Get the rationale as Markdown.
/// </summary>
/// <param name="findingId">Finding identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Markdown response or null if finding not found.</returns>
Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for managing per-layer SBOMs and composition recipes.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
public interface ILayerSbomService
{
/// <summary>
/// Gets summary information for all layers in a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of layer summaries.</returns>
Task<ImmutableArray<LayerSummary>> GetLayerSummariesAsync(
ScanId scanId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the SBOM for a specific layer.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="layerDigest">The layer digest (e.g., "sha256:abc123...").</param>
/// <param name="format">SBOM format: "cdx" or "spdx".</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>SBOM bytes, or null if not found.</returns>
Task<byte[]?> GetLayerSbomAsync(
ScanId scanId,
string layerDigest,
string format,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the composition recipe for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Composition recipe response, or null if not found.</returns>
Task<CompositionRecipeResponse?> GetCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies the composition recipe for a scan against stored layer SBOMs.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result, or null if recipe not found.</returns>
Task<CompositionRecipeVerificationResult?> VerifyCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores per-layer SBOMs for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="imageDigest">The image digest.</param>
/// <param name="result">The layer SBOM composition result.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task StoreLayerSbomsAsync(
ScanId scanId,
string imageDigest,
LayerSbomCompositionResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Summary information for a layer.
/// </summary>
public sealed record LayerSummary
{
/// <summary>
/// The layer digest.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The layer order (0-indexed).
/// </summary>
public required int Order { get; init; }
/// <summary>
/// Whether this layer has a stored SBOM.
/// </summary>
public required bool HasSbom { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -89,9 +89,9 @@ public sealed record BundleVerifyResult(
DateTimeOffset VerifiedAt,
string? ErrorMessage = null)
{
public static BundleVerifyResult Success(string computedRootHash) =>
new(true, computedRootHash, true, true, DateTimeOffset.UtcNow);
public static BundleVerifyResult Success(string computedRootHash, TimeProvider? timeProvider = null) =>
new(true, computedRootHash, true, true, (timeProvider ?? TimeProvider.System).GetUtcNow());
public static BundleVerifyResult Failure(string error, string computedRootHash = "") =>
new(false, computedRootHash, false, false, DateTimeOffset.UtcNow, error);
public static BundleVerifyResult Failure(string error, string computedRootHash = "", TimeProvider? timeProvider = null) =>
new(false, computedRootHash, false, false, (timeProvider ?? TimeProvider.System).GetUtcNow(), error);
}

View File

@@ -0,0 +1,126 @@
// -----------------------------------------------------------------------------
// IVexGateQueryService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: Interface for querying VEX gate results from completed scans.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for querying VEX gate evaluation results.
/// </summary>
public interface IVexGateQueryService
{
/// <summary>
/// Gets VEX gate results for a completed scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="query">Optional query parameters for filtering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate results or null if scan not found.</returns>
Task<VexGateResultsResponse?> GetGateResultsAsync(
string scanId,
VexGateResultsQuery? query = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current gate policy configuration.
/// </summary>
/// <param name="tenantId">Optional tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Policy configuration.</returns>
Task<VexGatePolicyDto> GetPolicyAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// DTO for VEX gate policy configuration.
/// </summary>
public sealed record VexGatePolicyDto
{
/// <summary>
/// Policy version identifier.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Whether gate evaluation is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Default decision when no rule matches.
/// </summary>
public required string DefaultDecision { get; init; }
/// <summary>
/// Policy rules in priority order.
/// </summary>
public required IReadOnlyList<VexGatePolicyRuleDto> Rules { get; init; }
}
/// <summary>
/// DTO for a single gate policy rule.
/// </summary>
public sealed record VexGatePolicyRuleDto
{
/// <summary>
/// Rule identifier.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Priority (higher = evaluated first).
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Decision when this rule matches.
/// </summary>
public required string Decision { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Conditions that must be met for this rule.
/// </summary>
public VexGatePolicyConditionDto? Condition { get; init; }
}
/// <summary>
/// DTO for policy rule conditions.
/// </summary>
public sealed record VexGatePolicyConditionDto
{
/// <summary>
/// Required vendor VEX status.
/// </summary>
public string? VendorStatus { get; init; }
/// <summary>
/// Required exploitability state.
/// </summary>
public bool? IsExploitable { get; init; }
/// <summary>
/// Required reachability state.
/// </summary>
public bool? IsReachable { get; init; }
/// <summary>
/// Required compensating control state.
/// </summary>
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Matching severity levels.
/// </summary>
public IReadOnlyList<string>? SeverityLevels { get; init; }
}

View File

@@ -0,0 +1,194 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using System.Text.Json;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Default implementation of <see cref="ILayerSbomService"/>.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
public sealed class LayerSbomService : ILayerSbomService
{
private readonly ICompositionRecipeService _recipeService;
// In-memory cache for layer SBOMs (would be replaced with CAS in production)
private static readonly ConcurrentDictionary<string, LayerSbomStore> LayerSbomCache = new(StringComparer.Ordinal);
public LayerSbomService(ICompositionRecipeService? recipeService = null)
{
_recipeService = recipeService ?? new CompositionRecipeService();
}
/// <inheritdoc />
public Task<ImmutableArray<LayerSummary>> GetLayerSummariesAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult(ImmutableArray<LayerSummary>.Empty);
}
var summaries = store.LayerRefs
.OrderBy(r => r.Order)
.Select(r => new LayerSummary
{
LayerDigest = r.LayerDigest,
Order = r.Order,
HasSbom = true,
ComponentCount = r.ComponentCount,
})
.ToImmutableArray();
return Task.FromResult(summaries);
}
/// <inheritdoc />
public Task<byte[]?> GetLayerSbomAsync(
ScanId scanId,
string layerDigest,
string format,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult<byte[]?>(null);
}
var artifact = store.Artifacts.FirstOrDefault(a =>
string.Equals(a.LayerDigest, layerDigest, StringComparison.OrdinalIgnoreCase));
if (artifact is null)
{
return Task.FromResult<byte[]?>(null);
}
var bytes = string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase)
? artifact.SpdxJsonBytes
: artifact.CycloneDxJsonBytes;
return Task.FromResult<byte[]?>(bytes);
}
/// <inheritdoc />
public Task<CompositionRecipeResponse?> GetCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult<CompositionRecipeResponse?>(null);
}
return Task.FromResult<CompositionRecipeResponse?>(store.Recipe);
}
/// <inheritdoc />
public Task<CompositionRecipeVerificationResult?> VerifyCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult<CompositionRecipeVerificationResult?>(null);
}
if (store.Recipe is null)
{
return Task.FromResult<CompositionRecipeVerificationResult?>(null);
}
var result = _recipeService.Verify(store.Recipe, store.LayerRefs);
return Task.FromResult<CompositionRecipeVerificationResult?>(result);
}
/// <inheritdoc />
public Task StoreLayerSbomsAsync(
ScanId scanId,
string imageDigest,
LayerSbomCompositionResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
var key = scanId.Value;
// Build a mock SbomCompositionResult for recipe generation
// In a real implementation, this would come from the scan coordinator
var recipe = BuildRecipe(scanId.Value, imageDigest, result);
var store = new LayerSbomStore
{
ScanId = scanId.Value,
ImageDigest = imageDigest,
Artifacts = result.Artifacts,
LayerRefs = result.References,
Recipe = recipe,
};
LayerSbomCache[key] = store;
return Task.CompletedTask;
}
private CompositionRecipeResponse BuildRecipe(string scanId, string imageDigest, LayerSbomCompositionResult result)
{
var layers = result.References
.Select(r => new CompositionRecipeLayer
{
Digest = r.LayerDigest,
Order = r.Order,
FragmentDigest = r.FragmentDigest,
SbomDigests = new LayerSbomDigests
{
CycloneDx = r.CycloneDxDigest,
Spdx = r.SpdxDigest,
},
ComponentCount = r.ComponentCount,
})
.OrderBy(l => l.Order)
.ToImmutableArray();
return new CompositionRecipeResponse
{
ScanId = scanId,
ImageDigest = imageDigest,
CreatedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
Recipe = new CompositionRecipe
{
Version = "1.0.0",
GeneratorName = "StellaOps.Scanner",
GeneratorVersion = "2026.04",
Layers = layers,
MerkleRoot = result.MerkleRoot,
AggregatedSbomDigests = new AggregatedSbomDigests
{
CycloneDx = result.MerkleRoot, // Placeholder - would come from actual SBOM
Spdx = null,
},
},
};
}
private sealed record LayerSbomStore
{
public required string ScanId { get; init; }
public required string ImageDigest { get; init; }
public required ImmutableArray<LayerSbomArtifact> Artifacts { get; init; }
public required ImmutableArray<LayerSbomRef> LayerRefs { get; init; }
public CompositionRecipeResponse? Recipe { get; init; }
}
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
@@ -62,7 +63,7 @@ internal sealed class MessagingPlatformEventPublisher : IPlatformEventPublisher
Headers = new Dictionary<string, string>
{
["kind"] = @event.Kind,
["occurredAt"] = @event.OccurredAt.ToString("O")
["occurredAt"] = @event.OccurredAt.ToString("O", CultureInfo.InvariantCulture)
}
};

View File

@@ -19,13 +19,16 @@ internal sealed class OfflineKitManifestService
private readonly OfflineKitStateStore _stateStore;
private readonly ILogger<OfflineKitManifestService> _logger;
private readonly TimeProvider _timeProvider;
public OfflineKitManifestService(
OfflineKitStateStore stateStore,
ILogger<OfflineKitManifestService> logger)
ILogger<OfflineKitManifestService> logger,
TimeProvider? timeProvider = null)
{
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -49,7 +52,7 @@ internal sealed class OfflineKitManifestService
Version = status.Current.BundleId ?? "unknown",
Assets = BuildAssetMap(status.Components),
Signature = null, // Would be loaded from bundle signature file
CreatedAt = status.Current.CapturedAt ?? DateTimeOffset.UtcNow,
CreatedAt = status.Current.CapturedAt ?? _timeProvider.GetUtcNow(),
ExpiresAt = status.Current.CapturedAt?.AddDays(30) // Default 30-day expiry
};
}
@@ -155,7 +158,7 @@ internal sealed class OfflineKitManifestService
private void ValidateExpiration(OfflineKitManifestTransport manifest, OfflineKitValidationResult result)
{
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < DateTimeOffset.UtcNow)
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < _timeProvider.GetUtcNow())
{
result.Warnings.Add(new OfflineKitValidationWarning
{
@@ -166,7 +169,7 @@ internal sealed class OfflineKitManifestService
}
// Check freshness (warn if older than 7 days)
var age = DateTimeOffset.UtcNow - manifest.CreatedAt;
var age = _timeProvider.GetUtcNow() - manifest.CreatedAt;
if (age.TotalDays > 30)
{
result.Warnings.Add(new OfflineKitValidationWarning
@@ -218,7 +221,7 @@ internal sealed class OfflineKitManifestService
Valid = true,
Algorithm = "ECDSA-P256",
KeyId = "authority-key-001",
SignedAt = DateTimeOffset.UtcNow
SignedAt = _timeProvider.GetUtcNow()
};
}
catch (FormatException)

View File

@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -62,7 +63,7 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
new("event", payload),
new("kind", @event.Kind),
new("tenant", @event.Tenant),
new("occurredAt", @event.OccurredAt.ToString("O")),
new("occurredAt", @event.OccurredAt.ToString("O", CultureInfo.InvariantCulture)),
new("idempotencyKey", @event.IdempotencyKey)
};

View File

@@ -20,6 +20,7 @@ public sealed class ReplayCommandService : IReplayCommandService
{
private readonly TriageDbContext _dbContext;
private readonly ILogger<ReplayCommandService> _logger;
private readonly TimeProvider _timeProvider;
// Configuration (would come from IOptions in real implementation)
private const string DefaultBinary = "stellaops";
@@ -27,10 +28,12 @@ public sealed class ReplayCommandService : IReplayCommandService
public ReplayCommandService(
TriageDbContext dbContext,
ILogger<ReplayCommandService> logger)
ILogger<ReplayCommandService> logger,
TimeProvider? timeProvider = null)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -92,7 +95,7 @@ public sealed class ReplayCommandService : IReplayCommandService
OfflineCommand = offlineCommand,
Snapshot = snapshotInfo,
Bundle = bundleInfo,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
ExpectedVerdictHash = verdictHash
};
}
@@ -141,7 +144,7 @@ public sealed class ReplayCommandService : IReplayCommandService
OfflineCommand = offlineCommand,
Snapshot = snapshotInfo,
Bundle = bundleInfo,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
ExpectedFinalDigest = scan.FinalDigest ?? ComputeDigest($"scan:{scan.Id}")
};
}
@@ -358,7 +361,7 @@ public sealed class ReplayCommandService : IReplayCommandService
return new SnapshotInfoDto
{
Id = snapshotId,
CreatedAt = scan?.SnapshotCreatedAt ?? DateTimeOffset.UtcNow,
CreatedAt = scan?.SnapshotCreatedAt ?? _timeProvider.GetUtcNow(),
FeedVersions = scan?.FeedVersions ?? new Dictionary<string, string>
{
["nvd"] = "latest",
@@ -381,7 +384,7 @@ public sealed class ReplayCommandService : IReplayCommandService
SizeBytes = null, // Would be computed when bundle is generated
ContentHash = contentHash,
Format = "tar.gz",
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
ExpiresAt = _timeProvider.GetUtcNow().AddDays(7),
Contents = new[]
{
"manifest.json",
@@ -405,7 +408,7 @@ public sealed class ReplayCommandService : IReplayCommandService
SizeBytes = null,
ContentHash = contentHash,
Format = "tar.gz",
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
ExpiresAt = _timeProvider.GetUtcNow().AddDays(30),
Contents = new[]
{
"manifest.json",

View File

@@ -29,6 +29,7 @@ public sealed class ReportSigner : IReportSigner
private readonly ILogger<ReportSigner> logger;
private readonly ICryptoProviderRegistry cryptoRegistry;
private readonly ICryptoHmac cryptoHmac;
private readonly TimeProvider timeProvider;
private readonly ICryptoProvider? provider;
private readonly CryptoKeyReference? keyReference;
private readonly CryptoSignerResolution? signerResolution;
@@ -38,11 +39,13 @@ public sealed class ReportSigner : IReportSigner
IOptions<ScannerWebServiceOptions> options,
ICryptoProviderRegistry cryptoRegistry,
ICryptoHmac cryptoHmac,
TimeProvider timeProvider,
ILogger<ReportSigner> logger)
{
ArgumentNullException.ThrowIfNull(options);
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
this.cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
var value = options.Value ?? new ScannerWebServiceOptions();
@@ -79,7 +82,7 @@ public sealed class ReportSigner : IReportSigner
reference,
canonicalAlgorithm,
privateKey,
createdAt: DateTimeOffset.UtcNow);
createdAt: timeProvider.GetUtcNow());
provider.UpsertSigningKey(signingKeyDescriptor);

View File

@@ -84,13 +84,13 @@ internal sealed record RuntimeReconciliationResult
public string? ErrorMessage { get; init; }
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message)
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message, TimeProvider? timeProvider = null)
=> new()
{
ImageDigest = imageDigest,
ErrorCode = code,
ErrorMessage = message,
ReconciledAt = DateTimeOffset.UtcNow
ReconciledAt = (timeProvider ?? TimeProvider.System).GetUtcNow()
};
}

View File

@@ -23,6 +23,7 @@ public sealed class ScoreReplayService : IScoreReplayService
private readonly IProofBundleWriter _bundleWriter;
private readonly IScanManifestSigner _manifestSigner;
private readonly IScoringService _scoringService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ScoreReplayService> _logger;
public ScoreReplayService(
@@ -31,6 +32,7 @@ public sealed class ScoreReplayService : IScoreReplayService
IProofBundleWriter bundleWriter,
IScanManifestSigner manifestSigner,
IScoringService scoringService,
TimeProvider timeProvider,
ILogger<ScoreReplayService> logger)
{
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
@@ -38,6 +40,7 @@ public sealed class ScoreReplayService : IScoreReplayService
_bundleWriter = bundleWriter ?? throw new ArgumentNullException(nameof(bundleWriter));
_manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner));
_scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -99,7 +102,7 @@ public sealed class ScoreReplayService : IScoreReplayService
RootHash: bundle.RootHash,
BundleUri: bundle.BundleUri,
ManifestHash: manifest.ComputeHash(),
ReplayedAt: DateTimeOffset.UtcNow,
ReplayedAt: _timeProvider.GetUtcNow(),
Deterministic: manifest.Deterministic);
}
finally
@@ -164,7 +167,7 @@ public sealed class ScoreReplayService : IScoreReplayService
ComputedRootHash: computedRootHash,
ManifestValid: manifestVerify.IsValid,
LedgerValid: ledgerValid,
VerifiedAt: DateTimeOffset.UtcNow,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: string.Join("; ", errors));
}

View File

@@ -0,0 +1,497 @@
// -----------------------------------------------------------------------------
// SecretDetectionSettingsService.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-005 - Create Settings CRUD API endpoints
// Description: Service layer for secret detection configuration.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Text.Json;
using StellaOps.Scanner.Core.Secrets.Configuration;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service interface for secret detection settings.
/// </summary>
public interface ISecretDetectionSettingsService
{
/// <summary>Gets settings for a tenant.</summary>
Task<SecretDetectionSettingsResponseDto?> GetSettingsAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>Creates default settings for a tenant.</summary>
Task<SecretDetectionSettingsResponseDto> CreateSettingsAsync(
Guid tenantId,
string createdBy,
CancellationToken cancellationToken = default);
/// <summary>Updates settings with optimistic concurrency.</summary>
Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync(
Guid tenantId,
SecretDetectionSettingsDto settings,
int expectedVersion,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>Gets available rule categories.</summary>
Task<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Service interface for secret exception patterns.
/// </summary>
public interface ISecretExceptionPatternService
{
/// <summary>Gets all exception patterns for a tenant.</summary>
Task<SecretExceptionPatternListResponseDto> GetPatternsAsync(
Guid tenantId,
bool includeInactive = false,
CancellationToken cancellationToken = default);
/// <summary>Gets a specific pattern by ID.</summary>
Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
Guid patternId,
CancellationToken cancellationToken = default);
/// <summary>Creates a new exception pattern.</summary>
Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> CreatePatternAsync(
Guid tenantId,
SecretExceptionPatternDto pattern,
string createdBy,
CancellationToken cancellationToken = default);
/// <summary>Updates an exception pattern.</summary>
Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
Guid patternId,
SecretExceptionPatternDto pattern,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>Deletes an exception pattern.</summary>
Task<bool> DeletePatternAsync(
Guid patternId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementation of secret detection settings service.
/// </summary>
public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsService
{
private readonly ISecretDetectionSettingsRepository _repository;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SecretDetectionSettingsService(
ISecretDetectionSettingsRepository repository,
TimeProvider timeProvider)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<SecretDetectionSettingsResponseDto?> GetSettingsAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var row = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
return row is null ? null : MapToDto(row);
}
public async Task<SecretDetectionSettingsResponseDto> CreateSettingsAsync(
Guid tenantId,
string createdBy,
CancellationToken cancellationToken = default)
{
var defaultSettings = SecretDetectionSettings.CreateDefault(tenantId, createdBy);
var row = MapToRow(defaultSettings, tenantId, createdBy);
var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false);
return MapToDto(created);
}
public async Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync(
Guid tenantId,
SecretDetectionSettingsDto settings,
int expectedVersion,
string updatedBy,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return (false, null, "Settings not found for tenant");
}
// Validate settings
var validationErrors = ValidateSettings(settings);
if (validationErrors.Count > 0)
{
return (false, null, string.Join("; ", validationErrors));
}
// Apply updates
existing.Enabled = settings.Enabled;
existing.RevelationPolicy = JsonSerializer.Serialize(settings.RevelationPolicy, JsonOptions);
existing.EnabledRuleCategories = settings.EnabledRuleCategories.ToArray();
existing.DisabledRuleIds = settings.DisabledRuleIds.ToArray();
existing.AlertSettings = JsonSerializer.Serialize(settings.AlertSettings, JsonOptions);
existing.MaxFileSizeBytes = settings.MaxFileSizeBytes;
existing.ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray();
existing.ExcludedPaths = settings.ExcludedPaths.ToArray();
existing.ScanBinaryFiles = settings.ScanBinaryFiles;
existing.RequireSignedRuleBundles = settings.RequireSignedRuleBundles;
existing.UpdatedBy = updatedBy;
var success = await _repository.UpdateAsync(existing, expectedVersion, cancellationToken).ConfigureAwait(false);
if (!success)
{
return (false, null, "Version conflict - settings were modified by another request");
}
// Fetch updated version
var updated = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
return (true, updated is null ? null : MapToDto(updated), null);
}
public Task<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default)
{
var categories = new List<RuleCategoryDto>
{
new() { Id = SecretRuleCategories.Aws, Name = "AWS", Description = "Amazon Web Services credentials", RuleCount = 15 },
new() { Id = SecretRuleCategories.Gcp, Name = "GCP", Description = "Google Cloud Platform credentials", RuleCount = 12 },
new() { Id = SecretRuleCategories.Azure, Name = "Azure", Description = "Microsoft Azure credentials", RuleCount = 10 },
new() { Id = SecretRuleCategories.Generic, Name = "Generic", Description = "Generic secrets and passwords", RuleCount = 25 },
new() { Id = SecretRuleCategories.PrivateKeys, Name = "Private Keys", Description = "SSH, PGP, and other private keys", RuleCount = 8 },
new() { Id = SecretRuleCategories.Database, Name = "Database", Description = "Database connection strings and credentials", RuleCount = 18 },
new() { Id = SecretRuleCategories.Messaging, Name = "Messaging", Description = "Messaging platform credentials (Slack, Discord)", RuleCount = 6 },
new() { Id = SecretRuleCategories.Payment, Name = "Payment", Description = "Payment processor credentials (Stripe, PayPal)", RuleCount = 5 },
new() { Id = SecretRuleCategories.SocialMedia, Name = "Social Media", Description = "Social media API keys", RuleCount = 8 },
new() { Id = SecretRuleCategories.Internal, Name = "Internal", Description = "Custom internal secrets", RuleCount = 0 }
};
return Task.FromResult(new RuleCategoriesResponseDto { Categories = categories });
}
private static IReadOnlyList<string> ValidateSettings(SecretDetectionSettingsDto settings)
{
var errors = new List<string>();
if (settings.MaxFileSizeBytes < 1024)
{
errors.Add("MaxFileSizeBytes must be at least 1024 bytes");
}
if (settings.MaxFileSizeBytes > 100 * 1024 * 1024)
{
errors.Add("MaxFileSizeBytes must be 100 MB or less");
}
if (settings.RevelationPolicy.PartialRevealChars < 1 || settings.RevelationPolicy.PartialRevealChars > 10)
{
errors.Add("PartialRevealChars must be between 1 and 10");
}
if (settings.AlertSettings.Enabled && settings.AlertSettings.Destinations.Count == 0)
{
errors.Add("At least one destination is required when alerting is enabled");
}
if (settings.AlertSettings.MaxAlertsPerScan < 1 || settings.AlertSettings.MaxAlertsPerScan > 100)
{
errors.Add("MaxAlertsPerScan must be between 1 and 100");
}
return errors;
}
private static SecretDetectionSettingsResponseDto MapToDto(SecretDetectionSettingsRow row)
{
var revelationPolicy = JsonSerializer.Deserialize<RevelationPolicyDto>(row.RevelationPolicy, JsonOptions)
?? new RevelationPolicyDto
{
DefaultPolicy = SecretRevelationPolicyType.PartialReveal,
ExportPolicy = SecretRevelationPolicyType.FullMask,
PartialRevealChars = 4,
MaxMaskChars = 8,
FullRevealRoles = []
};
var alertSettings = JsonSerializer.Deserialize<SecretAlertSettingsDto>(row.AlertSettings, JsonOptions)
?? new SecretAlertSettingsDto
{
Enabled = false,
MinimumAlertSeverity = SecretSeverityType.High,
Destinations = [],
MaxAlertsPerScan = 10,
DeduplicationWindowMinutes = 1440,
IncludeFilePath = true,
IncludeMaskedValue = true,
IncludeImageRef = true
};
return new SecretDetectionSettingsResponseDto
{
TenantId = row.TenantId,
Settings = new SecretDetectionSettingsDto
{
Enabled = row.Enabled,
RevelationPolicy = revelationPolicy,
EnabledRuleCategories = row.EnabledRuleCategories,
DisabledRuleIds = row.DisabledRuleIds,
AlertSettings = alertSettings,
MaxFileSizeBytes = row.MaxFileSizeBytes,
ExcludedFileExtensions = row.ExcludedFileExtensions,
ExcludedPaths = row.ExcludedPaths,
ScanBinaryFiles = row.ScanBinaryFiles,
RequireSignedRuleBundles = row.RequireSignedRuleBundles
},
Version = row.Version,
UpdatedAt = row.UpdatedAt,
UpdatedBy = row.UpdatedBy
};
}
private static SecretDetectionSettingsRow MapToRow(SecretDetectionSettings settings, Guid tenantId, string updatedBy)
{
var revelationPolicyDto = new RevelationPolicyDto
{
DefaultPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.DefaultPolicy,
ExportPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.ExportPolicy,
PartialRevealChars = settings.RevelationPolicy.PartialRevealChars,
MaxMaskChars = settings.RevelationPolicy.MaxMaskChars,
FullRevealRoles = settings.RevelationPolicy.FullRevealRoles
};
var alertSettingsDto = new SecretAlertSettingsDto
{
Enabled = settings.AlertSettings.Enabled,
MinimumAlertSeverity = (SecretSeverityType)settings.AlertSettings.MinimumAlertSeverity,
Destinations = settings.AlertSettings.Destinations.Select(d => new SecretAlertDestinationDto
{
Id = d.Id,
Name = d.Name,
ChannelType = (Contracts.AlertChannelType)d.ChannelType,
ChannelId = d.ChannelId,
SeverityFilter = d.SeverityFilter?.Select(s => (SecretSeverityType)s).ToList(),
RuleCategoryFilter = d.RuleCategoryFilter?.ToList(),
IsActive = d.IsActive
}).ToList(),
MaxAlertsPerScan = settings.AlertSettings.MaxAlertsPerScan,
DeduplicationWindowMinutes = (int)settings.AlertSettings.DeduplicationWindow.TotalMinutes,
IncludeFilePath = settings.AlertSettings.IncludeFilePath,
IncludeMaskedValue = settings.AlertSettings.IncludeMaskedValue,
IncludeImageRef = settings.AlertSettings.IncludeImageRef,
AlertMessagePrefix = settings.AlertSettings.AlertMessagePrefix
};
return new SecretDetectionSettingsRow
{
TenantId = tenantId,
Enabled = settings.Enabled,
RevelationPolicy = JsonSerializer.Serialize(revelationPolicyDto, JsonOptions),
EnabledRuleCategories = settings.EnabledRuleCategories.ToArray(),
DisabledRuleIds = settings.DisabledRuleIds.ToArray(),
AlertSettings = JsonSerializer.Serialize(alertSettingsDto, JsonOptions),
MaxFileSizeBytes = settings.MaxFileSizeBytes,
ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray(),
ExcludedPaths = settings.ExcludedPaths.ToArray(),
ScanBinaryFiles = settings.ScanBinaryFiles,
RequireSignedRuleBundles = settings.RequireSignedRuleBundles,
UpdatedBy = updatedBy
};
}
}
/// <summary>
/// Implementation of secret exception pattern service.
/// </summary>
public sealed class SecretExceptionPatternService : ISecretExceptionPatternService
{
private readonly ISecretExceptionPatternRepository _repository;
private readonly TimeProvider _timeProvider;
public SecretExceptionPatternService(
ISecretExceptionPatternRepository repository,
TimeProvider timeProvider)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<SecretExceptionPatternListResponseDto> GetPatternsAsync(
Guid tenantId,
bool includeInactive = false,
CancellationToken cancellationToken = default)
{
var patterns = includeInactive
? await _repository.GetAllByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false)
: await _repository.GetActiveByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new SecretExceptionPatternListResponseDto
{
Patterns = patterns.Select(MapToDto).ToList(),
TotalCount = patterns.Count
};
}
public async Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
Guid patternId,
CancellationToken cancellationToken = default)
{
var pattern = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
return pattern is null ? null : MapToDto(pattern);
}
public async Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> CreatePatternAsync(
Guid tenantId,
SecretExceptionPatternDto pattern,
string createdBy,
CancellationToken cancellationToken = default)
{
var errors = ValidatePattern(pattern);
if (errors.Count > 0)
{
return (null, errors);
}
var row = new SecretExceptionPatternRow
{
TenantId = tenantId,
Name = pattern.Name,
Description = pattern.Description,
ValuePattern = pattern.ValuePattern,
ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray(),
FilePathGlob = pattern.FilePathGlob,
Justification = pattern.Justification,
ExpiresAt = pattern.ExpiresAt,
IsActive = pattern.IsActive,
CreatedBy = createdBy
};
var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false);
return (MapToDto(created), []);
}
public async Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
Guid patternId,
SecretExceptionPatternDto pattern,
string updatedBy,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return (false, null, ["Pattern not found"]);
}
var errors = ValidatePattern(pattern);
if (errors.Count > 0)
{
return (false, null, errors);
}
existing.Name = pattern.Name;
existing.Description = pattern.Description;
existing.ValuePattern = pattern.ValuePattern;
existing.ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray();
existing.FilePathGlob = pattern.FilePathGlob;
existing.Justification = pattern.Justification;
existing.ExpiresAt = pattern.ExpiresAt;
existing.IsActive = pattern.IsActive;
existing.UpdatedBy = updatedBy;
existing.UpdatedAt = _timeProvider.GetUtcNow();
var success = await _repository.UpdateAsync(existing, cancellationToken).ConfigureAwait(false);
if (!success)
{
return (false, null, ["Failed to update pattern"]);
}
var updated = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
return (true, updated is null ? null : MapToDto(updated), []);
}
public async Task<bool> DeletePatternAsync(
Guid patternId,
CancellationToken cancellationToken = default)
{
return await _repository.DeleteAsync(patternId, cancellationToken).ConfigureAwait(false);
}
private static IReadOnlyList<string> ValidatePattern(SecretExceptionPatternDto pattern)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(pattern.Name))
{
errors.Add("Name is required");
}
else if (pattern.Name.Length > 100)
{
errors.Add("Name must be 100 characters or less");
}
if (string.IsNullOrWhiteSpace(pattern.ValuePattern))
{
errors.Add("ValuePattern is required");
}
else
{
try
{
_ = new System.Text.RegularExpressions.Regex(pattern.ValuePattern);
}
catch (System.Text.RegularExpressions.RegexParseException ex)
{
errors.Add(string.Format(CultureInfo.InvariantCulture, "ValuePattern is not a valid regex: {0}", ex.Message));
}
}
if (string.IsNullOrWhiteSpace(pattern.Justification))
{
errors.Add("Justification is required");
}
else if (pattern.Justification.Length < 20)
{
errors.Add("Justification must be at least 20 characters");
}
return errors;
}
private static SecretExceptionPatternResponseDto MapToDto(SecretExceptionPatternRow row)
{
return new SecretExceptionPatternResponseDto
{
Id = row.ExceptionId,
TenantId = row.TenantId,
Pattern = new SecretExceptionPatternDto
{
Name = row.Name,
Description = row.Description,
ValuePattern = row.ValuePattern,
ApplicableRuleIds = row.ApplicableRuleIds,
FilePathGlob = row.FilePathGlob,
Justification = row.Justification,
ExpiresAt = row.ExpiresAt,
IsActive = row.IsActive
},
MatchCount = row.MatchCount,
LastMatchedAt = row.LastMatchedAt,
CreatedAt = row.CreatedAt,
CreatedBy = row.CreatedBy,
UpdatedAt = row.UpdatedAt,
UpdatedBy = row.UpdatedBy
};
}
}

View File

@@ -40,6 +40,7 @@ public sealed class SliceQueryService : ISliceQueryService
private readonly SliceHasher _hasher;
private readonly IFileContentAddressableStore _cas;
private readonly IScanMetadataRepository _scanRepo;
private readonly TimeProvider _timeProvider;
private readonly SliceQueryServiceOptions _options;
private readonly ILogger<SliceQueryService> _logger;
@@ -51,6 +52,7 @@ public sealed class SliceQueryService : ISliceQueryService
SliceHasher hasher,
IFileContentAddressableStore cas,
IScanMetadataRepository scanRepo,
TimeProvider timeProvider,
IOptions<SliceQueryServiceOptions> options,
ILogger<SliceQueryService> logger)
{
@@ -61,6 +63,7 @@ public sealed class SliceQueryService : ISliceQueryService
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
_scanRepo = scanRepo ?? throw new ArgumentNullException(nameof(scanRepo));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options?.Value ?? new SliceQueryServiceOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -121,7 +124,7 @@ public sealed class SliceQueryService : ISliceQueryService
PathWitnesses = slice.Verdict.PathWitnesses.IsDefaultOrEmpty
? Array.Empty<string>()
: slice.Verdict.PathWitnesses.ToList(),
CachedAt = DateTimeOffset.UtcNow
CachedAt = _timeProvider.GetUtcNow()
};
await _cache.SetAsync(cacheKey, cacheEntry, TimeSpan.FromHours(1), cancellationToken).ConfigureAwait(false);
}

View File

@@ -64,6 +64,12 @@ public sealed class TestProofBundleRepository : StellaOps.Scanner.Storage.Reposi
{
private readonly ConcurrentDictionary<string, ProofBundleRow> _bundlesByRootHash = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<Guid, List<ProofBundleRow>> _bundlesByScanId = new();
private readonly TimeProvider _timeProvider;
public TestProofBundleRepository(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<ProofBundleRow?> GetByRootHashAsync(string rootHash, CancellationToken cancellationToken = default)
{
@@ -112,8 +118,8 @@ public sealed class TestProofBundleRepository : StellaOps.Scanner.Storage.Reposi
public Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var expired = _bundlesByRootHash.Values
.Where(b => b.ExpiresAt.HasValue && b.ExpiresAt.Value < now)
.ToList();

View File

@@ -4,12 +4,13 @@
// Description: Implementation of IUnifiedEvidenceService for assembling evidence.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Triage;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Scanner.WebService.Contracts;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.WebService.Services;
@@ -22,6 +23,7 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
private readonly TriageDbContext _dbContext;
private readonly IGatingReasonService _gatingService;
private readonly IReplayCommandService _replayService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<UnifiedEvidenceService> _logger;
private const double DefaultPolicyTrustThreshold = 0.7;
@@ -30,11 +32,13 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
TriageDbContext dbContext,
IGatingReasonService gatingService,
IReplayCommandService replayService,
TimeProvider timeProvider,
ILogger<UnifiedEvidenceService> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_gatingService = gatingService ?? throw new ArgumentNullException(nameof(gatingService));
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -106,7 +110,7 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
ReplayCommand = replayResponse?.FullCommand?.Command,
ShortReplayCommand = replayResponse?.ShortCommand?.Command,
EvidenceBundleUrl = replayResponse?.Bundle?.DownloadUri,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
CacheKey = cacheKey
};
}
@@ -249,7 +253,7 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
{
ArtifactDigest = ComputeDigest(finding.Purl),
ManifestHash = ComputeDigest(contentForHash),
FeedSnapshotHash = ComputeDigest(finding.LastSeenAt.ToString("O")),
FeedSnapshotHash = ComputeDigest(finding.LastSeenAt.ToString("O", CultureInfo.InvariantCulture)),
PolicyHash = ComputeDigest("default-policy"),
KnowledgeSnapshotId = finding.KnowledgeSnapshotId
};
@@ -277,11 +281,11 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
AttestationsVerified = hasAttestations,
EvidenceComplete = hasVex && hasReachability,
Issues = issues.Count > 0 ? issues : null,
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
}
private static double ComputeVexTrustScore(TriageEffectiveVex vex)
private double ComputeVexTrustScore(TriageEffectiveVex vex)
{
const double IssuerWeight = 0.4;
const double RecencyWeight = 0.2;
@@ -289,7 +293,7 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
const double EvidenceWeight = 0.2;
var issuerTrust = GetIssuerTrust(vex.Issuer);
var recencyTrust = GetRecencyTrust((DateTimeOffset?)vex.ValidFrom);
var recencyTrust = GetRecencyTrust((DateTimeOffset?)vex.ValidFrom, _timeProvider.GetUtcNow());
var justificationTrust = GetJustificationTrust(vex.PrunedSourcesJson);
var evidenceTrust = !string.IsNullOrEmpty(vex.DsseEnvelopeHash) ? 0.8 : 0.3;
@@ -309,10 +313,10 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
_ => 0.5
};
private static double GetRecencyTrust(DateTimeOffset? timestamp)
private static double GetRecencyTrust(DateTimeOffset? timestamp, DateTimeOffset now)
{
if (timestamp is null) return 0.3;
var age = DateTimeOffset.UtcNow - timestamp.Value;
var age = now - timestamp.Value;
return age.TotalDays switch { <= 7 => 1.0, <= 30 => 0.9, <= 90 => 0.7, <= 365 => 0.5, _ => 0.3 };
}

View File

@@ -0,0 +1,208 @@
// -----------------------------------------------------------------------------
// VexGateQueryService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: Service for querying VEX gate results from completed scans.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for querying VEX gate evaluation results.
/// Uses in-memory storage for gate results (populated by scan worker).
/// </summary>
public sealed class VexGateQueryService : IVexGateQueryService
{
private readonly IVexGateResultsStore _resultsStore;
private readonly ILogger<VexGateQueryService> _logger;
private readonly VexGatePolicyDto _defaultPolicy;
public VexGateQueryService(
IVexGateResultsStore resultsStore,
ILogger<VexGateQueryService> logger)
{
_resultsStore = resultsStore ?? throw new ArgumentNullException(nameof(resultsStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_defaultPolicy = CreateDefaultPolicy();
}
/// <inheritdoc />
public async Task<VexGateResultsResponse?> GetGateResultsAsync(
string scanId,
VexGateResultsQuery? query = null,
CancellationToken cancellationToken = default)
{
var results = await _resultsStore.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
if (results is null)
{
_logger.LogDebug("Gate results not found for scan {ScanId}", scanId);
return null;
}
// Apply query filters if provided
if (query is not null)
{
results = ApplyFilters(results, query);
}
return results;
}
/// <inheritdoc />
public Task<VexGatePolicyDto> GetPolicyAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
// TODO: Load tenant-specific policy from configuration
_logger.LogDebug("Getting gate policy for tenant {TenantId}", tenantId ?? "(default)");
return Task.FromResult(_defaultPolicy);
}
private static VexGateResultsResponse ApplyFilters(VexGateResultsResponse results, VexGateResultsQuery query)
{
var filtered = results.GatedFindings.AsEnumerable();
if (!string.IsNullOrEmpty(query.Decision))
{
filtered = filtered.Where(f =>
f.Decision.Equals(query.Decision, StringComparison.OrdinalIgnoreCase));
}
if (query.MinConfidence.HasValue)
{
filtered = filtered.Where(f =>
f.Evidence.ConfidenceScore >= query.MinConfidence.Value);
}
if (query.Offset.HasValue && query.Offset.Value > 0)
{
filtered = filtered.Skip(query.Offset.Value);
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
filtered = filtered.Take(query.Limit.Value);
}
return results with { GatedFindings = filtered.ToList() };
}
private static VexGatePolicyDto CreateDefaultPolicy()
{
return new VexGatePolicyDto
{
Version = "default",
Enabled = true,
DefaultDecision = "Warn",
Rules = new List<VexGatePolicyRuleDto>
{
new()
{
RuleId = "block-exploitable-reachable",
Priority = 100,
Decision = "Block",
Description = "Block findings that are exploitable and reachable without compensating controls",
Condition = new VexGatePolicyConditionDto
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false
}
},
new()
{
RuleId = "warn-high-not-reachable",
Priority = 90,
Decision = "Warn",
Description = "Warn on high/critical severity that is not reachable",
Condition = new VexGatePolicyConditionDto
{
IsReachable = false,
SeverityLevels = new[] { "critical", "high" }
}
},
new()
{
RuleId = "pass-vendor-not-affected",
Priority = 80,
Decision = "Pass",
Description = "Pass findings with vendor not_affected VEX status",
Condition = new VexGatePolicyConditionDto
{
VendorStatus = "NotAffected"
}
},
new()
{
RuleId = "pass-backport-confirmed",
Priority = 70,
Decision = "Pass",
Description = "Pass findings with confirmed backport fix",
Condition = new VexGatePolicyConditionDto
{
VendorStatus = "Fixed"
}
}
}
};
}
}
/// <summary>
/// Interface for storing and retrieving VEX gate results.
/// </summary>
public interface IVexGateResultsStore
{
/// <summary>
/// Gets gate results for a scan.
/// </summary>
Task<VexGateResultsResponse?> GetAsync(string scanId, CancellationToken cancellationToken = default);
/// <summary>
/// Stores gate results for a scan.
/// </summary>
Task StoreAsync(string scanId, VexGateResultsResponse results, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of VEX gate results store.
/// </summary>
public sealed class InMemoryVexGateResultsStore : IVexGateResultsStore
{
private readonly ConcurrentDictionary<string, VexGateResultsResponse> _results = new(StringComparer.OrdinalIgnoreCase);
private readonly int _maxEntries;
public InMemoryVexGateResultsStore(int maxEntries = 10000)
{
_maxEntries = maxEntries;
}
public Task<VexGateResultsResponse?> GetAsync(string scanId, CancellationToken cancellationToken = default)
{
_results.TryGetValue(scanId, out var result);
return Task.FromResult(result);
}
public Task StoreAsync(string scanId, VexGateResultsResponse results, CancellationToken cancellationToken = default)
{
// Simple eviction: if at capacity, remove oldest (first) entry
while (_results.Count >= _maxEntries)
{
var firstKey = _results.Keys.FirstOrDefault();
if (firstKey is not null)
{
_results.TryRemove(firstKey, out _);
}
else
{
break;
}
}
_results[scanId] = results;
return Task.CompletedTask;
}
}

View File

@@ -7,16 +7,19 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="YamlDotNet" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
@@ -26,6 +29,8 @@
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Explainability/StellaOps.Policy.Explainability.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
@@ -47,6 +52,7 @@
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Orchestration/StellaOps.Scanner.Orchestration.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
</ItemGroup>

View File

@@ -10,12 +10,14 @@ public sealed class FidelityMetricsService
private readonly BitwiseFidelityCalculator _bitwiseCalculator;
private readonly SemanticFidelityCalculator _semanticCalculator;
private readonly PolicyFidelityCalculator _policyCalculator;
private readonly TimeProvider _timeProvider;
public FidelityMetricsService()
public FidelityMetricsService(TimeProvider? timeProvider = null)
{
_bitwiseCalculator = new BitwiseFidelityCalculator();
_semanticCalculator = new SemanticFidelityCalculator();
_policyCalculator = new PolicyFidelityCalculator();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -67,7 +69,7 @@ public sealed class FidelityMetricsService
IdenticalOutputs = bfIdentical,
SemanticMatches = sfMatches,
PolicyMatches = pfMatches,
ComputedAt = DateTimeOffset.UtcNow,
ComputedAt = _timeProvider.GetUtcNow(),
Mismatches = allMismatches.Count > 0 ? allMismatches : null
};
}
@@ -108,7 +110,7 @@ public sealed class FidelityMetricsService
Passed = failures.Count == 0,
ShouldBlockRelease = shouldBlock,
FailureReasons = failures,
EvaluatedAt = DateTimeOffset.UtcNow
EvaluatedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -142,4 +142,20 @@ internal sealed class NullBinaryVulnerabilityService : IBinaryVulnerabilityServi
{
return Task.FromResult(System.Collections.Immutable.ImmutableArray<BinaryVulnMatch>.Empty);
}
public Task<System.Collections.Immutable.ImmutableArray<CorpusFunctionMatch>> IdentifyFunctionFromCorpusAsync(
FunctionFingerprintSet fingerprints,
CorpusLookupOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(System.Collections.Immutable.ImmutableArray<CorpusFunctionMatch>.Empty);
}
public Task<System.Collections.Immutable.ImmutableDictionary<string, System.Collections.Immutable.ImmutableArray<CorpusFunctionMatch>>> IdentifyFunctionsFromCorpusBatchAsync(
IEnumerable<(string Key, FunctionFingerprintSet Fingerprints)> functions,
CorpusLookupOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(System.Collections.Immutable.ImmutableDictionary<string, System.Collections.Immutable.ImmutableArray<CorpusFunctionMatch>>.Empty);
}
}

View File

@@ -0,0 +1,49 @@
// -----------------------------------------------------------------------------
// IScanMetricsCollector.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T017
// Description: Interface for scan metrics collection.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Worker.Metrics;
/// <summary>
/// Interface for collecting scan metrics during execution.
/// </summary>
public interface IScanMetricsCollector
{
/// <summary>
/// Gets the metrics ID for this scan.
/// </summary>
Guid MetricsId { get; }
/// <summary>
/// Start tracking a phase.
/// </summary>
IDisposable StartPhase(string phaseName);
/// <summary>
/// Complete a phase with success.
/// </summary>
void CompletePhase(string phaseName, Dictionary<string, object>? metrics = null);
/// <summary>
/// Complete a phase with failure.
/// </summary>
void FailPhase(string phaseName, string errorCode, string? errorMessage = null);
/// <summary>
/// Set artifact counts.
/// </summary>
void SetCounts(int? packageCount = null, int? findingCount = null, int? vexDecisionCount = null);
/// <summary>
/// Records VEX gate metrics.
/// </summary>
void RecordVexGateMetrics(
int totalFindings,
int passedCount,
int warnedCount,
int blockedCount,
TimeSpan elapsed);
}

View File

@@ -7,7 +7,7 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism.Abstractions;
using StellaOps.Determinism;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Repositories;
@@ -17,7 +17,7 @@ namespace StellaOps.Scanner.Worker.Metrics;
/// Collects and persists scan metrics during execution.
/// Thread-safe for concurrent phase tracking.
/// </summary>
public sealed class ScanMetricsCollector : IDisposable
public sealed class ScanMetricsCollector : IScanMetricsCollector, IDisposable
{
private readonly IScanMetricsRepository _repository;
private readonly ILogger<ScanMetricsCollector> _logger;
@@ -200,6 +200,22 @@ public sealed class ScanMetricsCollector : IDisposable
_vexDecisionCount = vexDecisionCount;
}
/// <summary>
/// Records VEX gate metrics.
/// </summary>
public void RecordVexGateMetrics(
int totalFindings,
int passedCount,
int warnedCount,
int blockedCount,
TimeSpan elapsed)
{
_vexDecisionCount = passedCount + warnedCount + blockedCount;
_logger.LogDebug(
"VEX gate metrics: total={Total}, passed={Passed}, warned={Warned}, blocked={Blocked}, elapsed={ElapsedMs}ms",
totalFindings, passedCount, warnedCount, blockedCount, elapsed.TotalMilliseconds);
}
/// <summary>
/// Set additional metadata.
/// </summary>
@@ -249,7 +265,8 @@ public sealed class ScanMetricsCollector : IDisposable
VexDecisionCount = _vexDecisionCount,
ScannerVersion = _scannerVersion,
ScannerImageDigest = _scannerImageDigest,
IsReplay = _isReplay
IsReplay = _isReplay,
CreatedAt = finishedAt
};
try

View File

@@ -17,21 +17,21 @@ public class PoEOrchestrator
private readonly IReachabilityResolver _resolver;
private readonly IProofEmitter _emitter;
private readonly IPoECasStore _casStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PoEOrchestrator> _logger;
private readonly TimeProvider _timeProvider;
public PoEOrchestrator(
IReachabilityResolver resolver,
IProofEmitter emitter,
IPoECasStore casStore,
TimeProvider timeProvider,
ILogger<PoEOrchestrator> logger)
ILogger<PoEOrchestrator> logger,
TimeProvider? timeProvider = null)
{
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_casStore = casStore ?? throw new ArgumentNullException(nameof(casStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>

View File

@@ -21,17 +21,17 @@ namespace StellaOps.Scanner.Worker.Processing;
public sealed class BinaryFindingMapper
{
private readonly IBinaryVulnerabilityService _binaryVulnService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<BinaryFindingMapper> _logger;
private readonly TimeProvider _timeProvider;
public BinaryFindingMapper(
IBinaryVulnerabilityService binaryVulnService,
TimeProvider timeProvider,
ILogger<BinaryFindingMapper> logger)
ILogger<BinaryFindingMapper> logger,
TimeProvider? timeProvider = null)
{
_binaryVulnService = binaryVulnService ?? throw new ArgumentNullException(nameof(binaryVulnService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>

View File

@@ -26,6 +26,9 @@ public static class ScanStageNames
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
public const string ScanSecrets = "scan-secrets";
// Sprint: SPRINT_20260106_003_002 - VEX Gate Service
public const string VexGate = "vex-gate";
public static readonly IReadOnlyList<string> Ordered = new[]
{
IngestReplay,
@@ -36,6 +39,7 @@ public static class ScanStageNames
ScanSecrets,
BinaryLookup,
EpssEnrichment,
VexGate,
ComposeArtifacts,
Entropy,
GeneratePoE,

View File

@@ -26,14 +26,14 @@ internal sealed class SecretsAnalyzerStageExecutor : IScanStageExecutor
"scanner.rootfs",
};
private readonly ISecretsAnalyzer _secretsAnalyzer;
private readonly SecretsAnalyzer _secretsAnalyzer;
private readonly ScannerWorkerMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly IOptions<ScannerWorkerOptions> _options;
private readonly ILogger<SecretsAnalyzerStageExecutor> _logger;
public SecretsAnalyzerStageExecutor(
ISecretsAnalyzer secretsAnalyzer,
SecretsAnalyzer secretsAnalyzer,
ScannerWorkerMetrics metrics,
TimeProvider timeProvider,
IOptions<ScannerWorkerOptions> options,
@@ -74,7 +74,7 @@ internal sealed class SecretsAnalyzerStageExecutor : IScanStageExecutor
}
var startTime = _timeProvider.GetTimestamp();
var allFindings = new List<SecretFinding>();
var allFindings = new List<SecretLeakEvidence>();
try
{
@@ -227,7 +227,7 @@ public sealed record SecretsAnalysisReport
{
public required string JobId { get; init; }
public required string ScanId { get; init; }
public required ImmutableArray<SecretFinding> Findings { get; init; }
public required ImmutableArray<SecretLeakEvidence> Findings { get; init; }
public required int FilesScanned { get; init; }
public required string RulesetVersion { get; init; }
public required DateTimeOffset AnalyzedAtUtc { get; init; }

View File

@@ -41,7 +41,8 @@ internal sealed record SurfaceManifestRequest(
string? ReplayBundleUri = null,
string? ReplayBundleHash = null,
string? ReplayPolicyPin = null,
string? ReplayFeedPin = null);
string? ReplayFeedPin = null,
SurfaceFacetSeals? FacetSeals = null);
internal interface ISurfaceManifestPublisher
{
@@ -138,7 +139,9 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
Sha256 = request.ReplayBundleHash ?? string.Empty,
PolicySnapshotId = request.ReplayPolicyPin,
FeedSnapshotId = request.ReplayFeedPin
}
},
// FCT-022: Facet seals for per-facet drift tracking (SPRINT_20260105_002_002_FACET)
FacetSeals = request.FacetSeals
};
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, SerializerOptions);

View File

@@ -0,0 +1,407 @@
// -----------------------------------------------------------------------------
// VexGateStageExecutor.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T015
// Description: Scan stage executor that applies VEX gate filtering to findings.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.Worker.Metrics;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Scan stage executor that applies VEX gate filtering to vulnerability findings.
/// Evaluates findings against VEX evidence and configurable policies to determine
/// which findings should pass, warn, or block the pipeline.
/// </summary>
public sealed class VexGateStageExecutor : IScanStageExecutor
{
private readonly IVexGateService _vexGateService;
private readonly ILogger<VexGateStageExecutor> _logger;
private readonly VexGateStageOptions _options;
private readonly IScanMetricsCollector? _metricsCollector;
public VexGateStageExecutor(
IVexGateService vexGateService,
ILogger<VexGateStageExecutor> logger,
IOptions<VexGateStageOptions> options,
IScanMetricsCollector? metricsCollector = null)
{
_vexGateService = vexGateService ?? throw new ArgumentNullException(nameof(vexGateService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new VexGateStageOptions();
_metricsCollector = metricsCollector;
}
public string StageName => ScanStageNames.VexGate;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
// Check if gate is bypassed (emergency scan mode)
if (_options.Bypass)
{
_logger.LogWarning(
"VEX gate bypassed for job {JobId} (emergency scan mode)",
context.JobId);
context.Analysis.Set(ScanAnalysisKeys.VexGateBypassed, true);
return;
}
var startTime = context.TimeProvider.GetTimestamp();
// Extract findings from analysis context
var findings = ExtractFindings(context);
if (findings.Count == 0)
{
_logger.LogDebug(
"No findings found for job {JobId}; skipping VEX gate evaluation",
context.JobId);
StoreEmptySummary(context);
return;
}
_logger.LogInformation(
"Evaluating {FindingCount} findings through VEX gate for job {JobId}",
findings.Count,
context.JobId);
// Evaluate all findings in batch
var gatedResults = await _vexGateService.EvaluateBatchAsync(findings, cancellationToken)
.ConfigureAwait(false);
// Store results in analysis context
var resultsMap = gatedResults.ToDictionary(
r => r.Finding.FindingId,
r => r,
StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.VexGateResults, resultsMap);
// Calculate and store summary
var summary = CalculateSummary(gatedResults, context.TimeProvider.GetUtcNow());
context.Analysis.Set(ScanAnalysisKeys.VexGateSummary, summary);
// Store policy version for traceability
context.Analysis.Set(ScanAnalysisKeys.VexGatePolicyVersion, _options.PolicyVersion ?? "default");
context.Analysis.Set(ScanAnalysisKeys.VexGateBypassed, false);
// Record metrics
var elapsed = context.TimeProvider.GetElapsedTime(startTime);
RecordMetrics(summary, elapsed);
_logger.LogInformation(
"VEX gate completed for job {JobId}: {Passed} passed, {Warned} warned, {Blocked} blocked ({ElapsedMs}ms)",
context.JobId,
summary.PassedCount,
summary.WarnedCount,
summary.BlockedCount,
elapsed.TotalMilliseconds);
// Log blocked findings at warning level for visibility
if (summary.BlockedCount > 0)
{
LogBlockedFindings(gatedResults, context.JobId);
}
}
private IReadOnlyList<VexGateFinding> ExtractFindings(ScanJobContext context)
{
var findings = new List<VexGateFinding>();
// Extract from OS package analyzer results
ExtractFindingsFromAnalyzers(
context,
ScanAnalysisKeys.OsPackageAnalyzers,
findings);
// Extract from language analyzer results
ExtractFindingsFromAnalyzers(
context,
ScanAnalysisKeys.LanguageAnalyzerResults,
findings);
// Extract from binary vulnerability findings
if (context.Analysis.TryGet<IReadOnlyList<object>>(ScanAnalysisKeys.BinaryVulnerabilityFindings, out var binaryFindings))
{
foreach (var finding in binaryFindings)
{
var gateFinding = ConvertToGateFinding(finding);
if (gateFinding is not null)
{
findings.Add(gateFinding);
}
}
}
return findings;
}
private void ExtractFindingsFromAnalyzers(
ScanJobContext context,
string analysisKey,
List<VexGateFinding> findings)
{
if (!context.Analysis.TryGet<object>(analysisKey, out var results) ||
results is not System.Collections.IDictionary dictionary)
{
return;
}
foreach (var analyzerResult in dictionary.Values)
{
if (analyzerResult is null)
{
continue;
}
ExtractFindingsFromAnalyzerResult(analyzerResult, findings, context);
}
}
private void ExtractFindingsFromAnalyzerResult(
object analyzerResult,
List<VexGateFinding> findings,
ScanJobContext context)
{
var resultType = analyzerResult.GetType();
// Try to get Vulnerabilities property
var vulnsProperty = resultType.GetProperty("Vulnerabilities");
if (vulnsProperty?.GetValue(analyzerResult) is IEnumerable<object> vulns)
{
foreach (var vuln in vulns)
{
var gateFinding = ConvertToGateFinding(vuln);
if (gateFinding is not null)
{
findings.Add(gateFinding);
}
}
}
// Try to get Findings property
var findingsProperty = resultType.GetProperty("Findings");
if (findingsProperty?.GetValue(analyzerResult) is IEnumerable<object> findingsList)
{
foreach (var finding in findingsList)
{
var gateFinding = ConvertToGateFinding(finding);
if (gateFinding is not null)
{
findings.Add(gateFinding);
}
}
}
}
private static VexGateFinding? ConvertToGateFinding(object finding)
{
var findingType = finding.GetType();
// Extract vulnerability ID (CVE)
string? vulnId = null;
var cveIdProperty = findingType.GetProperty("CveId");
if (cveIdProperty?.GetValue(finding) is string cveId && !string.IsNullOrWhiteSpace(cveId))
{
vulnId = cveId;
}
else
{
var vulnIdProperty = findingType.GetProperty("VulnerabilityId");
if (vulnIdProperty?.GetValue(finding) is string vid && !string.IsNullOrWhiteSpace(vid))
{
vulnId = vid;
}
}
if (string.IsNullOrWhiteSpace(vulnId))
{
return null;
}
// Extract PURL
string? purl = null;
var purlProperty = findingType.GetProperty("Purl");
if (purlProperty?.GetValue(finding) is string p)
{
purl = p;
}
else
{
var packageProperty = findingType.GetProperty("PackageUrl");
if (packageProperty?.GetValue(finding) is string pu)
{
purl = pu;
}
}
// Extract finding ID
string findingId;
var idProperty = findingType.GetProperty("FindingId") ?? findingType.GetProperty("Id");
if (idProperty?.GetValue(finding) is string id && !string.IsNullOrWhiteSpace(id))
{
findingId = id;
}
else
{
// Generate a deterministic ID
findingId = $"{vulnId}:{purl ?? "unknown"}";
}
// Extract severity
string? severity = null;
var severityProperty = findingType.GetProperty("Severity") ?? findingType.GetProperty("SeverityLevel");
if (severityProperty?.GetValue(finding) is string sev)
{
severity = sev;
}
// Extract reachability (if available from previous stages)
bool? isReachable = null;
var reachableProperty = findingType.GetProperty("IsReachable");
if (reachableProperty?.GetValue(finding) is bool reachable)
{
isReachable = reachable;
}
// Extract exploitability (if available from EPSS or KEV)
bool? isExploitable = null;
var exploitableProperty = findingType.GetProperty("IsExploitable");
if (exploitableProperty?.GetValue(finding) is bool exploitable)
{
isExploitable = exploitable;
}
return new VexGateFinding
{
FindingId = findingId,
VulnerabilityId = vulnId,
Purl = purl ?? string.Empty,
ImageDigest = string.Empty, // Will be set from context if needed
SeverityLevel = severity,
IsReachable = isReachable ?? false,
IsExploitable = isExploitable ?? false,
HasCompensatingControl = false, // Would need additional context
};
}
private static VexGateSummary CalculateSummary(
ImmutableArray<GatedFinding> results,
DateTimeOffset evaluatedAt)
{
var passedCount = 0;
var warnedCount = 0;
var blockedCount = 0;
foreach (var result in results)
{
switch (result.GateResult.Decision)
{
case VexGateDecision.Pass:
passedCount++;
break;
case VexGateDecision.Warn:
warnedCount++;
break;
case VexGateDecision.Block:
blockedCount++;
break;
}
}
return new VexGateSummary
{
TotalFindings = results.Length,
PassedCount = passedCount,
WarnedCount = warnedCount,
BlockedCount = blockedCount,
EvaluatedAt = evaluatedAt,
};
}
private void StoreEmptySummary(ScanJobContext context)
{
var summary = new VexGateSummary
{
TotalFindings = 0,
PassedCount = 0,
WarnedCount = 0,
BlockedCount = 0,
EvaluatedAt = context.TimeProvider.GetUtcNow(),
};
context.Analysis.Set(ScanAnalysisKeys.VexGateSummary, summary);
context.Analysis.Set(ScanAnalysisKeys.VexGateResults, new Dictionary<string, GatedFinding>());
context.Analysis.Set(ScanAnalysisKeys.VexGateBypassed, false);
}
private void RecordMetrics(VexGateSummary summary, TimeSpan elapsed)
{
_metricsCollector?.RecordVexGateMetrics(
summary.TotalFindings,
summary.PassedCount,
summary.WarnedCount,
summary.BlockedCount,
elapsed);
}
private void LogBlockedFindings(ImmutableArray<GatedFinding> results, string jobId)
{
foreach (var result in results)
{
if (result.GateResult.Decision == VexGateDecision.Block)
{
_logger.LogWarning(
"VEX gate BLOCKED finding in job {JobId}: {VulnId} ({Purl}) - {Rationale}",
jobId,
result.Finding.VulnerabilityId,
result.Finding.Purl,
result.GateResult.Rationale);
}
}
}
}
/// <summary>
/// Options for VEX gate stage execution.
/// </summary>
public sealed class VexGateStageOptions
{
/// <summary>
/// If true, bypass VEX gate evaluation (emergency scan mode).
/// </summary>
public bool Bypass { get; set; }
/// <summary>
/// Policy version identifier for traceability.
/// </summary>
public string? PolicyVersion { get; set; }
}
/// <summary>
/// Summary of VEX gate evaluation results.
/// </summary>
public sealed record VexGateSummary
{
public required int TotalFindings { get; init; }
public required int PassedCount { get; init; }
public required int WarnedCount { get; init; }
public required int BlockedCount { get; init; }
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Percentage of findings that passed the gate.
/// </summary>
public double PassRate => TotalFindings > 0 ? (double)PassedCount / TotalFindings : 0;
/// <summary>
/// Percentage of findings that were blocked.
/// </summary>
public double BlockRate => TotalFindings > 0 ? (double)BlockedCount / TotalFindings : 0;
}

View File

@@ -15,6 +15,7 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
@@ -24,6 +25,7 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Gate/StellaOps.Scanner.Gate.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />

View File

@@ -0,0 +1,22 @@
# Scanner Gate Benchmarks Charter
## Mission
- Benchmark VEX gate policy evaluation performance deterministically.
## Responsibilities
- Provide reproducible BenchmarkDotNet runs for gate evaluation throughput.
- Keep benchmark inputs deterministic and offline-friendly.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/scanner/architecture.md
## Working Agreement
- Use fixed seeds and stable inputs for deterministic benchmarks.
- Avoid network dependencies and nondeterministic clocks.
- Keep benchmark overhead minimal and isolated from core logic.
## Testing Strategy
- Benchmarks should be runnable in Release without external services.

View File

@@ -0,0 +1,19 @@
```
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.7462)
Unknown processor
.NET SDK 10.0.101
[Host] : .NET 10.0.1 (10.0.125.57005), X64 RyuJIT AVX2
Job-IXVNFV : .NET 10.0.1 (10.0.125.57005), X64 RyuJIT AVX2
IterationCount=10 RunStrategy=Throughput
```
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------------------ |---------:|---------:|---------:|------:|--------:|-------:|----------:|------------:|
| Evaluate_Single | 283.3 ns | 7.83 ns | 5.18 ns | 1.00 | 0.02 | 0.1316 | 552 B | 1.00 |
| Evaluate_Batch100 | 396.8 ns | 13.62 ns | 9.01 ns | 1.40 | 0.04 | 0.1648 | 691 B | 1.25 |
| Evaluate_Batch1000 | 418.0 ns | 15.04 ns | 9.95 ns | 1.48 | 0.04 | 0.1650 | 691 B | 1.25 |
| Evaluate_NoRuleMatch | 350.5 ns | 16.08 ns | 10.64 ns | 1.24 | 0.04 | 0.1760 | 736 B | 1.33 |
| Evaluate_FirstRuleMatch | 298.2 ns | 11.85 ns | 7.05 ns | 1.05 | 0.03 | 0.1316 | 552 B | 1.00 |
| Evaluate_DiverseMix | 396.1 ns | 20.15 ns | 11.99 ns | 1.40 | 0.05 | 0.1648 | 691 B | 1.25 |

View File

@@ -0,0 +1,7 @@
Method;Job;AnalyzeLaunchVariance;EvaluateOverhead;MaxAbsoluteError;MaxRelativeError;MinInvokeCount;MinIterationTime;OutlierMode;Affinity;EnvironmentVariables;Jit;LargeAddressAware;Platform;PowerPlanMode;Runtime;AllowVeryLargeObjects;Concurrent;CpuGroups;Force;HeapAffinitizeMask;HeapCount;NoAffinitize;RetainVm;Server;Arguments;BuildConfiguration;Clock;EngineFactory;NuGetReferences;Toolchain;IsMutator;InvocationCount;IterationCount;IterationTime;LaunchCount;MaxIterationCount;MaxWarmupIterationCount;MemoryRandomization;MinIterationCount;MinWarmupIterationCount;RunStrategy;UnrollFactor;WarmupCount;Mean;Error;StdDev;Ratio;RatioSD;Gen0;Allocated;Alloc Ratio
Evaluate_Single;Job-IXVNFV;False;Default;Default;Default;Default;Default;Default;11111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 10.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;10;Default;Default;Default;Default;Default;Default;Default;Throughput;16;Default;283.3 ns;7.83 ns;5.18 ns;1.00;0.02;0.1316;552 B;1.00
Evaluate_Batch100;Job-IXVNFV;False;Default;Default;Default;Default;Default;Default;11111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 10.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;10;Default;Default;Default;Default;Default;Default;Default;Throughput;16;Default;396.8 ns;13.62 ns;9.01 ns;1.40;0.04;0.1648;691 B;1.25
Evaluate_Batch1000;Job-IXVNFV;False;Default;Default;Default;Default;Default;Default;11111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 10.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;10;Default;Default;Default;Default;Default;Default;Default;Throughput;16;Default;418.0 ns;15.04 ns;9.95 ns;1.48;0.04;0.1650;691 B;1.25
Evaluate_NoRuleMatch;Job-IXVNFV;False;Default;Default;Default;Default;Default;Default;11111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 10.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;10;Default;Default;Default;Default;Default;Default;Default;Throughput;16;Default;350.5 ns;16.08 ns;10.64 ns;1.24;0.04;0.1760;736 B;1.33
Evaluate_FirstRuleMatch;Job-IXVNFV;False;Default;Default;Default;Default;Default;Default;11111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 10.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;10;Default;Default;Default;Default;Default;Default;Default;Throughput;16;Default;298.2 ns;11.85 ns;7.05 ns;1.05;0.03;0.1316;552 B;1.00
Evaluate_DiverseMix;Job-IXVNFV;False;Default;Default;Default;Default;Default;Default;11111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 10.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;10;Default;Default;Default;Default;Default;Default;Default;Throughput;16;Default;396.1 ns;20.15 ns;11.99 ns;1.40;0.05;0.1648;691 B;1.25
1 Method Job AnalyzeLaunchVariance EvaluateOverhead MaxAbsoluteError MaxRelativeError MinInvokeCount MinIterationTime OutlierMode Affinity EnvironmentVariables Jit LargeAddressAware Platform PowerPlanMode Runtime AllowVeryLargeObjects Concurrent CpuGroups Force HeapAffinitizeMask HeapCount NoAffinitize RetainVm Server Arguments BuildConfiguration Clock EngineFactory NuGetReferences Toolchain IsMutator InvocationCount IterationCount IterationTime LaunchCount MaxIterationCount MaxWarmupIterationCount MemoryRandomization MinIterationCount MinWarmupIterationCount RunStrategy UnrollFactor WarmupCount Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
2 Evaluate_Single Job-IXVNFV False Default Default Default Default Default Default 11111111 Empty RyuJit Default X64 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c .NET 10.0 False True False True Default Default False False False Default Default Default Default Default Default Default Default 10 Default Default Default Default Default Default Default Throughput 16 Default 283.3 ns 7.83 ns 5.18 ns 1.00 0.02 0.1316 552 B 1.00
3 Evaluate_Batch100 Job-IXVNFV False Default Default Default Default Default Default 11111111 Empty RyuJit Default X64 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c .NET 10.0 False True False True Default Default False False False Default Default Default Default Default Default Default Default 10 Default Default Default Default Default Default Default Throughput 16 Default 396.8 ns 13.62 ns 9.01 ns 1.40 0.04 0.1648 691 B 1.25
4 Evaluate_Batch1000 Job-IXVNFV False Default Default Default Default Default Default 11111111 Empty RyuJit Default X64 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c .NET 10.0 False True False True Default Default False False False Default Default Default Default Default Default Default Default 10 Default Default Default Default Default Default Default Throughput 16 Default 418.0 ns 15.04 ns 9.95 ns 1.48 0.04 0.1650 691 B 1.25
5 Evaluate_NoRuleMatch Job-IXVNFV False Default Default Default Default Default Default 11111111 Empty RyuJit Default X64 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c .NET 10.0 False True False True Default Default False False False Default Default Default Default Default Default Default Default 10 Default Default Default Default Default Default Default Throughput 16 Default 350.5 ns 16.08 ns 10.64 ns 1.24 0.04 0.1760 736 B 1.33
6 Evaluate_FirstRuleMatch Job-IXVNFV False Default Default Default Default Default Default 11111111 Empty RyuJit Default X64 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c .NET 10.0 False True False True Default Default False False False Default Default Default Default Default Default Default Default 10 Default Default Default Default Default Default Default Throughput 16 Default 298.2 ns 11.85 ns 7.05 ns 1.05 0.03 0.1316 552 B 1.00
7 Evaluate_DiverseMix Job-IXVNFV False Default Default Default Default Default Default 11111111 Empty RyuJit Default X64 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c .NET 10.0 False True False True Default Default False False False Default Default Default Default Default Default Default Default 10 Default Default Default Default Default Default Default Throughput 16 Default 396.1 ns 20.15 ns 11.99 ns 1.40 0.05 0.1648 691 B 1.25

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<title>StellaOps.Scanner.Gate.Benchmarks.VexGateBenchmarks-20260107-091600</title>
<style type="text/css">
table { border-collapse: collapse; display: block; width: 100%; overflow: auto; }
td, th { padding: 6px 13px; border: 1px solid #ddd; text-align: right; }
tr { background-color: #fff; border-top: 1px solid #ccc; }
tr:nth-child(even) { background: #f8f8f8; }
</style>
</head>
<body>
<pre><code>
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.7462)
Unknown processor
.NET SDK 10.0.101
[Host] : .NET 10.0.1 (10.0.125.57005), X64 RyuJIT AVX2
Job-IXVNFV : .NET 10.0.1 (10.0.125.57005), X64 RyuJIT AVX2
</code></pre>
<pre><code>IterationCount=10 RunStrategy=Throughput
</code></pre>
<table>
<thead><tr><th>Method </th><th>Mean</th><th>Error</th><th>StdDev</th><th>Ratio</th><th>RatioSD</th><th>Gen0</th><th>Allocated</th><th>Alloc Ratio</th>
</tr>
</thead><tbody><tr><td>Evaluate_Single</td><td>283.3 ns</td><td>7.83 ns</td><td>5.18 ns</td><td>1.00</td><td>0.02</td><td>0.1316</td><td>552 B</td><td>1.00</td>
</tr><tr><td>Evaluate_Batch100</td><td>396.8 ns</td><td>13.62 ns</td><td>9.01 ns</td><td>1.40</td><td>0.04</td><td>0.1648</td><td>691 B</td><td>1.25</td>
</tr><tr><td>Evaluate_Batch1000</td><td>418.0 ns</td><td>15.04 ns</td><td>9.95 ns</td><td>1.48</td><td>0.04</td><td>0.1650</td><td>691 B</td><td>1.25</td>
</tr><tr><td>Evaluate_NoRuleMatch</td><td>350.5 ns</td><td>16.08 ns</td><td>10.64 ns</td><td>1.24</td><td>0.04</td><td>0.1760</td><td>736 B</td><td>1.33</td>
</tr><tr><td>Evaluate_FirstRuleMatch</td><td>298.2 ns</td><td>11.85 ns</td><td>7.05 ns</td><td>1.05</td><td>0.03</td><td>0.1316</td><td>552 B</td><td>1.00</td>
</tr><tr><td>Evaluate_DiverseMix</td><td>396.1 ns</td><td>20.15 ns</td><td>11.99 ns</td><td>1.40</td><td>0.05</td><td>0.1648</td><td>691 B</td><td>1.25</td>
</tr></tbody></table>
</body>
</html>

View File

@@ -0,0 +1,11 @@
// -----------------------------------------------------------------------------
// Program.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T014 - Performance benchmarks for batch evaluation
// Description: Entry point for VEX gate benchmarks.
// -----------------------------------------------------------------------------
using BenchmarkDotNet.Running;
using StellaOps.Scanner.Gate.Benchmarks;
BenchmarkRunner.Run<VexGateBenchmarks>();

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);NU1603</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Gate\StellaOps.Scanner.Gate.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,229 @@
// -----------------------------------------------------------------------------
// VexGateBenchmarks.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T014 - Performance benchmarks for batch evaluation
// Description: BenchmarkDotNet benchmarks for VEX gate batch evaluation.
// -----------------------------------------------------------------------------
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Engines;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Gate;
namespace StellaOps.Scanner.Gate.Benchmarks;
/// <summary>
/// Benchmarks for VEX gate batch evaluation operations.
/// Target: >= 1000 findings/sec evaluation throughput.
///
/// To run: dotnet run -c Release
/// </summary>
[MemoryDiagnoser]
[SimpleJob(RunStrategy.Throughput, iterationCount: 10)]
public class VexGateBenchmarks
{
private VexGatePolicyEvaluator _policyEvaluator = null!;
private VexGateEvidence[] _singleFindings = null!;
private VexGateEvidence[] _batchFindings100 = null!;
private VexGateEvidence[] _batchFindings1000 = null!;
[GlobalSetup]
public void Setup()
{
// Setup policy evaluator with default policy
var policyOptions = Options.Create(new VexGatePolicyOptions
{
Enabled = true,
Policy = VexGatePolicy.Default,
});
_policyEvaluator = new VexGatePolicyEvaluator(
policyOptions,
NullLogger<VexGatePolicyEvaluator>.Instance);
// Pre-generate test findings
_singleFindings = GenerateFindings(1);
_batchFindings100 = GenerateFindings(100);
_batchFindings1000 = GenerateFindings(1000);
}
private static VexGateEvidence[] GenerateFindings(int count)
{
var findings = new VexGateEvidence[count];
var random = new Random(42); // Fixed seed for reproducibility
for (int i = 0; i < count; i++)
{
// Generate diverse evidence scenarios
var scenario = i % 5;
findings[i] = scenario switch
{
0 => CreateBlockableEvidence(i),
1 => CreateWarnableEvidence(i),
2 => CreatePassableVendorNotAffected(i),
3 => CreatePassableFixed(i),
_ => CreateDefaultEvidence(i),
};
}
return findings;
}
private static VexGateEvidence CreateBlockableEvidence(int index)
{
return new VexGateEvidence
{
VendorStatus = null,
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false,
ConfidenceScore = 0.95,
SeverityLevel = "critical",
Justification = null,
BackportHints = [],
};
}
private static VexGateEvidence CreateWarnableEvidence(int index)
{
return new VexGateEvidence
{
VendorStatus = null,
IsExploitable = false,
IsReachable = false,
HasCompensatingControl = false,
ConfidenceScore = 0.7,
SeverityLevel = "high",
Justification = null,
BackportHints = [],
};
}
private static VexGateEvidence CreatePassableVendorNotAffected(int index)
{
return new VexGateEvidence
{
VendorStatus = VexStatus.NotAffected,
IsExploitable = false,
IsReachable = false,
HasCompensatingControl = false,
ConfidenceScore = 0.99,
SeverityLevel = "medium",
Justification = VexJustification.VulnerableCodeNotPresent,
BackportHints = [],
};
}
private static VexGateEvidence CreatePassableFixed(int index)
{
return new VexGateEvidence
{
VendorStatus = VexStatus.Fixed,
IsExploitable = false,
IsReachable = false,
HasCompensatingControl = false,
ConfidenceScore = 0.98,
SeverityLevel = "high",
Justification = null,
BackportHints = [$"backport-{index}"],
};
}
private static VexGateEvidence CreateDefaultEvidence(int index)
{
return new VexGateEvidence
{
VendorStatus = VexStatus.Affected,
IsExploitable = true,
IsReachable = false,
HasCompensatingControl = false,
ConfidenceScore = 0.6,
SeverityLevel = "medium",
Justification = null,
BackportHints = [],
};
}
/// <summary>
/// Benchmark single finding evaluation.
/// Baseline for throughput calculations.
/// </summary>
[Benchmark(Baseline = true)]
public (VexGateDecision, string, string) Evaluate_Single()
{
return _policyEvaluator.Evaluate(_singleFindings[0]);
}
/// <summary>
/// Benchmark batch of 100 findings.
/// Typical scan size for small containers.
/// </summary>
[Benchmark(OperationsPerInvoke = 100)]
public void Evaluate_Batch100()
{
for (int i = 0; i < 100; i++)
{
_ = _policyEvaluator.Evaluate(_batchFindings100[i]);
}
}
/// <summary>
/// Benchmark batch of 1000 findings.
/// Stress test for large container scans.
/// Target: >= 1000 findings/sec.
/// </summary>
[Benchmark(OperationsPerInvoke = 1000)]
public void Evaluate_Batch1000()
{
for (int i = 0; i < 1000; i++)
{
_ = _policyEvaluator.Evaluate(_batchFindings1000[i]);
}
}
/// <summary>
/// Benchmark policy rule matching with all rules checked.
/// Measures worst-case scenario where no rules match.
/// </summary>
[Benchmark]
public (VexGateDecision, string, string) Evaluate_NoRuleMatch()
{
// Under investigation status with no definitive exploitability info
// This should not match any specific rules and fall to default
var evidence = new VexGateEvidence
{
VendorStatus = VexStatus.UnderInvestigation,
IsExploitable = false,
IsReachable = false,
HasCompensatingControl = true, // Has control so won't match block rule
ConfidenceScore = 0.5,
SeverityLevel = "low", // Low severity won't match warn rule
Justification = null,
BackportHints = [],
};
return _policyEvaluator.Evaluate(evidence);
}
/// <summary>
/// Benchmark best-case early exit (first rule matches).
/// Measures overhead when exploitable+reachable rule matches.
/// </summary>
[Benchmark]
public (VexGateDecision, string, string) Evaluate_FirstRuleMatch()
{
return _policyEvaluator.Evaluate(_batchFindings100[0]); // Blockable evidence
}
/// <summary>
/// Benchmark diverse findings mix.
/// Simulates realistic scan with varied CVE statuses.
/// </summary>
[Benchmark(OperationsPerInvoke = 100)]
public void Evaluate_DiverseMix()
{
foreach (var evidence in _batchFindings100)
{
_ = _policyEvaluator.Evaluate(evidence);
}
}
}

View File

@@ -13,7 +13,7 @@ Provide advisory feed integration and offline bundles for CVE-to-symbol mapping
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/scanner/architecture.md`
- `docs/modules/concelier/architecture.md`
- `docs/reachability/slice-schema.md`
- `docs/modules/reach-graph/guides/slice-schema.md`
## Working Directory & Boundaries
- Primary scope: `src/Scanner/__Libraries/StellaOps.Scanner.Advisory/`

View File

@@ -44,7 +44,7 @@ public sealed class AdvisoryClient : IAdvisoryClient
var normalized = cveId.Trim().ToUpperInvariant();
var cacheKey = $"advisory:cve:{normalized}";
if (_cache.TryGetValue(cacheKey, out AdvisorySymbolMapping cached))
if (_cache.TryGetValue(cacheKey, out AdvisorySymbolMapping? cached) && cached is not null)
{
return cached;
}

View File

@@ -9,18 +9,20 @@ internal sealed class DenoRuntimeTraceRecorder
{
private readonly List<DenoRuntimeEvent> _events = new();
private readonly string _rootPath;
private readonly TimeProvider _timeProvider;
public DenoRuntimeTraceRecorder(string rootPath)
public DenoRuntimeTraceRecorder(string rootPath, TimeProvider? timeProvider = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_rootPath = Path.GetFullPath(rootPath);
_timeProvider = timeProvider ?? TimeProvider.System;
}
public void AddModuleLoad(string absoluteModulePath, string reason, IEnumerable<string> permissions, string? origin = null, DateTimeOffset? timestamp = null)
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
var evt = new DenoModuleLoadEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Module: identity,
Reason: reason ?? string.Empty,
Permissions: NormalizePermissions(permissions),
@@ -32,7 +34,7 @@ internal sealed class DenoRuntimeTraceRecorder
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
var evt = new DenoPermissionUseEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Permission: permission ?? string.Empty,
Module: identity,
Details: details ?? string.Empty);
@@ -42,7 +44,7 @@ internal sealed class DenoRuntimeTraceRecorder
public void AddNpmResolution(string specifier, string package, string version, string resolved, bool exists, DateTimeOffset? timestamp = null)
{
_events.Add(new DenoNpmResolutionEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Specifier: specifier ?? string.Empty,
Package: package ?? string.Empty,
Version: version ?? string.Empty,
@@ -54,7 +56,7 @@ internal sealed class DenoRuntimeTraceRecorder
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
_events.Add(new DenoWasmLoadEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Module: identity,
Importer: importerRelativePath ?? string.Empty,
Reason: reason ?? string.Empty));

View File

@@ -19,12 +19,14 @@ internal sealed class DotNetCallgraphBuilder
private readonly Dictionary<string, string> _typeToAssemblyPath = new();
private readonly Dictionary<string, string?> _assemblyToPurl = new();
private readonly string _contextDigest;
private readonly TimeProvider _timeProvider;
private int _assemblyCount;
private int _typeCount;
public DotNetCallgraphBuilder(string contextDigest)
public DotNetCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
{
_contextDigest = contextDigest;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -114,7 +116,7 @@ internal sealed class DotNetCallgraphBuilder
var contentHash = DotNetGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
var metadata = new DotNetGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: _timeProvider.GetUtcNow(),
GeneratorVersion: DotNetGraphIdentifiers.GetGeneratorVersion(),
ContextDigest: _contextDigest,
AssemblyCount: _assemblyCount,

View File

@@ -16,12 +16,14 @@ internal sealed class JavaCallgraphBuilder
private readonly List<JavaUnknown> _unknowns = new();
private readonly Dictionary<string, string> _classToJarPath = new();
private readonly string _contextDigest;
private readonly TimeProvider _timeProvider;
private int _jarCount;
private int _classCount;
public JavaCallgraphBuilder(string contextDigest)
public JavaCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
{
_contextDigest = contextDigest;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -177,7 +179,7 @@ internal sealed class JavaCallgraphBuilder
var contentHash = JavaGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
var metadata = new JavaGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: _timeProvider.GetUtcNow(),
GeneratorVersion: JavaGraphIdentifiers.GetGeneratorVersion(),
ContextDigest: _contextDigest,
JarCount: _jarCount,

View File

@@ -28,13 +28,14 @@ internal static class JavaEntrypointAocWriter
string tenantId,
string scanId,
Stream outputStream,
CancellationToken cancellationToken)
TimeProvider? timeProvider = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resolution);
ArgumentNullException.ThrowIfNull(outputStream);
using var writer = new StreamWriter(outputStream, Encoding.UTF8, leaveOpen: true);
var timestamp = DateTimeOffset.UtcNow;
var timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow();
// Write header record
var header = new AocHeader

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Resolver;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Runtime;
@@ -187,7 +188,7 @@ internal static class JavaRuntimeIngestor
ResolutionPath: ImmutableArray.Create("runtime-trace"),
Metadata: ImmutableDictionary<string, string>.Empty
.Add("runtime.invocation_count", entry.InvocationCount.ToString())
.Add("runtime.first_seen", entry.FirstSeen.ToString("O")));
.Add("runtime.first_seen", entry.FirstSeen.ToString("O", CultureInfo.InvariantCulture)));
}
private static JavaResolutionStatistics RecalculateStatistics(

View File

@@ -249,7 +249,7 @@ internal static class PythonDistributionLoader
return false;
}
private static void AddFileEvidence(LanguageAnalyzerContext context, string path, string source, ICollection<LanguageComponentEvidence> evidence)
private static void AddFileEvidence(LanguageAnalyzerContext context, string? path, string source, ICollection<LanguageComponentEvidence> evidence)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
@@ -15,7 +16,6 @@ internal sealed class PythonRuntimeEvidenceCollector
AllowTrailingCommas = true
};
private readonly TimeProvider _timeProvider;
private readonly List<PythonRuntimeEvent> _events = [];
private readonly Dictionary<string, string> _pathHashes = new();
private readonly HashSet<string> _loadedModules = new(StringComparer.Ordinal);
@@ -26,15 +26,6 @@ internal sealed class PythonRuntimeEvidenceCollector
private string? _pythonVersion;
private string? _platform;
/// <summary>
/// Initializes a new instance of the <see cref="PythonRuntimeEvidenceCollector"/> class.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public PythonRuntimeEvidenceCollector(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Parses a JSON line from the runtime evidence output.
/// </summary>
@@ -399,8 +390,8 @@ internal sealed class PythonRuntimeEvidenceCollector
ThreadId: null));
}
private string GetUtcTimestamp()
private static string GetUtcTimestamp()
{
return _timeProvider.GetUtcNow().ToString("O");
return DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
}
}

View File

@@ -8,12 +8,20 @@
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Python.Tests" />
</ItemGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Python.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj" />

View File

@@ -15,11 +15,13 @@ internal sealed class NativeCallgraphBuilder
private readonly List<NativeUnknown> _unknowns = new();
private readonly Dictionary<ulong, string> _addressToSymbolId = new();
private readonly string _layerDigest;
private readonly TimeProvider _timeProvider;
private int _binaryCount;
public NativeCallgraphBuilder(string layerDigest)
public NativeCallgraphBuilder(string layerDigest, TimeProvider? timeProvider = null)
{
_layerDigest = layerDigest;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -80,7 +82,7 @@ internal sealed class NativeCallgraphBuilder
var contentHash = NativeGraphIdentifiers.ComputeGraphHash(functions, edges, roots);
var metadata = new NativeGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: _timeProvider.GetUtcNow(),
GeneratorVersion: NativeGraphIdentifiers.GetGeneratorVersion(),
LayerDigest: _layerDigest,
BinaryCount: _binaryCount,

View File

@@ -1,3 +1,5 @@
using System.Globalization;
namespace StellaOps.Scanner.Analyzers.Native.Internal.Graph;
/// <summary>
@@ -26,7 +28,7 @@ internal static class NativeGraphDsseWriter
Version: "1.0.0",
LayerDigest: graph.LayerDigest,
ContentHash: graph.ContentHash,
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O"),
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O", CultureInfo.InvariantCulture),
GeneratorVersion: graph.Metadata.GeneratorVersion,
BinaryCount: graph.Metadata.BinaryCount,
FunctionCount: graph.Metadata.FunctionCount,
@@ -126,7 +128,7 @@ internal static class NativeGraphDsseWriter
LayerDigest: graph.LayerDigest,
ContentHash: graph.ContentHash,
Metadata: new NdjsonMetadataPayload(
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O"),
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O", CultureInfo.InvariantCulture),
GeneratorVersion: graph.Metadata.GeneratorVersion,
BinaryCount: graph.Metadata.BinaryCount,
FunctionCount: graph.Metadata.FunctionCount,

View File

@@ -42,6 +42,11 @@ public sealed class SecretsAnalyzer : ILanguageAnalyzer
/// </summary>
public SecretRuleset? Ruleset => _ruleset;
/// <summary>
/// Gets the ruleset version string for tracking and reporting.
/// </summary>
public string RulesetVersion => _ruleset?.Version ?? "unknown";
/// <summary>
/// Sets the ruleset to use for detection.
/// Called by SecretsAnalyzerHost after loading the bundle.
@@ -51,6 +56,43 @@ public sealed class SecretsAnalyzer : ILanguageAnalyzer
_ruleset = ruleset ?? throw new ArgumentNullException(nameof(ruleset));
}
/// <summary>
/// Analyzes raw file content for secrets. Adapter for Worker stage executor.
/// </summary>
public async ValueTask<List<SecretLeakEvidence>> AnalyzeAsync(
byte[] content,
string relativePath,
CancellationToken ct)
{
if (!IsEnabled || content is null || content.Length == 0)
{
return new List<SecretLeakEvidence>();
}
var findings = new List<SecretLeakEvidence>();
foreach (var rule in _ruleset!.GetRulesForFile(relativePath))
{
ct.ThrowIfCancellationRequested();
var matches = await _detector.DetectAsync(content, relativePath, rule, ct);
foreach (var match in matches)
{
var confidence = MapScoreToConfidence(match.ConfidenceScore);
if (confidence < _options.Value.MinConfidence)
{
continue;
}
var evidence = SecretLeakEvidence.FromMatch(match, _masker, _ruleset!, _timeProvider);
findings.Add(evidence);
}
}
return findings;
}
public async ValueTask AnalyzeAsync(
LanguageAnalyzerContext context,
LanguageComponentWriter writer,

View File

@@ -192,6 +192,17 @@ public enum ClaimStatus
/// </summary>
public sealed class BattlecardGenerator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new battlecard generator.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public BattlecardGenerator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Generates a markdown battlecard from claims and metrics.
/// </summary>
@@ -201,7 +212,7 @@ public sealed class BattlecardGenerator
sb.AppendLine("# Stella Ops Scanner - Competitive Battlecard");
sb.AppendLine();
sb.AppendLine($"*Generated: {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC*");
sb.AppendLine($"*Generated: {_timeProvider.GetUtcNow():yyyy-MM-dd HH:mm:ss} UTC*");
sb.AppendLine();
// Key Differentiators

View File

@@ -8,6 +8,17 @@ namespace StellaOps.Scanner.Benchmark.Metrics;
/// </summary>
public sealed class MetricsCalculator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new metrics calculator.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public MetricsCalculator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Calculates metrics for a single image.
/// </summary>
@@ -49,7 +60,7 @@ public sealed class MetricsCalculator
FalsePositives = fp,
TrueNegatives = tn,
FalseNegatives = fn,
Timestamp = DateTimeOffset.UtcNow
Timestamp = _timeProvider.GetUtcNow()
};
}
@@ -74,7 +85,7 @@ public sealed class MetricsCalculator
TotalTrueNegatives = totalTn,
TotalFalseNegatives = totalFn,
PerImageMetrics = perImageMetrics,
Timestamp = DateTimeOffset.UtcNow
Timestamp = _timeProvider.GetUtcNow()
};
}

View File

@@ -12,8 +12,8 @@ Provide deterministic call graph extraction for supported languages and native b
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/scanner/architecture.md`
- `docs/reachability/DELIVERY_GUIDE.md`
- `docs/reachability/binary-reachability-schema.md`
- `docs/modules/reach-graph/guides/DELIVERY_GUIDE.md`
- `docs/modules/reach-graph/guides/binary-reachability-schema.md`
## Working Directory & Boundaries
- Primary scope: `src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/`

View File

@@ -513,8 +513,10 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
var shStrTab = reader.ReadBytes((int)shStrTabSize);
// Find symbol and string tables for resolving names
// Note: symtab/strtab values are captured for future use with static symbols
long symtabOffset = 0, strtabOffset = 0;
long symtabSize = 0;
_ = (symtabOffset, strtabOffset, symtabSize); // Suppress unused warnings
int symtabEntrySize = is64Bit ? 24 : 16;
// Find .dynsym and .dynstr for dynamic relocations

View File

@@ -54,4 +54,10 @@ public static class ScanAnalysisKeys
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
public const string SecretFindings = "analysis.secrets.findings";
public const string SecretRulesetVersion = "analysis.secrets.ruleset.version";
// Sprint: SPRINT_20260106_003_002 - VEX Gate Service
public const string VexGateResults = "analysis.vexgate.results";
public const string VexGateSummary = "analysis.vexgate.summary";
public const string VexGatePolicyVersion = "analysis.vexgate.policy.version";
public const string VexGateBypassed = "analysis.vexgate.bypassed";
}

View File

@@ -70,7 +70,8 @@ public sealed record EpssEvidence
double percentile,
DateOnly modelDate,
string? source = null,
bool fromCache = false)
bool fromCache = false,
TimeProvider? timeProvider = null)
{
return new EpssEvidence
{
@@ -78,7 +79,7 @@ public sealed record EpssEvidence
Score = score,
Percentile = percentile,
ModelDate = modelDate,
CapturedAt = DateTimeOffset.UtcNow,
CapturedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(),
Source = source,
FromCache = fromCache
};

View File

@@ -336,10 +336,6 @@ public sealed class DefaultFalsificationConditionGenerator : IFalsificationCondi
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultFalsificationConditionGenerator"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public DefaultFalsificationConditionGenerator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;

View File

@@ -300,10 +300,6 @@ public sealed class ZeroDayWindowCalculator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ZeroDayWindowCalculator"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public ZeroDayWindowCalculator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;

View File

@@ -102,11 +102,11 @@ public sealed class ProofBundleWriterOptions
/// Default implementation of IProofBundleWriter.
/// Creates ZIP bundles with the following structure:
/// bundle.zip/
/// ├── manifest.json # Canonical JSON scan manifest
/// ├── manifest.dsse.json # DSSE envelope for manifest
/// ├── score_proof.json # ProofLedger nodes array
/// ├── proof_root.dsse.json # DSSE envelope for root hash (optional)
/// └── meta.json # Bundle metadata
/// manifest.json - Canonical JSON scan manifest
/// manifest.dsse.json - DSSE envelope for manifest
/// score_proof.json - ProofLedger nodes array
/// proof_root.dsse.json - DSSE envelope for root hash (optional)
/// meta.json - Bundle metadata
/// </summary>
public sealed class ProofBundleWriter : IProofBundleWriter
{
@@ -120,7 +120,7 @@ public sealed class ProofBundleWriter : IProofBundleWriter
PropertyNameCaseInsensitive = true
};
public ProofBundleWriter(TimeProvider? timeProvider = null, ProofBundleWriterOptions? options = null)
public ProofBundleWriter(ProofBundleWriterOptions? options = null, TimeProvider? timeProvider = null)
{
_options = options ?? new ProofBundleWriterOptions();
_timeProvider = timeProvider ?? TimeProvider.System;

View File

@@ -13,7 +13,7 @@ namespace StellaOps.Scanner.Core;
/// <summary>
/// Captures all inputs that affect a scan's results.
/// Per advisory "Building a Deeper Moat Beyond Reachability" §12.
/// Per advisory "Building a Deeper Moat Beyond Reachability" section 12.
/// This manifest ensures reproducibility: same manifest + same seed = same results.
/// </summary>
/// <param name="ScanId">Unique identifier for this scan run.</param>
@@ -55,8 +55,8 @@ public sealed record ScanManifest(
/// <summary>
/// Create a manifest builder with required fields.
/// </summary>
public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest) =>
new(scanId, artifactDigest);
public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null) =>
new(scanId, artifactDigest, timeProvider);
/// <summary>
/// Serialize to canonical JSON (for hashing).
@@ -99,7 +99,8 @@ public sealed class ScanManifestBuilder
{
private readonly string _scanId;
private readonly string _artifactDigest;
private DateTimeOffset _createdAtUtc = DateTimeOffset.UtcNow;
private readonly TimeProvider _timeProvider;
private DateTimeOffset? _createdAtUtc;
private string? _artifactPurl;
private string _scannerVersion = "1.0.0";
private string _workerVersion = "1.0.0";
@@ -110,10 +111,11 @@ public sealed class ScanManifestBuilder
private byte[] _seed = new byte[32];
private readonly Dictionary<string, string> _knobs = [];
internal ScanManifestBuilder(string scanId, string artifactDigest)
internal ScanManifestBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null)
{
_scanId = scanId ?? throw new ArgumentNullException(nameof(scanId));
_artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ScanManifestBuilder WithCreatedAt(DateTimeOffset createdAtUtc)
@@ -187,7 +189,7 @@ public sealed class ScanManifestBuilder
public ScanManifest Build() => new(
ScanId: _scanId,
CreatedAtUtc: _createdAtUtc,
CreatedAtUtc: _createdAtUtc ?? _timeProvider.GetUtcNow(),
ArtifactDigest: _artifactDigest,
ArtifactPurl: _artifactPurl,
ScannerVersion: _scannerVersion,

View File

@@ -77,11 +77,11 @@ public sealed record ManifestVerificationResult(
string? ErrorMessage = null,
string? KeyId = null)
{
public static ManifestVerificationResult Success(ScanManifest manifest, DateTimeOffset verifiedAt, string? keyId = null) =>
new(true, manifest, verifiedAt, null, keyId);
public static ManifestVerificationResult Success(ScanManifest manifest, string? keyId = null, TimeProvider? timeProvider = null) =>
new(true, manifest, (timeProvider ?? TimeProvider.System).GetUtcNow(), null, keyId);
public static ManifestVerificationResult Failure(DateTimeOffset verifiedAt, string error) =>
new(false, null, verifiedAt, error);
public static ManifestVerificationResult Failure(string error, TimeProvider? timeProvider = null) =>
new(false, null, (timeProvider ?? TimeProvider.System).GetUtcNow(), error);
}
/// <summary>

Some files were not shown because too many files have changed in this diff Show More