Merge remote changes (theirs)
This commit is contained in:
@@ -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.5–5.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.5–5.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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
@@ -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>
|
||||
@@ -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>();
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user