Merge remote changes (theirs)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
// -----------------------------------------------------------------------------
// ISecretAlertDeduplicator.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-006 - Implement rate limiting / deduplication
// Description: Interface for deduplicating and rate-limiting secret alerts.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Handles deduplication and rate limiting for secret alerts.
/// </summary>
/// <remarks>
/// Uses distributed cache (Valkey) to track:
/// - Recent alerts by deduplication key (prevents duplicate alerts)
/// - Alert count per scan (enforces per-scan rate limits)
/// Per SPRINT_20260104_007_BE task SDA-006.
/// </remarks>
public interface ISecretAlertDeduplicator
{
/// <summary>
/// Checks if an alert should be sent or is a duplicate.
/// </summary>
/// <param name="deduplicationKey">The deduplication key (from SecretFindingAlertEvent).</param>
/// <param name="window">Deduplication window (don't alert same key within this period).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if alert should be sent, false if duplicate.</returns>
Task<bool> ShouldAlertAsync(
string deduplicationKey,
TimeSpan window,
CancellationToken cancellationToken = default);
/// <summary>
/// Records that an alert was sent, for future deduplication.
/// </summary>
/// <param name="deduplicationKey">The deduplication key.</param>
/// <param name="window">How long to remember this alert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RecordAlertSentAsync(
string deduplicationKey,
TimeSpan window,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if scan has exceeded alert rate limit.
/// </summary>
/// <param name="scanId">Scan identifier.</param>
/// <param name="maxAlerts">Maximum alerts allowed for this scan.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if under limit, false if exceeded.</returns>
Task<bool> IsUnderRateLimitAsync(
Guid scanId,
int maxAlerts,
CancellationToken cancellationToken = default);
/// <summary>
/// Increments the alert count for a scan.
/// </summary>
/// <param name="scanId">Scan identifier.</param>
/// <param name="ttl">How long to keep the counter (should outlive scan duration).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>New alert count for the scan.</returns>
Task<int> IncrementScanAlertCountAsync(
Guid scanId,
TimeSpan ttl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets current alert count for a scan.
/// </summary>
/// <param name="scanId">Scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Current alert count, 0 if not tracked.</returns>
Task<int> GetScanAlertCountAsync(
Guid scanId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,146 @@
// -----------------------------------------------------------------------------
// ISecretAlertEmitter.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-005 - Add alert emission to SecretsAnalyzerHost
// Description: Interface for emitting secret finding alerts to notification channels.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Emits secret finding alerts to configured notification channels.
/// </summary>
/// <remarks>
/// Implementations handle routing to Slack, Teams, Email, Webhook, PagerDuty, etc.
/// Deduplication and rate limiting are handled by <see cref="ISecretAlertDeduplicator"/>.
/// Per SPRINT_20260104_007_BE task SDA-005.
/// </remarks>
public interface ISecretAlertEmitter
{
/// <summary>
/// Emits an alert for a secret finding.
/// </summary>
/// <param name="alert">The alert event to emit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if alert was emitted to at least one channel, false if skipped.</returns>
/// <remarks>
/// Alert may be skipped due to:
/// - Alerting disabled for tenant
/// - Severity below threshold
/// - No matching destinations
/// - Rate limit exceeded
/// - Deduplicated (same secret alerted recently)
/// </remarks>
Task<AlertEmissionResult> EmitAsync(
SecretFindingAlertEvent alert,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits alerts for multiple findings in a batch.
/// </summary>
/// <param name="alerts">The alert events to emit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Results for each alert.</returns>
/// <remarks>
/// Batch processing respects per-scan rate limits.
/// </remarks>
Task<IReadOnlyList<AlertEmissionResult>> EmitBatchAsync(
IReadOnlyList<SecretFindingAlertEvent> alerts,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an alert emission attempt.
/// </summary>
public sealed record AlertEmissionResult
{
/// <summary>
/// The alert event that was processed.
/// </summary>
public required Guid EventId { get; init; }
/// <summary>
/// Whether the alert was emitted to at least one channel.
/// </summary>
public required bool WasEmitted { get; init; }
/// <summary>
/// Channels the alert was sent to.
/// </summary>
public required IReadOnlyList<string> Channels { get; init; }
/// <summary>
/// Reason if alert was skipped.
/// </summary>
public AlertSkipReason? SkipReason { get; init; }
/// <summary>
/// Additional context about skip reason.
/// </summary>
public string? SkipDetails { get; init; }
/// <summary>
/// Creates a successful emission result.
/// </summary>
public static AlertEmissionResult Success(Guid eventId, IReadOnlyList<string> channels) => new()
{
EventId = eventId,
WasEmitted = true,
Channels = channels,
SkipReason = null,
SkipDetails = null
};
/// <summary>
/// Creates a skipped emission result.
/// </summary>
public static AlertEmissionResult Skipped(Guid eventId, AlertSkipReason reason, string? details = null) => new()
{
EventId = eventId,
WasEmitted = false,
Channels = [],
SkipReason = reason,
SkipDetails = details
};
}
/// <summary>
/// Reason why an alert was not emitted.
/// </summary>
public enum AlertSkipReason
{
/// <summary>
/// Alerting is disabled for the tenant.
/// </summary>
AlertingDisabled,
/// <summary>
/// Finding severity below minimum threshold.
/// </summary>
BelowSeverityThreshold,
/// <summary>
/// No alert destinations configured.
/// </summary>
NoDestinations,
/// <summary>
/// No destinations match the finding (by severity/category filters).
/// </summary>
NoMatchingDestinations,
/// <summary>
/// Rate limit exceeded for this scan.
/// </summary>
RateLimitExceeded,
/// <summary>
/// Same finding was alerted within deduplication window.
/// </summary>
Deduplicated,
/// <summary>
/// Alert emission failed.
/// </summary>
EmissionFailed
}

View File

@@ -0,0 +1,167 @@
// -----------------------------------------------------------------------------
// ISecretAlertRouter.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-007 - Add severity-based routing
// Description: Routes secret alerts to appropriate channels based on severity and filters.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Routes secret alerts to appropriate notification channels based on severity and filters.
/// </summary>
/// <remarks>
/// Per SPRINT_20260104_007_BE task SDA-007.
/// Routing logic:
/// - Critical: Always alert, page on-call
/// - High: Alert to security channel
/// - Medium: Alert if configured
/// - Low: No alert by default
/// </remarks>
public interface ISecretAlertRouter
{
/// <summary>
/// Determines which destinations should receive an alert.
/// </summary>
/// <param name="alert">The alert event.</param>
/// <param name="settings">Tenant's alert settings.</param>
/// <returns>List of destinations that should receive the alert.</returns>
IReadOnlyList<SecretAlertDestination> RouteAlert(
SecretFindingAlertEvent alert,
SecretAlertSettings settings);
/// <summary>
/// Checks if an alert should be sent based on severity threshold.
/// </summary>
/// <param name="findingSeverity">Severity of the finding.</param>
/// <param name="minimumSeverity">Minimum severity required for alerting.</param>
/// <returns>True if finding meets severity threshold.</returns>
bool MeetsSeverityThreshold(SecretSeverity findingSeverity, SecretSeverity minimumSeverity);
}
/// <summary>
/// Default implementation of secret alert router.
/// </summary>
public sealed class SecretAlertRouter : ISecretAlertRouter
{
/// <inheritdoc />
public IReadOnlyList<SecretAlertDestination> RouteAlert(
SecretFindingAlertEvent alert,
SecretAlertSettings settings)
{
ArgumentNullException.ThrowIfNull(alert);
ArgumentNullException.ThrowIfNull(settings);
if (!settings.Enabled)
{
return [];
}
if (!MeetsSeverityThreshold(alert.Severity, settings.MinimumAlertSeverity))
{
return [];
}
var matchingDestinations = new List<SecretAlertDestination>();
foreach (var destination in settings.Destinations)
{
if (DestinationMatchesAlert(destination, alert))
{
matchingDestinations.Add(destination);
}
}
return matchingDestinations;
}
/// <inheritdoc />
public bool MeetsSeverityThreshold(SecretSeverity findingSeverity, SecretSeverity minimumSeverity)
{
// Higher severity value = more severe
return findingSeverity >= minimumSeverity;
}
/// <summary>
/// Checks if a destination matches the alert based on its filters.
/// </summary>
private static bool DestinationMatchesAlert(SecretAlertDestination destination, SecretFindingAlertEvent alert)
{
// Check severity filter if specified
if (destination.SeverityFilter is { Count: > 0 })
{
if (!destination.SeverityFilter.Contains(alert.Severity))
{
return false;
}
}
// Check category filter if specified
if (destination.RuleCategoryFilter is { Count: > 0 })
{
if (!destination.RuleCategoryFilter.Contains(alert.RuleCategory, StringComparer.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
}
/// <summary>
/// Extension methods for alert routing.
/// </summary>
public static class AlertRoutingExtensions
{
/// <summary>
/// Gets the default priority level for a severity.
/// </summary>
public static AlertPriority GetDefaultPriority(this SecretSeverity severity)
{
return severity switch
{
SecretSeverity.Critical => AlertPriority.P1Immediate,
SecretSeverity.High => AlertPriority.P2Urgent,
SecretSeverity.Medium => AlertPriority.P3Normal,
SecretSeverity.Low => AlertPriority.P4Info,
_ => AlertPriority.P4Info
};
}
/// <summary>
/// Determines if this severity should page on-call.
/// </summary>
public static bool ShouldPage(this SecretSeverity severity)
{
return severity == SecretSeverity.Critical;
}
}
/// <summary>
/// Alert priority levels for incident management integration.
/// </summary>
public enum AlertPriority
{
/// <summary>
/// P1: Immediate attention required, page on-call.
/// </summary>
P1Immediate = 1,
/// <summary>
/// P2: Urgent, requires prompt attention.
/// </summary>
P2Urgent = 2,
/// <summary>
/// P3: Normal priority, address in timely manner.
/// </summary>
P3Normal = 3,
/// <summary>
/// P4: Informational, for awareness only.
/// </summary>
P4Info = 4
}

View File

@@ -0,0 +1,222 @@
// -----------------------------------------------------------------------------
// SecretAlertEmitter.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-005 - Add alert emission to SecretsAnalyzerHost
// Description: Main implementation of secret alert emission with routing, deduplication, and rate limiting.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Emits secret finding alerts with routing, deduplication, and rate limiting.
/// </summary>
/// <remarks>
/// Per SPRINT_20260104_007_BE task SDA-005.
/// Flow:
/// 1. Check if alerting is enabled for tenant
/// 2. Check severity threshold
/// 3. Route to matching destinations
/// 4. Check rate limit
/// 5. Check deduplication
/// 6. Emit to notification channels
/// </remarks>
public sealed class SecretAlertEmitter : ISecretAlertEmitter
{
private readonly ISecretAlertRouter _router;
private readonly ISecretAlertDeduplicator _deduplicator;
private readonly ISecretAlertChannelSender _channelSender;
private readonly ISecretAlertSettingsProvider _settingsProvider;
private readonly ILogger<SecretAlertEmitter> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SecretAlertEmitter"/> class.
/// </summary>
public SecretAlertEmitter(
ISecretAlertRouter router,
ISecretAlertDeduplicator deduplicator,
ISecretAlertChannelSender channelSender,
ISecretAlertSettingsProvider settingsProvider,
ILogger<SecretAlertEmitter> logger)
{
_router = router ?? throw new ArgumentNullException(nameof(router));
_deduplicator = deduplicator ?? throw new ArgumentNullException(nameof(deduplicator));
_channelSender = channelSender ?? throw new ArgumentNullException(nameof(channelSender));
_settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<AlertEmissionResult> EmitAsync(
SecretFindingAlertEvent alert,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(alert);
try
{
// Get tenant settings
var settings = await _settingsProvider.GetAlertSettingsAsync(alert.TenantId, cancellationToken);
if (settings is null || !settings.Enabled)
{
_logger.LogDebug(
"Alert skipped: alerting disabled for tenant {TenantId}",
alert.TenantId);
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.AlertingDisabled);
}
// Check severity threshold
if (!_router.MeetsSeverityThreshold(alert.Severity, settings.MinimumAlertSeverity))
{
_logger.LogDebug(
"Alert skipped: severity {Severity} below threshold {Threshold}",
alert.Severity,
settings.MinimumAlertSeverity);
return AlertEmissionResult.Skipped(
alert.EventId,
AlertSkipReason.BelowSeverityThreshold,
$"Finding severity {alert.Severity} is below minimum {settings.MinimumAlertSeverity}");
}
// Route to matching destinations
var destinations = _router.RouteAlert(alert, settings);
if (destinations.Count == 0)
{
_logger.LogDebug(
"Alert skipped: no matching destinations for {RuleCategory}/{Severity}",
alert.RuleCategory,
alert.Severity);
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.NoMatchingDestinations);
}
// Check rate limit
if (!await _deduplicator.IsUnderRateLimitAsync(alert.ScanId, settings.MaxAlertsPerScan, cancellationToken))
{
_logger.LogWarning(
"Alert skipped: rate limit exceeded for scan {ScanId} (max {MaxAlerts})",
alert.ScanId,
settings.MaxAlertsPerScan);
return AlertEmissionResult.Skipped(
alert.EventId,
AlertSkipReason.RateLimitExceeded,
$"Scan {alert.ScanId} exceeded {settings.MaxAlertsPerScan} alerts");
}
// Check deduplication
if (!await _deduplicator.ShouldAlertAsync(alert.DeduplicationKey, settings.DeduplicationWindow, cancellationToken))
{
_logger.LogDebug(
"Alert skipped: duplicate within {Window} for key {Key}",
settings.DeduplicationWindow,
alert.DeduplicationKey);
return AlertEmissionResult.Skipped(
alert.EventId,
AlertSkipReason.Deduplicated,
$"Same finding alerted within {settings.DeduplicationWindow}");
}
// Send to channels
var sentChannels = new List<string>();
foreach (var destination in destinations)
{
try
{
await _channelSender.SendAsync(alert, destination, settings, cancellationToken);
sentChannels.Add($"{destination.ChannelType}:{destination.Name}");
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to send alert to {ChannelType}:{ChannelId}",
destination.ChannelType,
destination.ChannelId);
// Continue with other destinations
}
}
if (sentChannels.Count == 0)
{
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.EmissionFailed, "All channel sends failed");
}
// Record alert sent for deduplication and rate limiting
await _deduplicator.RecordAlertSentAsync(alert.DeduplicationKey, settings.DeduplicationWindow, cancellationToken);
await _deduplicator.IncrementScanAlertCountAsync(alert.ScanId, TimeSpan.FromHours(4), cancellationToken);
_logger.LogInformation(
"Alert emitted for {RuleId} ({Severity}) in {ImageRef} to {ChannelCount} channels",
alert.RuleId,
alert.Severity,
alert.ImageRef,
sentChannels.Count);
return AlertEmissionResult.Success(alert.EventId, sentChannels);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error emitting alert {EventId}", alert.EventId);
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.EmissionFailed, ex.Message);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlertEmissionResult>> EmitBatchAsync(
IReadOnlyList<SecretFindingAlertEvent> alerts,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(alerts);
var results = new List<AlertEmissionResult>(alerts.Count);
foreach (var alert in alerts)
{
var result = await EmitAsync(alert, cancellationToken);
results.Add(result);
// Stop if rate limit hit
if (result.SkipReason == AlertSkipReason.RateLimitExceeded)
{
// Mark remaining as rate limited
foreach (var remaining in alerts.Skip(results.Count))
{
results.Add(AlertEmissionResult.Skipped(
remaining.EventId,
AlertSkipReason.RateLimitExceeded,
"Batch rate limit exceeded"));
}
break;
}
}
return results;
}
}
/// <summary>
/// Provides alert settings for a tenant.
/// </summary>
public interface ISecretAlertSettingsProvider
{
/// <summary>
/// Gets alert settings for a tenant.
/// </summary>
Task<SecretAlertSettings?> GetAlertSettingsAsync(Guid tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Sends alerts to notification channels.
/// </summary>
public interface ISecretAlertChannelSender
{
/// <summary>
/// Sends an alert to a specific channel.
/// </summary>
Task SendAsync(
SecretFindingAlertEvent alert,
SecretAlertDestination destination,
SecretAlertSettings settings,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// SecretFindingAlertEvent.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-002 - Create SecretFindingAlertEvent
// Description: Alert event for secret findings to be routed to notification channels.
// -----------------------------------------------------------------------------
using System.Globalization;
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Alert event emitted when a secret is detected in a scan.
/// Routed to configured notification channels based on severity and settings.
/// </summary>
/// <remarks>
/// Implements deterministic deduplication key for rate limiting.
/// Per SPRINT_20260104_007_BE task SDA-002.
/// </remarks>
public sealed record SecretFindingAlertEvent
{
/// <summary>
/// Unique identifier for this alert event.
/// </summary>
public required Guid EventId { get; init; }
/// <summary>
/// Tenant that owns the scanned image.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// Scan job identifier.
/// </summary>
public required Guid ScanId { get; init; }
/// <summary>
/// Container image reference where secret was found.
/// Example: "registry.example.com/app:v1.2.3"
/// </summary>
public required string ImageRef { get; init; }
/// <summary>
/// Severity level of the finding.
/// </summary>
public required SecretSeverity Severity { get; init; }
/// <summary>
/// Detection rule identifier.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Human-readable rule name.
/// </summary>
public required string RuleName { get; init; }
/// <summary>
/// Rule category (e.g., "cloud_credentials", "api_keys").
/// </summary>
public required string RuleCategory { get; init; }
/// <summary>
/// File path within the image where secret was found.
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Line number where secret was found (1-based).
/// </summary>
public required int LineNumber { get; init; }
/// <summary>
/// Masked representation of the detected secret value.
/// Always masked based on tenant's revelation policy.
/// </summary>
public required string MaskedValue { get; init; }
/// <summary>
/// When the secret was detected (UTC).
/// </summary>
public required DateTimeOffset DetectedAt { get; init; }
/// <summary>
/// Identity or source that triggered the scan.
/// </summary>
public required string ScanTriggeredBy { get; init; }
/// <summary>
/// Image digest for provenance.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Optional remediation guidance text.
/// </summary>
public string? RemediationGuidance { get; init; }
/// <summary>
/// Deep link URL to view the finding in StellaOps UI.
/// </summary>
public string? FindingUrl { get; init; }
/// <summary>
/// Deterministic deduplication key for rate limiting.
/// Based on tenant, rule, file, and line - ensures same finding doesn't alert twice.
/// </summary>
/// <remarks>
/// Format: "{TenantId}:{RuleId}:{FilePath}:{LineNumber}"
/// </remarks>
public string DeduplicationKey => string.Format(
CultureInfo.InvariantCulture,
"{0}:{1}:{2}:{3}",
TenantId,
RuleId,
FilePath,
LineNumber);
/// <summary>
/// Alternative deduplication key including image reference.
/// Use this when the same secret in different images should trigger separate alerts.
/// </summary>
public string DeduplicationKeyWithImage => string.Format(
CultureInfo.InvariantCulture,
"{0}:{1}:{2}:{3}:{4}",
TenantId,
ImageRef,
RuleId,
FilePath,
LineNumber);
/// <summary>
/// Creates an alert event from a secret finding.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="scanId">Scan job identifier.</param>
/// <param name="imageRef">Container image reference.</param>
/// <param name="finding">The secret finding details.</param>
/// <param name="maskedValue">Pre-masked value based on tenant policy.</param>
/// <param name="scanTriggeredBy">Identity that triggered the scan.</param>
/// <param name="eventId">Event identifier.</param>
/// <param name="detectedAt">Detection timestamp.</param>
/// <returns>A new alert event.</returns>
public static SecretFindingAlertEvent Create(
Guid tenantId,
Guid scanId,
string imageRef,
SecretFindingInfo finding,
string maskedValue,
string scanTriggeredBy,
Guid eventId,
DateTimeOffset detectedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageRef);
ArgumentNullException.ThrowIfNull(finding);
ArgumentException.ThrowIfNullOrWhiteSpace(maskedValue);
ArgumentException.ThrowIfNullOrWhiteSpace(scanTriggeredBy);
return new SecretFindingAlertEvent
{
EventId = eventId,
TenantId = tenantId,
ScanId = scanId,
ImageRef = imageRef,
Severity = finding.Severity,
RuleId = finding.RuleId,
RuleName = finding.RuleName,
RuleCategory = finding.RuleCategory,
FilePath = finding.FilePath,
LineNumber = finding.LineNumber,
MaskedValue = maskedValue,
DetectedAt = detectedAt,
ScanTriggeredBy = scanTriggeredBy,
ImageDigest = finding.ImageDigest,
RemediationGuidance = finding.RemediationGuidance,
FindingUrl = null // Set by caller with appropriate URL
};
}
}
/// <summary>
/// Minimal finding info needed to create an alert event.
/// </summary>
public sealed record SecretFindingInfo
{
/// <summary>
/// Severity of the finding.
/// </summary>
public required SecretSeverity Severity { get; init; }
/// <summary>
/// Rule identifier.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Human-readable rule name.
/// </summary>
public required string RuleName { get; init; }
/// <summary>
/// Rule category.
/// </summary>
public required string RuleCategory { get; init; }
/// <summary>
/// File path where secret was found.
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Line number (1-based).
/// </summary>
public required int LineNumber { get; init; }
/// <summary>
/// Image digest for provenance.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Remediation guidance.
/// </summary>
public string? RemediationGuidance { get; init; }
}

View File

@@ -1,152 +1,45 @@
// -----------------------------------------------------------------------------
// SecretAlertSettings.cs
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
// Sprint: SPRINT_20260104_007_BE - Secret Detection Alert Integration
// Task: SDC-001, SDA-001 - Define alert settings models
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-001 - Define SecretDetectionSettings domain model (alert portion)
// Description: Configuration for secret detection alerting.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using StellaOps.Scanner.Analyzers.Secrets;
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Alert configuration for secret detection findings.
/// Severity levels for secret detection rules.
/// </summary>
public sealed record SecretAlertSettings
public enum SecretSeverity
{
/// <summary>
/// Enable/disable alerting for this tenant.
/// Informational finding, lowest priority.
/// </summary>
public bool Enabled { get; init; } = true;
Low = 0,
/// <summary>
/// Minimum severity to trigger alert.
/// Moderate risk, should be reviewed.
/// </summary>
public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High;
Medium = 1,
/// <summary>
/// Alert destinations by channel type.
/// Significant risk, should be addressed promptly.
/// </summary>
public ImmutableArray<SecretAlertDestination> Destinations { get; init; } = [];
High = 2,
/// <summary>
/// Rate limit: max alerts per scan.
/// Critical risk, requires immediate attention.
/// </summary>
[Range(1, 1000)]
public int MaxAlertsPerScan { get; init; } = 10;
/// <summary>
/// Rate limit: max alerts per hour per tenant.
/// </summary>
[Range(1, 10000)]
public int MaxAlertsPerHour { get; init; } = 100;
/// <summary>
/// Deduplication window: don't re-alert same secret within this period.
/// </summary>
public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24);
/// <summary>
/// Include file path in alert (may reveal repo structure).
/// </summary>
public bool IncludeFilePath { get; init; } = true;
/// <summary>
/// Include masked secret value in alert.
/// </summary>
public bool IncludeMaskedValue { get; init; } = true;
/// <summary>
/// Include line number in alert.
/// </summary>
public bool IncludeLineNumber { get; init; } = true;
/// <summary>
/// Group similar findings into a single alert.
/// </summary>
public bool GroupSimilarFindings { get; init; } = true;
/// <summary>
/// Maximum findings to group in a single alert.
/// </summary>
[Range(1, 100)]
public int MaxFindingsPerGroupedAlert { get; init; } = 10;
/// <summary>
/// Default alert settings.
/// </summary>
public static readonly SecretAlertSettings Default = new();
Critical = 3
}
/// <summary>
/// Alert destination configuration.
/// </summary>
public sealed record SecretAlertDestination
{
/// <summary>
/// Unique identifier for this destination.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Human-readable name for this destination.
/// </summary>
[Required]
[StringLength(200, MinimumLength = 1)]
public required string Name { get; init; }
/// <summary>
/// Type of alert channel.
/// </summary>
public required AlertChannelType ChannelType { get; init; }
/// <summary>
/// Channel identifier (Slack channel ID, email, webhook URL, etc.).
/// </summary>
[Required]
[StringLength(1000, MinimumLength = 1)]
public required string ChannelId { get; init; }
/// <summary>
/// Optional severity filter for this destination.
/// </summary>
public ImmutableArray<SecretSeverity>? SeverityFilter { get; init; }
/// <summary>
/// Optional rule category filter for this destination.
/// </summary>
public ImmutableArray<string>? RuleCategoryFilter { get; init; }
/// <summary>
/// Whether this destination is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// When this destination was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When this destination was last tested.
/// </summary>
public DateTimeOffset? LastTestedAt { get; init; }
/// <summary>
/// Result of the last test.
/// </summary>
public AlertDestinationTestResult? LastTestResult { get; init; }
}
/// <summary>
/// Type of alert channel.
/// Alert channel types supported for secret notifications.
/// </summary>
public enum AlertChannelType
{
/// <summary>
/// Slack channel or DM.
/// Slack workspace channel.
/// </summary>
Slack = 0,
@@ -156,53 +49,191 @@ public enum AlertChannelType
Teams = 1,
/// <summary>
/// Email address.
/// Email notification.
/// </summary>
Email = 2,
/// <summary>
/// Generic webhook URL.
/// Generic webhook endpoint.
/// </summary>
Webhook = 3,
/// <summary>
/// PagerDuty service.
/// PagerDuty incident.
/// </summary>
PagerDuty = 4,
/// <summary>
/// Opsgenie service.
/// </summary>
Opsgenie = 5,
/// <summary>
/// Discord webhook.
/// </summary>
Discord = 6
PagerDuty = 4
}
/// <summary>
/// Result of testing an alert destination.
/// Configuration for secret detection alerting.
/// </summary>
public sealed record AlertDestinationTestResult
public sealed record SecretAlertSettings
{
/// <summary>
/// Whether the test was successful.
/// Enable/disable alerting for this tenant.
/// </summary>
public required bool Success { get; init; }
public bool Enabled { get; init; } = false;
/// <summary>
/// When the test was performed.
/// Minimum severity to trigger an alert.
/// </summary>
public required DateTimeOffset TestedAt { get; init; }
public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High;
/// <summary>
/// Error message if the test failed.
/// Alert destinations by channel type.
/// </summary>
public string? ErrorMessage { get; init; }
public IReadOnlyList<SecretAlertDestination> Destinations { get; init; } = [];
/// <summary>
/// Response time in milliseconds.
/// Maximum alerts to send per scan (rate limiting).
/// </summary>
public int? ResponseTimeMs { get; init; }
public int MaxAlertsPerScan { get; init; } = 10;
/// <summary>
/// Don't re-alert for same secret within this window.
/// </summary>
public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24);
/// <summary>
/// Include file path in alert message.
/// </summary>
public bool IncludeFilePath { get; init; } = true;
/// <summary>
/// Include masked secret value in alert message.
/// </summary>
public bool IncludeMaskedValue { get; init; } = true;
/// <summary>
/// Include image reference in alert message.
/// </summary>
public bool IncludeImageRef { get; init; } = true;
/// <summary>
/// Custom message prefix for alerts.
/// </summary>
public string? AlertMessagePrefix { get; init; }
/// <summary>
/// Validates the alert settings.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (MaxAlertsPerScan < 1 || MaxAlertsPerScan > 100)
{
errors.Add("MaxAlertsPerScan must be between 1 and 100");
}
if (DeduplicationWindow < TimeSpan.FromMinutes(5))
{
errors.Add("DeduplicationWindow must be at least 5 minutes");
}
if (DeduplicationWindow > TimeSpan.FromDays(7))
{
errors.Add("DeduplicationWindow must be 7 days or less");
}
if (Enabled && Destinations.Count == 0)
{
errors.Add("At least one destination is required when alerting is enabled");
}
foreach (var dest in Destinations)
{
errors.AddRange(dest.Validate().Select(e => $"Destination '{dest.Name}': {e}"));
}
return errors;
}
/// <summary>
/// Creates default alert settings (disabled).
/// </summary>
public static SecretAlertSettings Default => new();
}
/// <summary>
/// Defines an alert destination for secret findings.
/// </summary>
public sealed record SecretAlertDestination
{
/// <summary>
/// Unique identifier for this destination.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Human-readable name for the destination.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Channel type (Slack, Teams, Email, etc.).
/// </summary>
public required AlertChannelType ChannelType { get; init; }
/// <summary>
/// Channel identifier (Slack channel ID, email address, webhook URL).
/// </summary>
public required string ChannelId { get; init; }
/// <summary>
/// Optional: Only alert for these severities.
/// If empty, respects MinimumAlertSeverity from parent settings.
/// </summary>
public IReadOnlyList<SecretSeverity>? SeverityFilter { get; init; }
/// <summary>
/// Optional: Only alert for these rule categories.
/// If empty, alerts for all categories.
/// </summary>
public IReadOnlyList<string>? RuleCategoryFilter { get; init; }
/// <summary>
/// Whether this destination is currently active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// Validates the destination configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Name))
{
errors.Add("Name is required");
}
if (string.IsNullOrWhiteSpace(ChannelId))
{
errors.Add("ChannelId is required");
}
else
{
// Validate channel ID format based on type
switch (ChannelType)
{
case AlertChannelType.Email:
if (!ChannelId.Contains('@'))
{
errors.Add("Email address must contain @");
}
break;
case AlertChannelType.Webhook:
if (!Uri.TryCreate(ChannelId, UriKind.Absolute, out var uri) ||
(uri.Scheme != "https" && uri.Scheme != "http"))
{
errors.Add("Webhook must be a valid HTTP(S) URL");
}
break;
}
}
return errors;
}
}

View File

@@ -1,182 +1,194 @@
// -----------------------------------------------------------------------------
// SecretDetectionSettings.cs
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-001 - Define SecretDetectionSettings domain model
// Description: Per-tenant configuration for secret detection behavior.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Per-tenant settings for secret leak detection.
/// Per-tenant configuration for secret detection.
/// Controls all aspects of secret leak detection including revelation policy,
/// enabled rules, exceptions, and alerting.
/// </summary>
public sealed record SecretDetectionSettings
{
/// <summary>
/// Unique identifier for the tenant.
/// Tenant this configuration belongs to.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// Whether secret detection is enabled for this tenant.
/// </summary>
public required bool Enabled { get; init; }
public bool Enabled { get; init; } = false;
/// <summary>
/// Policy controlling how detected secrets are revealed/masked.
/// Revelation policy configuration controlling how secrets are masked/shown.
/// </summary>
public required SecretRevelationPolicy RevelationPolicy { get; init; }
public RevelationPolicyConfig RevelationPolicy { get; init; } = RevelationPolicyConfig.Default;
/// <summary>
/// Configuration for revelation policy behavior.
/// Enabled rule categories. Empty means all categories enabled.
/// Examples: "aws", "gcp", "azure", "generic", "private-keys", "database"
/// </summary>
public required RevelationPolicyConfig RevelationConfig { get; init; }
public IReadOnlyList<string> EnabledRuleCategories { get; init; } = [];
/// <summary>
/// Categories of rules that are enabled for scanning.
/// Disabled rule IDs (overrides category enablement).
/// </summary>
public required ImmutableArray<string> EnabledRuleCategories { get; init; }
public IReadOnlyList<string> DisabledRuleIds { get; init; } = [];
/// <summary>
/// Exception patterns for allowlisting known false positives.
/// Exception patterns for suppressing false positives.
/// </summary>
public required ImmutableArray<SecretExceptionPattern> Exceptions { get; init; }
public IReadOnlyList<SecretExceptionPattern> Exceptions { get; init; } = [];
/// <summary>
/// Alert configuration for this tenant.
/// Alert configuration for secret findings.
/// </summary>
public required SecretAlertSettings AlertSettings { get; init; }
public SecretAlertSettings AlertSettings { get; init; } = SecretAlertSettings.Default;
/// <summary>
/// When these settings were last updated.
/// Maximum file size to scan for secrets (bytes).
/// Files larger than this are skipped.
/// </summary>
public long MaxFileSizeBytes { get; init; } = 10 * 1024 * 1024; // 10 MB
/// <summary>
/// File extensions to exclude from scanning.
/// </summary>
public IReadOnlyList<string> ExcludedFileExtensions { get; init; } = [".exe", ".dll", ".so", ".dylib", ".bin", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot"];
/// <summary>
/// Path patterns to exclude from scanning (glob patterns).
/// </summary>
public IReadOnlyList<string> ExcludedPaths { get; init; } = ["**/node_modules/**", "**/vendor/**", "**/.git/**"];
/// <summary>
/// Whether to scan binary files (slower, may have false positives).
/// </summary>
public bool ScanBinaryFiles { get; init; } = false;
/// <summary>
/// Whether to require signature verification for rule bundles.
/// </summary>
public bool RequireSignedRuleBundles { get; init; } = true;
/// <summary>
/// When this configuration was last updated.
/// </summary>
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Identity of the user who last updated settings.
/// Who last updated this configuration.
/// </summary>
public required string UpdatedBy { get; init; }
/// <summary>
/// Creates default settings for a new tenant.
/// Version number for optimistic concurrency.
/// </summary>
public static SecretDetectionSettings CreateDefault(
Guid tenantId,
TimeProvider timeProvider,
string createdBy = "system")
{
ArgumentNullException.ThrowIfNull(timeProvider);
public int Version { get; init; } = 1;
return new SecretDetectionSettings
/// <summary>
/// Validates the entire configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
// Validate revelation policy
errors.AddRange(RevelationPolicy.Validate().Select(e => $"RevelationPolicy: {e}"));
// Validate alert settings
errors.AddRange(AlertSettings.Validate().Select(e => $"AlertSettings: {e}"));
// Validate exceptions
var exceptionNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var exception in Exceptions)
{
TenantId = tenantId,
Enabled = false, // Opt-in by default
RevelationPolicy = SecretRevelationPolicy.PartialReveal,
RevelationConfig = RevelationPolicyConfig.Default,
EnabledRuleCategories = DefaultRuleCategories,
Exceptions = [],
AlertSettings = SecretAlertSettings.Default,
UpdatedAt = timeProvider.GetUtcNow(),
UpdatedBy = createdBy
};
if (!exceptionNames.Add(exception.Name))
{
errors.Add($"Duplicate exception name: {exception.Name}");
}
errors.AddRange(exception.Validate().Select(e => $"Exception '{exception.Name}': {e}"));
}
// Validate file size limit
if (MaxFileSizeBytes < 1024) // 1 KB minimum
{
errors.Add("MaxFileSizeBytes must be at least 1024 bytes");
}
if (MaxFileSizeBytes > 100 * 1024 * 1024) // 100 MB maximum
{
errors.Add("MaxFileSizeBytes must be 100 MB or less");
}
return errors;
}
/// <summary>
/// Default rule categories for new tenants.
/// Creates default settings for a new tenant.
/// </summary>
public static readonly ImmutableArray<string> DefaultRuleCategories =
[
"cloud-credentials",
"api-keys",
"private-keys",
"tokens",
"passwords"
];
public static SecretDetectionSettings CreateDefault(Guid tenantId, string createdBy, TimeProvider? timeProvider = null) => new()
{
TenantId = tenantId,
Enabled = false,
RevelationPolicy = RevelationPolicyConfig.Default,
AlertSettings = SecretAlertSettings.Default,
UpdatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(),
UpdatedBy = createdBy
};
/// <summary>
/// All available rule categories.
/// Creates a copy with updated timestamp and user.
/// </summary>
public static readonly ImmutableArray<string> AllRuleCategories =
[
"cloud-credentials",
"api-keys",
"private-keys",
"tokens",
"passwords",
"certificates",
"database-credentials",
"messaging-credentials",
"oauth-secrets",
"generic-secrets"
];
public SecretDetectionSettings WithUpdate(string updatedBy, TimeProvider? timeProvider = null) => this with
{
UpdatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(),
UpdatedBy = updatedBy,
Version = Version + 1
};
}
/// <summary>
/// Controls how detected secrets appear in different contexts.
/// Available rule categories for secret detection.
/// </summary>
public enum SecretRevelationPolicy
public static class SecretRuleCategories
{
/// <summary>
/// Show only that a secret was detected, no value shown.
/// Example: [SECRET_DETECTED: aws_access_key_id]
/// </summary>
FullMask = 0,
public const string Aws = "aws";
public const string Gcp = "gcp";
public const string Azure = "azure";
public const string Generic = "generic";
public const string PrivateKeys = "private-keys";
public const string Database = "database";
public const string Messaging = "messaging";
public const string Payment = "payment";
public const string SocialMedia = "social-media";
public const string Internal = "internal";
/// <summary>
/// Show first and last characters.
/// Example: AKIA****WXYZ
/// </summary>
PartialReveal = 1,
public static readonly IReadOnlyList<string> All =
[
Aws,
Gcp,
Azure,
Generic,
PrivateKeys,
Database,
Messaging,
Payment,
SocialMedia,
Internal
];
/// <summary>
/// Show full value (requires elevated permissions).
/// Use only for debugging/incident response.
/// </summary>
FullReveal = 2
}
/// <summary>
/// Detailed configuration for revelation policy behavior.
/// </summary>
public sealed record RevelationPolicyConfig
{
/// <summary>
/// Default policy for UI/API responses.
/// </summary>
public SecretRevelationPolicy DefaultPolicy { get; init; } = SecretRevelationPolicy.PartialReveal;
/// <summary>
/// Policy for exported reports (PDF, JSON).
/// </summary>
public SecretRevelationPolicy ExportPolicy { get; init; } = SecretRevelationPolicy.FullMask;
/// <summary>
/// Policy for logs and telemetry.
/// </summary>
public SecretRevelationPolicy LogPolicy { get; init; } = SecretRevelationPolicy.FullMask;
/// <summary>
/// Roles allowed to use FullReveal.
/// </summary>
public ImmutableArray<string> FullRevealRoles { get; init; } =
["security-admin", "incident-responder"];
/// <summary>
/// Number of characters to show at start for PartialReveal.
/// </summary>
[Range(0, 8)]
public int PartialRevealPrefixChars { get; init; } = 4;
/// <summary>
/// Number of characters to show at end for PartialReveal.
/// </summary>
[Range(0, 8)]
public int PartialRevealSuffixChars { get; init; } = 2;
/// <summary>
/// Default configuration.
/// </summary>
public static readonly RevelationPolicyConfig Default = new();
public static readonly IReadOnlyList<string> DefaultEnabled =
[
Aws,
Gcp,
Azure,
Generic,
PrivateKeys,
Database
];
}

View File

@@ -0,0 +1,196 @@
// -----------------------------------------------------------------------------
// SecretExceptionMatcher.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-007 - Integrate exception patterns into SecretsAnalyzerHost
// Description: Service for matching secret findings against exception patterns.
// -----------------------------------------------------------------------------
using System.Text.RegularExpressions;
using Microsoft.Extensions.FileSystemGlobbing;
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Service for matching secret findings against exception patterns.
/// Determines whether a finding should be suppressed based on configured exceptions.
/// </summary>
public sealed class SecretExceptionMatcher
{
private readonly IReadOnlyList<CompiledExceptionPattern> _compiledPatterns;
private readonly TimeProvider _timeProvider;
public SecretExceptionMatcher(
IEnumerable<SecretExceptionPattern> patterns,
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_compiledPatterns = CompilePatterns(patterns);
}
/// <summary>
/// Checks if a finding matches any exception pattern.
/// </summary>
/// <param name="secretValue">The detected secret value.</param>
/// <param name="ruleId">The rule ID that triggered the finding.</param>
/// <param name="filePath">The file path where the secret was found.</param>
/// <returns>Match result indicating if the finding is excepted.</returns>
public ExceptionMatchResult Match(string secretValue, string ruleId, string filePath)
{
var now = _timeProvider.GetUtcNow();
foreach (var compiled in _compiledPatterns)
{
if (!compiled.Pattern.IsEffective(now))
{
continue;
}
// Check rule ID filter
if (compiled.Pattern.ApplicableRuleIds.Count > 0 &&
!compiled.Pattern.ApplicableRuleIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase))
{
continue;
}
// Check file path glob
if (!string.IsNullOrEmpty(compiled.Pattern.FilePathGlob))
{
if (!MatchesGlob(filePath, compiled.Pattern.FilePathGlob))
{
continue;
}
}
// Check value pattern
try
{
if (compiled.ValueRegex.IsMatch(secretValue))
{
return ExceptionMatchResult.Excepted(compiled.Pattern);
}
}
catch (RegexMatchTimeoutException)
{
// Pattern timed out - treat as non-match for safety
continue;
}
}
return ExceptionMatchResult.NotExcepted("No exception pattern matched");
}
/// <summary>
/// Creates an empty matcher with no patterns.
/// </summary>
public static SecretExceptionMatcher Empty => new([]);
private static IReadOnlyList<CompiledExceptionPattern> CompilePatterns(
IEnumerable<SecretExceptionPattern> patterns)
{
var compiled = new List<CompiledExceptionPattern>();
foreach (var pattern in patterns)
{
try
{
var regex = new Regex(
pattern.ValuePattern,
RegexOptions.Compiled | RegexOptions.Singleline,
TimeSpan.FromSeconds(1));
compiled.Add(new CompiledExceptionPattern(pattern, regex));
}
catch (RegexParseException)
{
// Invalid pattern - skip it
// In production, this should be logged
}
}
return compiled;
}
private static bool MatchesGlob(string filePath, string globPattern)
{
try
{
var matcher = new Matcher();
matcher.AddInclude(globPattern);
// Normalize path separators
var normalizedPath = filePath.Replace('\\', '/');
// Match against the file name and relative path components
var result = matcher.Match(normalizedPath);
return result.HasMatches;
}
catch
{
// Invalid glob - treat as non-match
return false;
}
}
private sealed record CompiledExceptionPattern(
SecretExceptionPattern Pattern,
Regex ValueRegex);
}
/// <summary>
/// Provider interface for loading exception patterns for a tenant.
/// </summary>
public interface ISecretExceptionProvider
{
/// <summary>
/// Gets the active exception patterns for a tenant.
/// </summary>
Task<IReadOnlyList<SecretExceptionPattern>> GetExceptionsAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records that an exception pattern matched a finding.
/// </summary>
Task RecordMatchAsync(
Guid tenantId,
Guid exceptionId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of exception provider for testing.
/// </summary>
public sealed class InMemorySecretExceptionProvider : ISecretExceptionProvider
{
private readonly Dictionary<Guid, List<SecretExceptionPattern>> _exceptions = [];
public void AddException(Guid tenantId, SecretExceptionPattern exception)
{
if (!_exceptions.TryGetValue(tenantId, out var list))
{
list = [];
_exceptions[tenantId] = list;
}
list.Add(exception);
}
public Task<IReadOnlyList<SecretExceptionPattern>> GetExceptionsAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
if (_exceptions.TryGetValue(tenantId, out var list))
{
return Task.FromResult<IReadOnlyList<SecretExceptionPattern>>(list);
}
return Task.FromResult<IReadOnlyList<SecretExceptionPattern>>([]);
}
public Task RecordMatchAsync(
Guid tenantId,
Guid exceptionId,
CancellationToken cancellationToken = default)
{
// No-op for in-memory implementation
return Task.CompletedTask;
}
}

View File

@@ -1,17 +1,17 @@
// -----------------------------------------------------------------------------
// SecretExceptionPattern.cs
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-003 - Create SecretExceptionPattern model for allowlists
// Description: Defines patterns for excluding false positive secret detections.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Pattern for allowlisting known false positives in secret detection.
/// Defines a pattern for excluding detected secrets from findings (allowlist).
/// Used to suppress false positives or known-safe patterns.
/// </summary>
public sealed record SecretExceptionPattern
{
@@ -21,209 +21,163 @@ public sealed record SecretExceptionPattern
public required Guid Id { get; init; }
/// <summary>
/// Human-readable name for this exception.
/// Human-readable name for the exception.
/// </summary>
[Required]
[StringLength(200, MinimumLength = 1)]
public required string Name { get; init; }
/// <summary>
/// Description of why this exception exists.
/// Detailed description of why this exception exists.
/// </summary>
[Required]
[StringLength(2000, MinimumLength = 1)]
public required string Description { get; init; }
/// <summary>
/// Regex pattern to match against detected secret value.
/// Use anchors (^ $) for exact matches.
/// </summary>
[Required]
[StringLength(1000, MinimumLength = 1)]
public required string Pattern { get; init; }
public required string ValuePattern { get; init; }
/// <summary>
/// Type of pattern matching to use.
/// Optional: Only apply to specific rule IDs.
/// If empty, applies to all rules.
/// </summary>
public SecretExceptionMatchType MatchType { get; init; } = SecretExceptionMatchType.Regex;
public IReadOnlyList<string> ApplicableRuleIds { get; init; } = [];
/// <summary>
/// Optional: Only apply to specific rule IDs (glob patterns supported).
/// Optional: Only apply to files matching this glob pattern.
/// Example: "**/test/**", "*.test.ts"
/// </summary>
public ImmutableArray<string>? ApplicableRuleIds { get; init; }
/// <summary>
/// Optional: Only apply to specific file paths (glob pattern).
/// </summary>
[StringLength(500)]
public string? FilePathGlob { get; init; }
/// <summary>
/// Justification for this exception (audit trail).
/// Business justification for this exception (required for audit).
/// </summary>
[Required]
[StringLength(2000, MinimumLength = 10)]
public required string Justification { get; init; }
/// <summary>
/// Expiration date (null = permanent).
/// Expiration date. Null means permanent (requires periodic review).
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// When this exception was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Identity of the user who created this exception.
/// </summary>
[Required]
[StringLength(200)]
public required string CreatedBy { get; init; }
/// <summary>
/// When this exception was last modified.
/// </summary>
public DateTimeOffset? ModifiedAt { get; init; }
/// <summary>
/// Identity of the user who last modified this exception.
/// </summary>
[StringLength(200)]
public string? ModifiedBy { get; init; }
/// <summary>
/// Whether this exception is currently active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// Validates the pattern and returns any errors.
/// When this exception was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Who created this exception.
/// </summary>
public required string CreatedBy { get; init; }
/// <summary>
/// When this exception was last modified.
/// </summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// Who last modified this exception.
/// </summary>
public string? UpdatedBy { get; init; }
/// <summary>
/// Number of times this exception has matched a finding.
/// </summary>
public long MatchCount { get; init; }
/// <summary>
/// Last time this exception matched a finding.
/// </summary>
public DateTimeOffset? LastMatchedAt { get; init; }
/// <summary>
/// Checks if this exception has expired.
/// </summary>
public bool IsExpired(DateTimeOffset now) =>
ExpiresAt.HasValue && now > ExpiresAt.Value;
/// <summary>
/// Checks if this exception is currently effective.
/// </summary>
public bool IsEffective(DateTimeOffset now) =>
IsActive && !IsExpired(now);
/// <summary>
/// Validates the exception pattern.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Pattern))
if (string.IsNullOrWhiteSpace(Name))
{
errors.Add("Pattern cannot be empty");
return errors;
errors.Add("Name is required");
}
else if (Name.Length > 100)
{
errors.Add("Name must be 100 characters or less");
}
if (MatchType == SecretExceptionMatchType.Regex)
if (string.IsNullOrWhiteSpace(ValuePattern))
{
errors.Add("ValuePattern is required");
}
else
{
try
{
_ = new Regex(Pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
_ = new Regex(ValuePattern, RegexOptions.None, TimeSpan.FromSeconds(1));
}
catch (ArgumentException ex)
catch (RegexParseException ex)
{
errors.Add($"Invalid regex pattern: {ex.Message}");
errors.Add($"ValuePattern is not a valid regex: {ex.Message}");
}
}
if (string.IsNullOrWhiteSpace(Justification))
{
errors.Add("Justification is required");
}
else if (Justification.Length < 20)
{
errors.Add("Justification must be at least 20 characters");
}
if (ExpiresAt.HasValue && ExpiresAt.Value < CreatedAt)
{
errors.Add("ExpiresAt cannot be before CreatedAt");
errors.Add("ExpiresAt must be after CreatedAt");
}
return errors;
}
/// <summary>
/// Checks if this exception matches a detected secret.
/// </summary>
/// <param name="maskedValue">The masked secret value</param>
/// <param name="ruleId">The rule ID that detected the secret</param>
/// <param name="filePath">The file path where the secret was found</param>
/// <param name="now">Current time for expiration check</param>
/// <returns>True if this exception applies</returns>
public bool Matches(string maskedValue, string ruleId, string filePath, DateTimeOffset now)
{
// Check if active
if (!IsActive)
return false;
// Check expiration
if (ExpiresAt.HasValue && now > ExpiresAt.Value)
return false;
// Check rule ID filter
if (ApplicableRuleIds is { Length: > 0 })
{
var matchesRule = ApplicableRuleIds.Any(pattern =>
MatchesGlobPattern(ruleId, pattern));
if (!matchesRule)
return false;
}
// Check file path filter
if (!string.IsNullOrEmpty(FilePathGlob))
{
if (!MatchesGlobPattern(filePath, FilePathGlob))
return false;
}
// Check value pattern
return MatchType switch
{
SecretExceptionMatchType.Exact => maskedValue.Equals(Pattern, StringComparison.Ordinal),
SecretExceptionMatchType.Contains => maskedValue.Contains(Pattern, StringComparison.Ordinal),
SecretExceptionMatchType.Regex => MatchesRegex(maskedValue, Pattern),
_ => false
};
}
private static bool MatchesRegex(string value, string pattern)
{
try
{
return Regex.IsMatch(value, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
}
catch (RegexMatchTimeoutException)
{
return false;
}
}
private static bool MatchesGlobPattern(string value, string pattern)
{
if (string.IsNullOrEmpty(pattern))
return true;
// Simple glob matching: * matches any sequence, ? matches single char
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*")
.Replace("\\?", ".") + "$";
try
{
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
}
catch (RegexMatchTimeoutException)
{
return false;
}
}
}
/// <summary>
/// Type of pattern matching for secret exceptions.
/// Result of matching an exception pattern against a finding.
/// </summary>
public enum SecretExceptionMatchType
public sealed record ExceptionMatchResult
{
/// <summary>
/// Exact string match.
/// Whether any exception matched.
/// </summary>
Exact = 0,
public required bool IsExcepted { get; init; }
/// <summary>
/// Substring contains match.
/// The exception that matched, if any.
/// </summary>
Contains = 1,
public SecretExceptionPattern? MatchedException { get; init; }
/// <summary>
/// Regular expression match.
/// Reason for the match or non-match.
/// </summary>
Regex = 2
public string? Reason { get; init; }
public static ExceptionMatchResult NotExcepted(string reason) =>
new() { IsExcepted = false, Reason = reason };
public static ExceptionMatchResult Excepted(SecretExceptionPattern exception) =>
new() { IsExcepted = true, MatchedException = exception, Reason = $"Matched exception: {exception.Name}" };
}

View File

@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------------
// SecretRevelationPolicy.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-002 - Create SecretRevelationPolicy enum and config
// Description: Controls how detected secrets are displayed/masked in different contexts.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Defines how detected secret values are revealed or masked.
/// </summary>
public enum SecretRevelationPolicy
{
/// <summary>
/// Show only that a secret was detected, no value shown.
/// Example: [REDACTED]
/// </summary>
FullMask = 0,
/// <summary>
/// Show first and last N characters (configurable).
/// Example: AKIA****WXYZ
/// </summary>
PartialReveal = 1,
/// <summary>
/// Show full value. Requires elevated permissions and is audit-logged.
/// Use only for debugging/incident response.
/// </summary>
FullReveal = 2
}
/// <summary>
/// Configuration for secret revelation across different contexts.
/// </summary>
public sealed record RevelationPolicyConfig
{
/// <summary>
/// Default policy for UI/API responses.
/// </summary>
public SecretRevelationPolicy DefaultPolicy { get; init; } = SecretRevelationPolicy.PartialReveal;
/// <summary>
/// Policy for exported reports (PDF, JSON, SARIF).
/// </summary>
public SecretRevelationPolicy ExportPolicy { get; init; } = SecretRevelationPolicy.FullMask;
/// <summary>
/// Policy for logs and telemetry. Always enforced as FullMask regardless of setting.
/// </summary>
public SecretRevelationPolicy LogPolicy { get; init; } = SecretRevelationPolicy.FullMask;
/// <summary>
/// Roles allowed to use FullReveal policy.
/// </summary>
public IReadOnlyList<string> FullRevealRoles { get; init; } = ["security-admin", "incident-responder"];
/// <summary>
/// Number of characters to show at start and end for PartialReveal.
/// </summary>
public int PartialRevealChars { get; init; } = 4;
/// <summary>
/// Maximum characters to show in masked portion for PartialReveal.
/// </summary>
public int MaxMaskChars { get; init; } = 8;
/// <summary>
/// Whether to require explicit user action to reveal (even partial).
/// </summary>
public bool RequireExplicitReveal { get; init; } = false;
/// <summary>
/// Validates the configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (PartialRevealChars < 1 || PartialRevealChars > 10)
{
errors.Add("PartialRevealChars must be between 1 and 10");
}
if (MaxMaskChars < 1 || MaxMaskChars > 20)
{
errors.Add("MaxMaskChars must be between 1 and 20");
}
if (FullRevealRoles.Count == 0 && DefaultPolicy == SecretRevelationPolicy.FullReveal)
{
errors.Add("FullRevealRoles must not be empty when DefaultPolicy is FullReveal");
}
return errors;
}
/// <summary>
/// Creates a default secure configuration.
/// </summary>
public static RevelationPolicyConfig Default => new();
/// <summary>
/// Creates a strict configuration with maximum masking.
/// </summary>
public static RevelationPolicyConfig Strict => new()
{
DefaultPolicy = SecretRevelationPolicy.FullMask,
ExportPolicy = SecretRevelationPolicy.FullMask,
LogPolicy = SecretRevelationPolicy.FullMask,
RequireExplicitReveal = true
};
}

View File

@@ -0,0 +1,164 @@
// -----------------------------------------------------------------------------
// SecretMasker.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-008 - Implement revelation policy in findings output
// Description: Utility for masking secret values based on revelation policy.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Masking;
/// <summary>
/// Utility for masking secret values based on revelation policy.
/// Thread-safe and stateless.
/// </summary>
public static class SecretMasker
{
/// <summary>
/// Default mask character.
/// </summary>
public const char MaskChar = '*';
/// <summary>
/// Placeholder for fully masked secrets.
/// </summary>
public const string RedactedPlaceholder = "[REDACTED]";
/// <summary>
/// Masks a secret value according to the specified policy.
/// </summary>
/// <param name="secretValue">The secret value to mask.</param>
/// <param name="policy">The revelation policy to apply.</param>
/// <param name="partialChars">Number of characters to reveal at start/end for partial reveal.</param>
/// <param name="maxMaskChars">Maximum number of mask characters for partial reveal.</param>
/// <returns>The masked value.</returns>
public static string Mask(
string secretValue,
SecretRevelationPolicy policy,
int partialChars = 4,
int maxMaskChars = 8)
{
if (string.IsNullOrEmpty(secretValue))
{
return RedactedPlaceholder;
}
return policy switch
{
SecretRevelationPolicy.FullMask => RedactedPlaceholder,
SecretRevelationPolicy.PartialReveal => MaskPartial(secretValue, partialChars, maxMaskChars),
SecretRevelationPolicy.FullReveal => secretValue,
_ => RedactedPlaceholder
};
}
/// <summary>
/// Masks a secret value using the provided policy configuration.
/// </summary>
/// <param name="secretValue">The secret value to mask.</param>
/// <param name="config">The revelation policy configuration.</param>
/// <param name="context">The context (default, export, log) to use.</param>
/// <returns>The masked value.</returns>
public static string Mask(
string secretValue,
RevelationPolicyConfig config,
MaskingContext context = MaskingContext.Default)
{
var policy = context switch
{
MaskingContext.Default => config.DefaultPolicy,
MaskingContext.Export => config.ExportPolicy,
MaskingContext.Log => SecretRevelationPolicy.FullMask, // Always enforce full mask for logs
_ => config.DefaultPolicy
};
return Mask(secretValue, policy, config.PartialRevealChars, config.MaxMaskChars);
}
/// <summary>
/// Partially masks a value, showing first and last N characters.
/// </summary>
private static string MaskPartial(string value, int revealChars, int maxMaskChars)
{
if (value.Length <= revealChars * 2)
{
// Value too short - mask entirely
return new string(MaskChar, value.Length);
}
var prefix = value[..revealChars];
var suffix = value[^revealChars..];
var hiddenLength = value.Length - (revealChars * 2);
var maskLength = Math.Min(hiddenLength, maxMaskChars);
var masked = new string(MaskChar, maskLength);
return $"{prefix}{masked}{suffix}";
}
/// <summary>
/// Creates a safe string representation for logging.
/// Never reveals more than type information.
/// </summary>
/// <param name="secretType">The type of secret detected.</param>
/// <param name="valueLength">Length of the original value.</param>
/// <returns>Safe log message.</returns>
public static string ForLog(string secretType, int valueLength)
{
return $"[SECRET_DETECTED: {secretType}, length={valueLength}]";
}
/// <summary>
/// Checks if a string appears to be already masked.
/// </summary>
public static bool IsMasked(string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
return value == RedactedPlaceholder ||
value.Contains(MaskChar) ||
value.StartsWith("[SECRET_DETECTED:", StringComparison.Ordinal);
}
/// <summary>
/// Masks all occurrences of a secret in a larger text.
/// </summary>
/// <param name="text">The text containing secrets.</param>
/// <param name="secretValue">The secret value to mask.</param>
/// <param name="policy">The revelation policy to apply.</param>
/// <returns>Text with secrets masked.</returns>
public static string MaskInText(string text, string secretValue, SecretRevelationPolicy policy)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(secretValue))
{
return text;
}
var masked = Mask(secretValue, policy);
return text.Replace(secretValue, masked, StringComparison.Ordinal);
}
}
/// <summary>
/// Context for determining which masking policy to apply.
/// </summary>
public enum MaskingContext
{
/// <summary>
/// Default context (UI/API responses).
/// </summary>
Default = 0,
/// <summary>
/// Export context (reports, SARIF, JSON exports).
/// </summary>
Export = 1,
/// <summary>
/// Log context (always fully masked).
/// </summary>
Log = 2
}

View File

@@ -21,7 +21,7 @@ public sealed record ComponentDiffRequest
public SbomView View { get; init; } = SbomView.Inventory;
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
public string? OldImageDigest { get; init; }
= null;

View File

@@ -105,13 +105,16 @@ public sealed class CbomAggregationService : ICbomAggregationService
{
private readonly IEnumerable<ICryptoAssetExtractor> _extractors;
private readonly ILogger<CbomAggregationService> _logger;
private readonly TimeProvider _timeProvider;
public CbomAggregationService(
IEnumerable<ICryptoAssetExtractor> extractors,
ILogger<CbomAggregationService> logger)
ILogger<CbomAggregationService> logger,
TimeProvider? timeProvider = null)
{
_extractors = extractors;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<CbomAggregationResult> AggregateAsync(
@@ -167,7 +170,7 @@ public sealed class CbomAggregationService : ICbomAggregationService
ByComponent = byComponentImmutable,
UniqueAlgorithms = uniqueAlgorithms,
RiskAssessment = AssessRisk(assetsArray),
GeneratedAt = DateTimeOffset.UtcNow.ToString("o")
GeneratedAt = _timeProvider.GetUtcNow().ToString("o")
};
}

View File

@@ -0,0 +1,320 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Service for building and validating composition recipes.
/// </summary>
public interface ICompositionRecipeService
{
/// <summary>
/// Builds a composition recipe from a composition result.
/// </summary>
CompositionRecipeResponse BuildRecipe(
string scanId,
string imageDigest,
DateTimeOffset createdAt,
SbomCompositionResult compositionResult,
string? generatorName = null,
string? generatorVersion = null);
/// <summary>
/// Verifies a composition recipe against stored SBOMs.
/// </summary>
CompositionRecipeVerificationResult Verify(
CompositionRecipeResponse recipe,
ImmutableArray<LayerSbomRef> actualLayerSboms);
}
/// <summary>
/// API response for composition recipe endpoint.
/// </summary>
public sealed record CompositionRecipeResponse
{
[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 CompositionRecipe Recipe { get; init; }
}
/// <summary>
/// The composition recipe itself.
/// </summary>
public sealed record CompositionRecipe
{
[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 ImmutableArray<CompositionRecipeLayer> Layers { get; init; }
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
[JsonPropertyName("aggregatedSbomDigests")]
public required AggregatedSbomDigests AggregatedSbomDigests { get; init; }
}
/// <summary>
/// A single layer in the composition recipe.
/// </summary>
public sealed record CompositionRecipeLayer
{
[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 LayerSbomDigests SbomDigests { get; init; }
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Digests for a layer's SBOMs.
/// </summary>
public sealed record LayerSbomDigests
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public required string Spdx { get; init; }
}
/// <summary>
/// Digests for the aggregated (image-level) SBOMs.
/// </summary>
public sealed record AggregatedSbomDigests
{
[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 CompositionRecipeVerificationResult
{
[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 ImmutableArray<string> Errors { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Default implementation of <see cref="ICompositionRecipeService"/>.
/// </summary>
public sealed class CompositionRecipeService : ICompositionRecipeService
{
private const string RecipeVersion = "1.0.0";
/// <inheritdoc />
public CompositionRecipeResponse BuildRecipe(
string scanId,
string imageDigest,
DateTimeOffset createdAt,
SbomCompositionResult compositionResult,
string? generatorName = null,
string? generatorVersion = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
ArgumentNullException.ThrowIfNull(compositionResult);
var layers = compositionResult.LayerSboms
.Select(layer => new CompositionRecipeLayer
{
Digest = layer.LayerDigest,
Order = layer.Order,
FragmentDigest = layer.FragmentDigest,
SbomDigests = new LayerSbomDigests
{
CycloneDx = layer.CycloneDxDigest,
Spdx = layer.SpdxDigest,
},
ComponentCount = layer.ComponentCount,
})
.OrderBy(l => l.Order)
.ToImmutableArray();
var merkleRoot = compositionResult.LayerSbomMerkleRoot ?? ComputeMerkleRoot(layers);
var recipe = new CompositionRecipe
{
Version = RecipeVersion,
GeneratorName = generatorName ?? "StellaOps.Scanner",
GeneratorVersion = generatorVersion ?? "2026.04",
Layers = layers,
MerkleRoot = merkleRoot,
AggregatedSbomDigests = new AggregatedSbomDigests
{
CycloneDx = compositionResult.Inventory.JsonSha256,
Spdx = compositionResult.SpdxInventory?.JsonSha256,
},
};
return new CompositionRecipeResponse
{
ScanId = scanId,
ImageDigest = imageDigest,
CreatedAt = ScannerTimestamps.ToIso8601(createdAt),
Recipe = recipe,
};
}
/// <inheritdoc />
public CompositionRecipeVerificationResult Verify(
CompositionRecipeResponse recipe,
ImmutableArray<LayerSbomRef> actualLayerSboms)
{
ArgumentNullException.ThrowIfNull(recipe);
var errors = ImmutableArray.CreateBuilder<string>();
var layerDigestsMatch = true;
if (recipe.Recipe.Layers.Length != actualLayerSboms.Length)
{
errors.Add($"Layer count mismatch: expected {recipe.Recipe.Layers.Length}, got {actualLayerSboms.Length}");
layerDigestsMatch = false;
}
else
{
for (var i = 0; i < recipe.Recipe.Layers.Length; i++)
{
var expected = recipe.Recipe.Layers[i];
var actual = actualLayerSboms.FirstOrDefault(l => l.Order == expected.Order);
if (actual is null)
{
errors.Add($"Missing layer at order {expected.Order}");
layerDigestsMatch = false;
continue;
}
if (expected.Digest != actual.LayerDigest)
{
errors.Add($"Layer {i} digest mismatch: expected {expected.Digest}, got {actual.LayerDigest}");
layerDigestsMatch = false;
}
if (expected.SbomDigests.CycloneDx != actual.CycloneDxDigest)
{
errors.Add($"Layer {i} CycloneDX digest mismatch: expected {expected.SbomDigests.CycloneDx}, got {actual.CycloneDxDigest}");
layerDigestsMatch = false;
}
if (expected.SbomDigests.Spdx != actual.SpdxDigest)
{
errors.Add($"Layer {i} SPDX digest mismatch: expected {expected.SbomDigests.Spdx}, got {actual.SpdxDigest}");
layerDigestsMatch = false;
}
}
}
var computedMerkleRoot = ComputeMerkleRoot(recipe.Recipe.Layers);
var merkleRootMatch = recipe.Recipe.MerkleRoot == computedMerkleRoot;
if (!merkleRootMatch)
{
errors.Add($"Merkle root mismatch: expected {recipe.Recipe.MerkleRoot}, computed {computedMerkleRoot}");
}
return new CompositionRecipeVerificationResult
{
Valid = layerDigestsMatch && merkleRootMatch && errors.Count == 0,
MerkleRootMatch = merkleRootMatch,
LayerDigestsMatch = layerDigestsMatch,
Errors = errors.ToImmutable(),
};
}
private static string ComputeMerkleRoot(ImmutableArray<CompositionRecipeLayer> layers)
{
if (layers.IsDefaultOrEmpty)
{
return ComputeSha256(Array.Empty<byte>());
}
var leaves = layers
.OrderBy(l => l.Order)
.Select(l => HexToBytes(l.SbomDigests.CycloneDx))
.ToList();
if (leaves.Count == 1)
{
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
}
var nodes = leaves;
while (nodes.Count > 1)
{
var nextLevel = new List<byte[]>();
for (var i = 0; i < nodes.Count; i += 2)
{
if (i + 1 < nodes.Count)
{
var combined = new byte[nodes[i].Length + nodes[i + 1].Length];
Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length);
Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length);
nextLevel.Add(SHA256.HashData(combined));
}
else
{
nextLevel.Add(nodes[i]);
}
}
nodes = nextLevel;
}
return Convert.ToHexString(nodes[0]).ToLowerInvariant();
}
private static string ComputeSha256(byte[] bytes)
{
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private static byte[] HexToBytes(string hex)
{
return Convert.FromHexString(hex);
}
}

View File

@@ -0,0 +1,265 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using CycloneDX;
using CycloneDX.Models;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using JsonSerializer = CycloneDX.Json.Serializer;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in CycloneDX 1.7 format.
/// </summary>
public sealed class CycloneDxLayerWriter : ILayerSbomWriter
{
private static readonly Guid SerialNamespace = new("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d");
/// <inheritdoc />
public string Format => "cyclonedx";
/// <inheritdoc />
public Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var bom = BuildLayerBom(request, generatedAt);
var json16 = JsonSerializer.Serialize(bom);
var json = CycloneDx17Extensions.UpgradeJsonTo17(json16);
var jsonBytes = Encoding.UTF8.GetBytes(json);
var jsonDigest = ComputeSha256(jsonBytes);
var output = new LayerSbomOutput
{
LayerDigest = request.LayerDigest,
Format = Format,
JsonBytes = jsonBytes,
JsonDigest = jsonDigest,
MediaType = CycloneDx17Extensions.MediaTypes.InventoryJson,
ComponentCount = request.Components.Length,
};
return Task.FromResult(output);
}
private static Bom BuildLayerBom(LayerSbomRequest request, DateTimeOffset generatedAt)
{
// Note: CycloneDX.Core 10.x does not yet have v1_7 enum; serialize as v1_6 then upgrade via UpgradeJsonTo17()
var bom = new Bom
{
SpecVersion = SpecificationVersion.v1_6,
Version = 1,
Metadata = BuildMetadata(request, generatedAt),
Components = BuildComponents(request.Components),
Dependencies = BuildDependencies(request.Components),
};
var serialPayload = $"{request.Image.ImageDigest}|layer:{request.LayerDigest}|{ScannerTimestamps.ToIso8601(generatedAt)}";
bom.SerialNumber = $"urn:uuid:{ScannerIdentifiers.CreateDeterministicGuid(SerialNamespace, Encoding.UTF8.GetBytes(serialPayload)).ToString("d", CultureInfo.InvariantCulture)}";
return bom;
}
private static Metadata BuildMetadata(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var layerDigestShort = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
var bomRef = $"layer:{layerDigestShort}";
var metadata = new Metadata
{
Timestamp = generatedAt.UtcDateTime,
Component = new Component
{
BomRef = bomRef,
Type = Component.Classification.Container,
Name = $"layer-{request.LayerOrder}",
Version = layerDigestShort,
Properties = new List<Property>
{
new() { Name = "stellaops:layer.digest", Value = request.LayerDigest },
new() { Name = "stellaops:layer.order", Value = request.LayerOrder.ToString(CultureInfo.InvariantCulture) },
new() { Name = "stellaops:image.digest", Value = request.Image.ImageDigest },
},
},
Properties = new List<Property>
{
new() { Name = "stellaops:sbom.type", Value = "layer" },
new() { Name = "stellaops:sbom.view", Value = "inventory" },
},
};
if (!string.IsNullOrWhiteSpace(request.Image.ImageReference))
{
metadata.Component.Properties.Add(new Property
{
Name = "stellaops:image.reference",
Value = request.Image.ImageReference,
});
}
if (!string.IsNullOrWhiteSpace(request.GeneratorName))
{
metadata.Properties.Add(new Property
{
Name = "stellaops:generator.name",
Value = request.GeneratorName,
});
if (!string.IsNullOrWhiteSpace(request.GeneratorVersion))
{
metadata.Properties.Add(new Property
{
Name = "stellaops:generator.version",
Value = request.GeneratorVersion,
});
}
}
return metadata;
}
private static List<Component> BuildComponents(ImmutableArray<ComponentRecord> components)
{
var result = new List<Component>(components.Length);
foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
{
var model = new Component
{
BomRef = component.Identity.Key,
Name = component.Identity.Name,
Version = component.Identity.Version,
Purl = component.Identity.Purl,
Group = component.Identity.Group,
Type = MapClassification(component.Identity.ComponentType),
Scope = MapScope(component.Metadata?.Scope),
Properties = BuildProperties(component),
};
result.Add(model);
}
return result;
}
private static List<Property>? BuildProperties(ComponentRecord component)
{
var properties = new List<Property>();
if (component.Metadata?.Properties is not null)
{
foreach (var property in component.Metadata.Properties.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
properties.Add(new Property
{
Name = property.Key,
Value = property.Value,
});
}
}
if (!string.IsNullOrWhiteSpace(component.Metadata?.BuildId))
{
properties.Add(new Property
{
Name = "stellaops:buildId",
Value = component.Metadata!.BuildId,
});
}
properties.Add(new Property { Name = "stellaops:layerDigest", Value = component.LayerDigest });
for (var index = 0; index < component.Evidence.Length; index++)
{
var evidence = component.Evidence[index];
var builder = new StringBuilder(evidence.Kind);
builder.Append(':').Append(evidence.Value);
if (!string.IsNullOrWhiteSpace(evidence.Source))
{
builder.Append('@').Append(evidence.Source);
}
properties.Add(new Property
{
Name = $"stellaops:evidence[{index}]",
Value = builder.ToString(),
});
}
return properties.Count == 0 ? null : properties;
}
private static List<Dependency>? BuildDependencies(ImmutableArray<ComponentRecord> components)
{
var componentKeys = components.Select(static c => c.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal);
var dependencies = new List<Dependency>();
foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
{
if (component.Dependencies.IsDefaultOrEmpty || component.Dependencies.Length == 0)
{
continue;
}
var filtered = component.Dependencies.Where(componentKeys.Contains).OrderBy(k => k, StringComparer.Ordinal).ToArray();
if (filtered.Length == 0)
{
continue;
}
dependencies.Add(new Dependency
{
Ref = component.Identity.Key,
Dependencies = filtered.Select(key => new Dependency { Ref = key }).ToList(),
});
}
return dependencies.Count == 0 ? null : dependencies;
}
private static Component.Classification MapClassification(string? type)
{
if (string.IsNullOrWhiteSpace(type))
{
return Component.Classification.Library;
}
return type.Trim().ToLowerInvariant() switch
{
"application" => Component.Classification.Application,
"framework" => Component.Classification.Framework,
"container" => Component.Classification.Container,
"operating-system" or "os" => Component.Classification.Operating_System,
"device" => Component.Classification.Device,
"firmware" => Component.Classification.Firmware,
"file" => Component.Classification.File,
_ => Component.Classification.Library,
};
}
private static Component.ComponentScope? MapScope(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant() switch
{
"runtime" or "required" => Component.ComponentScope.Required,
"development" or "optional" => Component.ComponentScope.Optional,
"excluded" => Component.ComponentScope.Excluded,
_ => null,
};
}
private static string ComputeSha256(byte[] bytes)
{
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,100 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in a specific format (CycloneDX or SPDX).
/// </summary>
public interface ILayerSbomWriter
{
/// <summary>
/// The SBOM format produced by this writer.
/// </summary>
string Format { get; }
/// <summary>
/// Generates an SBOM for a single layer's components.
/// </summary>
/// <param name="request">The layer SBOM request containing layer info and components.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The generated SBOM bytes and digest.</returns>
Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to generate a per-layer SBOM.
/// </summary>
public sealed record LayerSbomRequest
{
/// <summary>
/// The image this layer belongs to.
/// </summary>
public required ImageArtifactDescriptor Image { get; init; }
/// <summary>
/// The layer digest (e.g., "sha256:abc123...").
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The order of this layer in the image (0-indexed).
/// </summary>
public required int LayerOrder { get; init; }
/// <summary>
/// Components in this layer.
/// </summary>
public required ImmutableArray<ComponentRecord> Components { get; init; }
/// <summary>
/// When the SBOM was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Generator name (e.g., "StellaOps.Scanner").
/// </summary>
public string? GeneratorName { get; init; }
/// <summary>
/// Generator version.
/// </summary>
public string? GeneratorVersion { get; init; }
}
/// <summary>
/// Output from a layer SBOM writer.
/// </summary>
public sealed record LayerSbomOutput
{
/// <summary>
/// The layer digest this SBOM represents.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The SBOM format (e.g., "cyclonedx", "spdx").
/// </summary>
public required string Format { get; init; }
/// <summary>
/// SBOM JSON bytes.
/// </summary>
public required byte[] JsonBytes { get; init; }
/// <summary>
/// SHA256 digest of the JSON (lowercase hex).
/// </summary>
public required string JsonDigest { get; init; }
/// <summary>
/// Media type of the JSON content.
/// </summary>
public required string MediaType { get; init; }
/// <summary>
/// Number of components in this layer SBOM.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -0,0 +1,197 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Composes per-layer SBOMs for all layers in an image.
/// </summary>
public interface ILayerSbomComposer
{
/// <summary>
/// Generates per-layer SBOMs for all layers in the composition request.
/// </summary>
/// <param name="request">The composition request containing layer fragments.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Layer SBOM artifacts and references.</returns>
Task<LayerSbomCompositionResult> ComposeAsync(
SbomCompositionRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of per-layer SBOM composition.
/// </summary>
public sealed record LayerSbomCompositionResult
{
/// <summary>
/// Per-layer SBOM artifacts (bytes and digests).
/// </summary>
public required ImmutableArray<LayerSbomArtifact> Artifacts { get; init; }
/// <summary>
/// Per-layer SBOM references for storage in CAS.
/// </summary>
public required ImmutableArray<LayerSbomRef> References { get; init; }
/// <summary>
/// Merkle root computed from all layer SBOM digests (CycloneDX).
/// </summary>
public required string MerkleRoot { get; init; }
}
/// <summary>
/// Default implementation of <see cref="ILayerSbomComposer"/>.
/// </summary>
public sealed class LayerSbomComposer : ILayerSbomComposer
{
private readonly CycloneDxLayerWriter _cdxWriter = new();
private readonly SpdxLayerWriter _spdxWriter;
public LayerSbomComposer(SpdxLayerWriter? spdxWriter = null)
{
_spdxWriter = spdxWriter ?? new SpdxLayerWriter();
}
/// <inheritdoc />
public async Task<LayerSbomCompositionResult> ComposeAsync(
SbomCompositionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.LayerFragments.IsDefaultOrEmpty)
{
return new LayerSbomCompositionResult
{
Artifacts = ImmutableArray<LayerSbomArtifact>.Empty,
References = ImmutableArray<LayerSbomRef>.Empty,
MerkleRoot = ComputeSha256(Array.Empty<byte>()),
};
}
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var artifacts = ImmutableArray.CreateBuilder<LayerSbomArtifact>(request.LayerFragments.Length);
var references = ImmutableArray.CreateBuilder<LayerSbomRef>(request.LayerFragments.Length);
var merkleLeaves = new List<byte[]>();
for (var order = 0; order < request.LayerFragments.Length; order++)
{
var fragment = request.LayerFragments[order];
var layerRequest = new LayerSbomRequest
{
Image = request.Image,
LayerDigest = fragment.LayerDigest,
LayerOrder = order,
Components = fragment.Components,
GeneratedAt = generatedAt,
GeneratorName = request.GeneratorName,
GeneratorVersion = request.GeneratorVersion,
};
var cdxOutput = await _cdxWriter.WriteAsync(layerRequest, cancellationToken).ConfigureAwait(false);
var spdxOutput = await _spdxWriter.WriteAsync(layerRequest, cancellationToken).ConfigureAwait(false);
var fragmentDigest = ComputeFragmentDigest(fragment);
var artifact = new LayerSbomArtifact
{
LayerDigest = fragment.LayerDigest,
CycloneDxJsonBytes = cdxOutput.JsonBytes,
CycloneDxDigest = cdxOutput.JsonDigest,
SpdxJsonBytes = spdxOutput.JsonBytes,
SpdxDigest = spdxOutput.JsonDigest,
ComponentCount = fragment.Components.Length,
};
var reference = new LayerSbomRef
{
LayerDigest = fragment.LayerDigest,
Order = order,
FragmentDigest = fragmentDigest,
CycloneDxDigest = cdxOutput.JsonDigest,
CycloneDxCasUri = $"cas://sbom/layers/{request.Image.ImageDigest}/{fragment.LayerDigest}.cdx.json",
SpdxDigest = spdxOutput.JsonDigest,
SpdxCasUri = $"cas://sbom/layers/{request.Image.ImageDigest}/{fragment.LayerDigest}.spdx.json",
ComponentCount = fragment.Components.Length,
};
artifacts.Add(artifact);
references.Add(reference);
merkleLeaves.Add(HexToBytes(cdxOutput.JsonDigest));
}
var merkleRoot = ComputeMerkleRoot(merkleLeaves);
return new LayerSbomCompositionResult
{
Artifacts = artifacts.ToImmutable(),
References = references.ToImmutable(),
MerkleRoot = merkleRoot,
};
}
private static string ComputeFragmentDigest(LayerComponentFragment fragment)
{
var componentKeys = fragment.Components
.Select(c => c.Identity.Key)
.OrderBy(k => k, StringComparer.Ordinal)
.ToArray();
var payload = $"{fragment.LayerDigest}|{string.Join(",", componentKeys)}";
return ComputeSha256(Encoding.UTF8.GetBytes(payload));
}
private static string ComputeMerkleRoot(List<byte[]> leaves)
{
if (leaves.Count == 0)
{
return ComputeSha256(Array.Empty<byte>());
}
if (leaves.Count == 1)
{
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
}
var nodes = leaves.ToList();
while (nodes.Count > 1)
{
var nextLevel = new List<byte[]>();
for (var i = 0; i < nodes.Count; i += 2)
{
if (i + 1 < nodes.Count)
{
var combined = new byte[nodes[i].Length + nodes[i + 1].Length];
Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length);
Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length);
nextLevel.Add(SHA256.HashData(combined));
}
else
{
nextLevel.Add(nodes[i]);
}
}
nodes = nextLevel;
}
return Convert.ToHexString(nodes[0]).ToLowerInvariant();
}
private static string ComputeSha256(byte[] bytes)
{
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private static byte[] HexToBytes(string hex)
{
return Convert.FromHexString(hex);
}
}

View File

@@ -0,0 +1,112 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Reference to a per-layer SBOM stored in CAS.
/// </summary>
public sealed record LayerSbomRef
{
/// <summary>
/// The digest of the layer (e.g., "sha256:abc123...").
/// </summary>
[JsonPropertyName("layerDigest")]
public required string LayerDigest { get; init; }
/// <summary>
/// The order of the layer in the image (0-indexed).
/// </summary>
[JsonPropertyName("order")]
public required int Order { get; init; }
/// <summary>
/// SHA256 digest of the layer fragment (component list).
/// </summary>
[JsonPropertyName("fragmentDigest")]
public required string FragmentDigest { get; init; }
/// <summary>
/// SHA256 digest of the CycloneDX SBOM for this layer.
/// </summary>
[JsonPropertyName("cycloneDxDigest")]
public required string CycloneDxDigest { get; init; }
/// <summary>
/// CAS URI of the CycloneDX SBOM.
/// </summary>
[JsonPropertyName("cycloneDxCasUri")]
public required string CycloneDxCasUri { get; init; }
/// <summary>
/// SHA256 digest of the SPDX SBOM for this layer.
/// </summary>
[JsonPropertyName("spdxDigest")]
public required string SpdxDigest { get; init; }
/// <summary>
/// CAS URI of the SPDX SBOM.
/// </summary>
[JsonPropertyName("spdxCasUri")]
public required string SpdxCasUri { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Result of generating per-layer SBOMs.
/// </summary>
public sealed record LayerSbomResult
{
/// <summary>
/// References to all per-layer SBOMs, ordered by layer order.
/// </summary>
[JsonPropertyName("layerSboms")]
public required ImmutableArray<LayerSbomRef> LayerSboms { get; init; }
/// <summary>
/// Merkle root computed from all layer SBOM digests.
/// </summary>
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
}
/// <summary>
/// Artifact bytes for a single layer's SBOM.
/// </summary>
public sealed record LayerSbomArtifact
{
/// <summary>
/// The layer digest this SBOM represents.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// CycloneDX JSON bytes.
/// </summary>
public required byte[] CycloneDxJsonBytes { get; init; }
/// <summary>
/// SHA256 of CycloneDX JSON.
/// </summary>
public required string CycloneDxDigest { get; init; }
/// <summary>
/// SPDX JSON bytes.
/// </summary>
public required byte[] SpdxJsonBytes { get; init; }
/// <summary>
/// SHA256 of SPDX JSON.
/// </summary>
public required string SpdxDigest { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -90,4 +90,19 @@ public sealed record SbomCompositionResult
/// SHA256 hex of the composition recipe JSON.
/// </summary>
public required string CompositionRecipeSha256 { get; init; }
/// <summary>
/// Per-layer SBOM references. Each layer has CycloneDX and SPDX SBOMs.
/// </summary>
public ImmutableArray<LayerSbomRef> LayerSboms { get; init; } = ImmutableArray<LayerSbomRef>.Empty;
/// <summary>
/// Per-layer SBOM artifacts (bytes). Only populated when layer SBOM generation is enabled.
/// </summary>
public ImmutableArray<LayerSbomArtifact> LayerSbomArtifacts { get; init; } = ImmutableArray<LayerSbomArtifact>.Empty;
/// <summary>
/// Merkle root computed from per-layer SBOM digests.
/// </summary>
public string? LayerSbomMerkleRoot { get; init; }
}

View File

@@ -40,7 +40,29 @@ public sealed record SpdxCompositionOptions
public SpdxLicenseListVersion LicenseListVersion { get; init; } = SpdxLicenseListVersion.V3_21;
public ImmutableArray<string> ProfileConformance { get; init; } = ImmutableArray.Create("core", "software");
/// <summary>
/// Gets or sets the SPDX 3.0.1 profile type. Defaults to Software.
/// </summary>
public Spdx3ProfileType ProfileType { get; init; } = Spdx3ProfileType.Software;
/// <summary>
/// Gets or sets an explicit profile conformance override.
/// If not set (default or empty), the conformance is derived from ProfileType.
/// </summary>
public ImmutableArray<string> ProfileConformance { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets the effective profile conformance based on ProfileType if ProfileConformance is not explicitly set.
/// </summary>
public ImmutableArray<string> GetEffectiveProfileConformance()
{
if (!ProfileConformance.IsDefaultOrEmpty && ProfileConformance.Length > 0)
{
return ProfileConformance;
}
return ProfileType.GetProfileConformance().ToImmutableArray();
}
}
public sealed class SpdxComposer : ISpdxComposer
@@ -139,12 +161,12 @@ public sealed class SpdxComposer : ISpdxComposer
var packages = new List<SpdxPackage>();
var packageIdMap = new Dictionary<string, string>(StringComparer.Ordinal);
var rootPackage = BuildRootPackage(request.Image, idBuilder);
var rootPackage = BuildRootPackage(request.Image, idBuilder, options);
packages.Add(rootPackage);
foreach (var component in graph.Components)
{
var package = BuildComponentPackage(component, idBuilder, licenseList);
var package = BuildComponentPackage(component, idBuilder, licenseList, options);
packages.Add(package);
packageIdMap[component.Identity.Key] = package.SpdxId;
}
@@ -175,7 +197,7 @@ public sealed class SpdxComposer : ISpdxComposer
Sbom = sbom,
Elements = packages.Cast<SpdxElement>().ToImmutableArray(),
Relationships = relationships,
ProfileConformance = options.ProfileConformance
ProfileConformance = options.GetEffectiveProfileConformance()
};
}
@@ -261,17 +283,23 @@ public sealed class SpdxComposer : ISpdxComposer
.ToImmutableArray();
}
private static SpdxPackage BuildRootPackage(ImageArtifactDescriptor image, SpdxIdBuilder idBuilder)
private static SpdxPackage BuildRootPackage(
ImageArtifactDescriptor image,
SpdxIdBuilder idBuilder,
SpdxCompositionOptions options)
{
var digest = image.ImageDigest;
var digestParts = digest.Split(':', 2, StringSplitOptions.TrimEntries);
var digestValue = digestParts.Length == 2 ? digestParts[1] : digest;
var checksums = ImmutableArray.Create(new SpdxChecksum
{
Algorithm = digestParts.Length == 2 ? digestParts[0].ToUpperInvariant() : "SHA256",
Value = digestValue
});
// Lite profile omits checksums
var checksums = options.ProfileType.IncludeChecksums()
? ImmutableArray.Create(new SpdxChecksum
{
Algorithm = digestParts.Length == 2 ? digestParts[0].ToUpperInvariant() : "SHA256",
Value = digestValue
})
: ImmutableArray<SpdxChecksum>.Empty;
return new SpdxPackage
{
@@ -288,13 +316,17 @@ public sealed class SpdxComposer : ISpdxComposer
private static SpdxPackage BuildComponentPackage(
AggregatedComponent component,
SpdxIdBuilder idBuilder,
SpdxLicenseList licenseList)
SpdxLicenseList licenseList,
SpdxCompositionOptions options)
{
var packageUrl = !string.IsNullOrWhiteSpace(component.Identity.Purl)
? component.Identity.Purl
: (component.Identity.Key.StartsWith("pkg:", StringComparison.Ordinal) ? component.Identity.Key : null);
var declared = BuildLicenseExpression(component.Metadata?.Licenses, licenseList);
// Lite profile omits detailed licensing
var declared = options.ProfileType.IncludeDetailedLicensing()
? BuildLicenseExpression(component.Metadata?.Licenses, licenseList)
: null;
return new SpdxPackage
{

View File

@@ -0,0 +1,335 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Spdx;
using StellaOps.Scanner.Emit.Spdx.Models;
using StellaOps.Scanner.Emit.Spdx.Serialization;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in SPDX 3.0.1 format.
/// </summary>
public sealed class SpdxLayerWriter : ILayerSbomWriter
{
private const string JsonMediaType = "application/spdx+json; version=3.0.1";
private readonly SpdxLicenseList _licenseList;
private readonly string _namespaceBase;
private readonly string? _creatorOrganization;
public SpdxLayerWriter(
SpdxLicenseListVersion licenseListVersion = SpdxLicenseListVersion.V3_21,
string namespaceBase = "https://stellaops.io/spdx",
string? creatorOrganization = null)
{
_licenseList = SpdxLicenseListProvider.Get(licenseListVersion);
_namespaceBase = namespaceBase;
_creatorOrganization = creatorOrganization;
}
/// <inheritdoc />
public string Format => "spdx";
/// <inheritdoc />
public Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var document = BuildLayerDocument(request, generatedAt);
var jsonBytes = SpdxJsonLdSerializer.Serialize(document);
var jsonDigest = CanonJson.Sha256Hex(jsonBytes);
var output = new LayerSbomOutput
{
LayerDigest = request.LayerDigest,
Format = Format,
JsonBytes = jsonBytes,
JsonDigest = jsonDigest,
MediaType = JsonMediaType,
ComponentCount = request.Components.Length,
};
return Task.FromResult(output);
}
private SpdxDocument BuildLayerDocument(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var layerDigestShort = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
var idBuilder = new SpdxIdBuilder(_namespaceBase, $"layer:{request.LayerDigest}");
var creationInfo = BuildCreationInfo(request, generatedAt);
var packages = new List<SpdxPackage>();
var packageIdMap = new Dictionary<string, string>(StringComparer.Ordinal);
var layerPackage = BuildLayerPackage(request, idBuilder, layerDigestShort);
packages.Add(layerPackage);
foreach (var component in request.Components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
{
var package = BuildComponentPackage(component, idBuilder);
packages.Add(package);
packageIdMap[component.Identity.Key] = package.SpdxId;
}
var relationships = BuildRelationships(idBuilder, request.Components, layerPackage, packageIdMap);
var rootElementIds = packages
.Select(static pkg => pkg.SpdxId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var sbom = new SpdxSbom
{
SpdxId = idBuilder.SbomId,
Name = "layer-sbom",
RootElements = new[] { layerPackage.SpdxId }.ToImmutableArray(),
Elements = rootElementIds,
SbomTypes = new[] { "build" }.ToImmutableArray()
};
return new SpdxDocument
{
DocumentNamespace = idBuilder.DocumentNamespace,
Name = $"SBOM for layer {request.LayerOrder} ({layerDigestShort[..12]}...)",
CreationInfo = creationInfo,
Sbom = sbom,
Elements = packages.Cast<SpdxElement>().ToImmutableArray(),
Relationships = relationships,
ProfileConformance = ImmutableArray.Create("core", "software")
};
}
private SpdxCreationInfo BuildCreationInfo(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var creators = ImmutableArray.CreateBuilder<string>();
var toolName = !string.IsNullOrWhiteSpace(request.GeneratorName)
? request.GeneratorName!.Trim()
: "StellaOps-Scanner";
if (!string.IsNullOrWhiteSpace(toolName))
{
var toolLabel = !string.IsNullOrWhiteSpace(request.GeneratorVersion)
? $"{toolName}-{request.GeneratorVersion!.Trim()}"
: toolName;
creators.Add($"Tool: {toolLabel}");
}
if (!string.IsNullOrWhiteSpace(_creatorOrganization))
{
creators.Add($"Organization: {_creatorOrganization!.Trim()}");
}
return new SpdxCreationInfo
{
Created = generatedAt,
Creators = creators.ToImmutable(),
SpecVersion = SpdxDefaults.SpecVersion
};
}
private static SpdxPackage BuildLayerPackage(LayerSbomRequest request, SpdxIdBuilder idBuilder, string layerDigestShort)
{
var digestParts = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries);
var algorithm = digestParts.Length == 2 ? digestParts[0].ToUpperInvariant() : "SHA256";
var digestValue = digestParts.Length == 2 ? digestParts[1] : request.LayerDigest;
var checksums = ImmutableArray.Create(new SpdxChecksum
{
Algorithm = algorithm,
Value = digestValue
});
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId($"layer:{request.LayerDigest}"),
Name = $"layer-{request.LayerOrder}",
Version = layerDigestShort,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = "container",
Checksums = checksums,
Comment = $"Container layer {request.LayerOrder} from image {request.Image.ImageDigest}"
};
}
private SpdxPackage BuildComponentPackage(ComponentRecord component, SpdxIdBuilder idBuilder)
{
var packageUrl = !string.IsNullOrWhiteSpace(component.Identity.Purl)
? component.Identity.Purl
: (component.Identity.Key.StartsWith("pkg:", StringComparison.Ordinal) ? component.Identity.Key : null);
var declared = BuildLicenseExpression(component.Metadata?.Licenses);
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId(component.Identity.Key),
Name = component.Identity.Name,
Version = component.Identity.Version,
PackageUrl = packageUrl,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = MapPrimaryPurpose(component.Identity.ComponentType),
DeclaredLicense = declared
};
}
private SpdxLicenseExpression? BuildLicenseExpression(IReadOnlyList<string>? licenses)
{
if (licenses is null || licenses.Count == 0)
{
return null;
}
var expressions = new List<SpdxLicenseExpression>();
foreach (var license in licenses)
{
if (string.IsNullOrWhiteSpace(license))
{
continue;
}
if (SpdxLicenseExpressionParser.TryParse(license, out var parsed, _licenseList))
{
expressions.Add(parsed!);
continue;
}
expressions.Add(new SpdxSimpleLicense(ToLicenseRef(license)));
}
if (expressions.Count == 0)
{
return null;
}
var current = expressions[0];
for (var i = 1; i < expressions.Count; i++)
{
current = new SpdxDisjunctiveLicense(current, expressions[i]);
}
return current;
}
private static string ToLicenseRef(string license)
{
var normalized = new string(license
.Trim()
.Select(ch => char.IsLetterOrDigit(ch) || ch == '.' || ch == '-' ? ch : '-')
.ToArray());
if (normalized.StartsWith("LicenseRef-", StringComparison.Ordinal))
{
return normalized;
}
return $"LicenseRef-{normalized}";
}
private static ImmutableArray<SpdxRelationship> BuildRelationships(
SpdxIdBuilder idBuilder,
ImmutableArray<ComponentRecord> components,
SpdxPackage layerPackage,
IReadOnlyDictionary<string, string> packageIdMap)
{
var relationships = new List<SpdxRelationship>();
var documentId = idBuilder.DocumentNamespace;
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(documentId, "describes", layerPackage.SpdxId),
FromElement = documentId,
Type = SpdxRelationshipType.Describes,
ToElements = ImmutableArray.Create(layerPackage.SpdxId)
});
var dependencyTargets = new HashSet<string>(StringComparer.Ordinal);
foreach (var component in components)
{
foreach (var dependencyKey in component.Dependencies)
{
if (packageIdMap.ContainsKey(dependencyKey))
{
dependencyTargets.Add(dependencyKey);
}
}
}
var rootDependencies = components
.Where(component => !dependencyTargets.Contains(component.Identity.Key))
.OrderBy(component => component.Identity.Key, StringComparer.Ordinal)
.ToArray();
foreach (var component in rootDependencies)
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var targetId))
{
continue;
}
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(layerPackage.SpdxId, "dependsOn", targetId),
FromElement = layerPackage.SpdxId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(targetId)
});
}
foreach (var component in components.OrderBy(c => c.Identity.Key, StringComparer.Ordinal))
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var fromId))
{
continue;
}
var deps = component.Dependencies
.Where(packageIdMap.ContainsKey)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
foreach (var depKey in deps)
{
var toId = packageIdMap[depKey];
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(fromId, "dependsOn", toId),
FromElement = fromId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(toId)
});
}
}
return relationships
.OrderBy(rel => rel.FromElement, StringComparer.Ordinal)
.ThenBy(rel => rel.Type)
.ThenBy(rel => rel.ToElements.FirstOrDefault() ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string? MapPrimaryPurpose(string? type)
{
if (string.IsNullOrWhiteSpace(type))
{
return "library";
}
return type.Trim().ToLowerInvariant() switch
{
"application" => "application",
"framework" => "framework",
"container" => "container",
"operating-system" or "os" => "operatingSystem",
"device" => "device",
"firmware" => "firmware",
"file" => "file",
_ => "library"
};
}
}

View File

@@ -17,7 +17,7 @@ public sealed record BomIndexBuildRequest
public required ComponentGraph Graph { get; init; }
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
}
public sealed record BomIndexArtifact

View File

@@ -10,6 +10,13 @@ namespace StellaOps.Scanner.Emit.Lineage;
/// </summary>
public sealed class SbomDiffEngine
{
private readonly TimeProvider _timeProvider;
public SbomDiffEngine(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Computes the semantic diff between two SBOMs.
/// </summary>
@@ -115,7 +122,7 @@ public sealed class SbomDiffEngine
Unchanged = unchanged,
IsBreaking = isBreaking
},
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -0,0 +1,78 @@
// <copyright file="Spdx3ProfileType.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Scanner.Emit.Spdx;
/// <summary>
/// SPDX 3.0.1 profile types for SBOM generation.
/// </summary>
public enum Spdx3ProfileType
{
/// <summary>
/// Full Software profile with all available fields.
/// Includes detailed licensing, checksums, external refs, etc.
/// </summary>
Software,
/// <summary>
/// Lite profile with minimal required fields.
/// Optimized for CI/CD and performance-sensitive use cases.
/// Includes: spdxId, name, packageVersion, packageUrl or downloadLocation.
/// </summary>
Lite,
/// <summary>
/// Build profile with provenance and build environment data.
/// Suitable for attestation integration.
/// </summary>
Build,
/// <summary>
/// Security profile with vulnerability and VEX data.
/// Suitable for security analysis and VexLens integration.
/// </summary>
Security
}
/// <summary>
/// Extension methods for <see cref="Spdx3ProfileType"/>.
/// </summary>
public static class Spdx3ProfileTypeExtensions
{
/// <summary>
/// Gets the profile conformance URIs for this profile type.
/// </summary>
public static string[] GetProfileConformance(this Spdx3ProfileType profileType) => profileType switch
{
Spdx3ProfileType.Software => ["core", "software"],
Spdx3ProfileType.Lite => ["core", "software", "lite"],
Spdx3ProfileType.Build => ["core", "software", "build"],
Spdx3ProfileType.Security => ["core", "software", "security"],
_ => ["core", "software"]
};
/// <summary>
/// Returns true if this profile should include detailed licensing.
/// </summary>
public static bool IncludeDetailedLicensing(this Spdx3ProfileType profileType) =>
profileType is Spdx3ProfileType.Software;
/// <summary>
/// Returns true if this profile should include checksums.
/// </summary>
public static bool IncludeChecksums(this Spdx3ProfileType profileType) =>
profileType is Spdx3ProfileType.Software or Spdx3ProfileType.Build or Spdx3ProfileType.Security;
/// <summary>
/// Returns true if this profile should include external references.
/// </summary>
public static bool IncludeExternalRefs(this Spdx3ProfileType profileType) =>
profileType is not Spdx3ProfileType.Lite;
/// <summary>
/// Returns true if this profile should include annotations and comments.
/// </summary>
public static bool IncludeAnnotations(this Spdx3ProfileType profileType) =>
profileType is Spdx3ProfileType.Software;
}

View File

@@ -156,7 +156,7 @@ Located in `Risk/`:
- `docs/modules/scanner/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/scanner/operations/entrypoint-problem.md`
- `docs/reachability/function-level-evidence.md`
- `docs/modules/reach-graph/guides/function-level-evidence.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.

View File

@@ -57,11 +57,13 @@ public interface IBaselineAnalyzer
public sealed class BaselineAnalyzer : IBaselineAnalyzer
{
private readonly ILogger<BaselineAnalyzer> _logger;
private readonly TimeProvider _timeProvider;
private readonly Dictionary<string, Regex> _compiledPatterns = new();
public BaselineAnalyzer(ILogger<BaselineAnalyzer> logger)
public BaselineAnalyzer(ILogger<BaselineAnalyzer> logger, TimeProvider? timeProvider = null)
{
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<BaselineReport> AnalyzeAsync(
@@ -97,7 +99,7 @@ public sealed class BaselineAnalyzer : IBaselineAnalyzer
{
ReportId = Guid.NewGuid(),
ScanId = context.ScanId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
ConfigUsed = context.Config.ConfigId,
EntryPoints = entryPoints.ToImmutableArray(),
Statistics = statistics,

View File

@@ -56,7 +56,8 @@ public sealed record BinaryAnalysisResult(
string binaryPath,
string binaryHash,
BinaryArchitecture architecture = BinaryArchitecture.Unknown,
BinaryFormat format = BinaryFormat.Unknown) => new(
BinaryFormat format = BinaryFormat.Unknown,
TimeProvider? timeProvider = null) => new(
binaryPath,
binaryHash,
architecture,
@@ -66,7 +67,7 @@ public sealed record BinaryAnalysisResult(
ImmutableArray<SourceCorrelation>.Empty,
ImmutableArray<VulnerableFunctionMatch>.Empty,
BinaryAnalysisMetrics.Empty,
DateTimeOffset.UtcNow);
(timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Gets functions at high-confidence correlation.
@@ -324,18 +325,22 @@ public sealed class BinaryAnalysisResultBuilder
private readonly Dictionary<long, SymbolInfo> _symbols = new();
private readonly List<SourceCorrelation> _correlations = new();
private readonly List<VulnerableFunctionMatch> _vulnerableMatches = new();
private readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow;
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startTime;
public BinaryAnalysisResultBuilder(
string binaryPath,
string binaryHash,
BinaryArchitecture architecture = BinaryArchitecture.Unknown,
BinaryFormat format = BinaryFormat.Unknown)
BinaryFormat format = BinaryFormat.Unknown,
TimeProvider? timeProvider = null)
{
_binaryPath = binaryPath;
_binaryHash = binaryHash;
_architecture = architecture;
_format = format;
_timeProvider = timeProvider ?? TimeProvider.System;
_startTime = _timeProvider.GetUtcNow();
}
/// <summary>
@@ -379,7 +384,8 @@ public sealed class BinaryAnalysisResultBuilder
/// </summary>
public BinaryAnalysisResult Build()
{
var duration = DateTimeOffset.UtcNow - _startTime;
var now = _timeProvider.GetUtcNow();
var duration = now - _startTime;
var metrics = new BinaryAnalysisMetrics(
TotalFunctions: _functions.Count,
@@ -401,6 +407,6 @@ public sealed class BinaryAnalysisResultBuilder
_correlations.OrderBy(c => c.BinaryOffset).ToImmutableArray(),
_vulnerableMatches.OrderByDescending(m => m.Severity).ToImmutableArray(),
metrics,
DateTimeOffset.UtcNow);
now);
}
}

View File

@@ -16,6 +16,7 @@ public sealed class BinaryIntelligenceAnalyzer
private readonly ISymbolRecovery _symbolRecovery;
private readonly VulnerableFunctionMatcher _vulnerabilityMatcher;
private readonly BinaryIntelligenceOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new binary intelligence analyzer.
@@ -25,12 +26,14 @@ public sealed class BinaryIntelligenceAnalyzer
IFingerprintIndex? fingerprintIndex = null,
ISymbolRecovery? symbolRecovery = null,
VulnerableFunctionMatcher? vulnerabilityMatcher = null,
BinaryIntelligenceOptions? options = null)
BinaryIntelligenceOptions? options = null,
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_fingerprintGenerator = fingerprintGenerator ?? new CombinedFingerprintGenerator();
_fingerprintIndex = fingerprintIndex ?? new InMemoryFingerprintIndex();
_fingerprintIndex = fingerprintIndex ?? new InMemoryFingerprintIndex(_timeProvider);
_symbolRecovery = symbolRecovery ?? new PatternBasedSymbolRecovery();
_vulnerabilityMatcher = vulnerabilityMatcher ?? new VulnerableFunctionMatcher(_fingerprintIndex);
_vulnerabilityMatcher = vulnerabilityMatcher ?? new VulnerableFunctionMatcher(_fingerprintIndex, timeProvider: _timeProvider);
_options = options ?? BinaryIntelligenceOptions.Default;
}
@@ -53,7 +56,7 @@ public sealed class BinaryIntelligenceAnalyzer
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var builder = new BinaryAnalysisResultBuilder(binaryPath, binaryHash, architecture, format);
var builder = new BinaryAnalysisResultBuilder(binaryPath, binaryHash, architecture, format, _timeProvider);
// Phase 1: Generate fingerprints for all functions
var fingerprints = new Dictionary<long, CodeFingerprint>();
@@ -186,7 +189,7 @@ public sealed class BinaryIntelligenceAnalyzer
SourceLine: null,
VulnerabilityIds: vulnerabilityIds?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
if (await _fingerprintIndex.AddAsync(entry, cancellationToken))
{

View File

@@ -14,6 +14,7 @@ public sealed class FingerprintCorpusBuilder
private readonly IFingerprintGenerator _fingerprintGenerator;
private readonly IFingerprintIndex _targetIndex;
private readonly FingerprintCorpusOptions _options;
private readonly TimeProvider _timeProvider;
private readonly List<CorpusBuildRecord> _buildHistory = new();
/// <summary>
@@ -22,11 +23,13 @@ public sealed class FingerprintCorpusBuilder
public FingerprintCorpusBuilder(
IFingerprintIndex targetIndex,
IFingerprintGenerator? fingerprintGenerator = null,
FingerprintCorpusOptions? options = null)
FingerprintCorpusOptions? options = null,
TimeProvider? timeProvider = null)
{
_targetIndex = targetIndex;
_fingerprintGenerator = fingerprintGenerator ?? new CombinedFingerprintGenerator();
_options = options ?? FingerprintCorpusOptions.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -41,7 +44,7 @@ public sealed class FingerprintCorpusBuilder
IReadOnlyList<FunctionSignature> functions,
CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
var startTime = _timeProvider.GetUtcNow();
var indexed = 0;
var skipped = 0;
var duplicates = 0;
@@ -93,7 +96,7 @@ public sealed class FingerprintCorpusBuilder
SourceLine: null,
VulnerabilityIds: package.VulnerabilityIds,
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
var added = await _targetIndex.AddAsync(entry, cancellationToken);
@@ -119,9 +122,9 @@ public sealed class FingerprintCorpusBuilder
Skipped: skipped,
Duplicates: duplicates,
Errors: errors.ToImmutableArray(),
Duration: DateTimeOffset.UtcNow - startTime);
Duration: _timeProvider.GetUtcNow() - startTime);
_buildHistory.Add(new CorpusBuildRecord(package.Purl, package.Version, result, DateTimeOffset.UtcNow));
_buildHistory.Add(new CorpusBuildRecord(package.Purl, package.Version, result, _timeProvider.GetUtcNow()));
return result;
}
@@ -207,7 +210,7 @@ public sealed class FingerprintCorpusBuilder
// For now, export build history as a summary
var data = new CorpusExportData
{
ExportedAt = DateTimeOffset.UtcNow,
ExportedAt = _timeProvider.GetUtcNow(),
Statistics = _targetIndex.GetStatistics(),
Entries = Array.Empty<CorpusEntryData>() // Full export would need index enumeration
};

View File

@@ -140,7 +140,18 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
private readonly ConcurrentDictionary<FingerprintAlgorithm, List<FingerprintMatch>> _algorithmIndex = new();
private readonly HashSet<string> _packages = new();
private readonly object _packagesLock = new();
private DateTimeOffset _lastUpdated = DateTimeOffset.UtcNow;
private readonly TimeProvider _timeProvider;
private DateTimeOffset _lastUpdated;
/// <summary>
/// Creates a new in-memory fingerprint index.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public InMemoryFingerprintIndex(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_lastUpdated = _timeProvider.GetUtcNow();
}
/// <inheritdoc/>
public int Count => _exactIndex.Count;
@@ -182,7 +193,7 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
_packages.Add(match.SourcePackage);
}
_lastUpdated = DateTimeOffset.UtcNow;
_lastUpdated = _timeProvider.GetUtcNow();
}
return Task.FromResult(added);
@@ -302,7 +313,7 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
SourceLine: null,
VulnerabilityIds: ImmutableArray<string>.Empty,
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
return AddAsync(match, cancellationToken).ContinueWith(_ => { }, cancellationToken);
}
@@ -313,9 +324,20 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
/// </summary>
public sealed class VulnerableFingerprintIndex : IFingerprintIndex
{
private readonly InMemoryFingerprintIndex _baseIndex = new();
private readonly InMemoryFingerprintIndex _baseIndex;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, VulnerabilityInfo> _vulnerabilities = new();
/// <summary>
/// Creates a new vulnerability-aware fingerprint index.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public VulnerableFingerprintIndex(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_baseIndex = new InMemoryFingerprintIndex(_timeProvider);
}
/// <inheritdoc/>
public int Count => _baseIndex.Count;
@@ -344,7 +366,7 @@ public sealed class VulnerableFingerprintIndex : IFingerprintIndex
SourceLine: null,
VulnerabilityIds: ImmutableArray.Create(vulnerabilityId),
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
var added = await _baseIndex.AddAsync(match, cancellationToken);

View File

@@ -11,16 +11,19 @@ public sealed class VulnerableFunctionMatcher
{
private readonly IFingerprintIndex _index;
private readonly VulnerableMatcherOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new vulnerable function matcher.
/// </summary>
public VulnerableFunctionMatcher(
IFingerprintIndex index,
VulnerableMatcherOptions? options = null)
VulnerableMatcherOptions? options = null,
TimeProvider? timeProvider = null)
{
_index = index;
_options = options ?? VulnerableMatcherOptions.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -165,7 +168,7 @@ public sealed class VulnerableFunctionMatcher
SourceLine: null,
VulnerabilityIds: ImmutableArray.Create(vulnerabilityId),
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
return await _index.AddAsync(entry, cancellationToken);
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.RegularExpressions;
using StellaOps.Scanner.EntryTrace.Semantic;
using YamlDotNet.RepresentationModel;
@@ -98,7 +99,7 @@ public sealed partial class DockerComposeParser : IManifestParser
Services = services.ToImmutableArray(),
Edges = edges.ToImmutableArray(),
IngressPaths = ingressPaths,
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
return Task.FromResult(graph);

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.RegularExpressions;
using StellaOps.Scanner.EntryTrace.Semantic;
using YamlDotNet.RepresentationModel;
@@ -87,7 +88,7 @@ public sealed partial class KubernetesManifestParser : IManifestParser
Services = services.ToImmutableArray(),
Edges = edges.ToImmutableArray(),
IngressPaths = ingressPaths.ToImmutableArray(),
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
return Task.FromResult(graph);

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.EntryTrace.Semantic;
namespace StellaOps.Scanner.EntryTrace.Mesh;
@@ -260,7 +261,7 @@ public sealed class MeshEntrypointAnalyzer
Services = ImmutableArray<ServiceNode>.Empty,
Edges = ImmutableArray<CrossContainerEdge>.Empty,
IngressPaths = ImmutableArray<IngressPath>.Empty,
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
}
@@ -303,7 +304,7 @@ public sealed class MeshEntrypointAnalyzer
Services = uniqueServices,
Edges = uniqueEdges,
IngressPaths = uniqueIngress,
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.EntryTrace.Semantic;
namespace StellaOps.Scanner.EntryTrace.Mesh;
@@ -423,7 +424,7 @@ public sealed class MeshEntrypointGraphBuilder
Services = _services.ToImmutableArray(),
Edges = _edges.ToImmutableArray(),
IngressPaths = _ingressPaths.ToImmutableArray(),
AnalyzedAt = DateTime.UtcNow.ToString("O"),
AnalyzedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
Metadata = _metadata.Count > 0
? _metadata.ToImmutableDictionary()
: null

View File

@@ -11,12 +11,13 @@ public sealed class CompositeRiskScorer : IRiskScorer
{
private readonly ImmutableArray<IRiskContributor> _contributors;
private readonly CompositeRiskScorerOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a composite scorer with default contributors.
/// </summary>
public CompositeRiskScorer(CompositeRiskScorerOptions? options = null)
: this(GetDefaultContributors(), options)
public CompositeRiskScorer(CompositeRiskScorerOptions? options = null, TimeProvider? timeProvider = null)
: this(GetDefaultContributors(), options, timeProvider)
{
}
@@ -25,10 +26,12 @@ public sealed class CompositeRiskScorer : IRiskScorer
/// </summary>
public CompositeRiskScorer(
IEnumerable<IRiskContributor> contributors,
CompositeRiskScorerOptions? options = null)
CompositeRiskScorerOptions? options = null,
TimeProvider? timeProvider = null)
{
_contributors = contributors.ToImmutableArray();
_options = options ?? CompositeRiskScorerOptions.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
@@ -66,7 +69,7 @@ public sealed class CompositeRiskScorer : IRiskScorer
Factors: allFactors.ToImmutableArray(),
BusinessContext: businessContext,
Recommendations: recommendations,
AssessedAt: DateTimeOffset.UtcNow);
AssessedAt: _timeProvider.GetUtcNow());
}
private RiskScore ComputeOverallScore(
@@ -75,7 +78,7 @@ public sealed class CompositeRiskScorer : IRiskScorer
{
if (factors.Count == 0)
{
return RiskScore.Zero;
return RiskScore.Zero(_timeProvider);
}
// Weighted average of factor contributions
@@ -106,7 +109,7 @@ public sealed class CompositeRiskScorer : IRiskScorer
OverallScore: baseScore,
Category: primaryCategory,
Confidence: confidence,
ComputedAt: DateTimeOffset.UtcNow);
ComputedAt: _timeProvider.GetUtcNow());
}
private float ComputeConfidence(IReadOnlyList<RiskFactor> factors)
@@ -217,6 +220,17 @@ public sealed record CompositeRiskScorerOptions(
/// </summary>
public sealed class RiskExplainer
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new risk explainer.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public RiskExplainer(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Generates a summary explanation for a risk assessment.
/// </summary>
@@ -268,7 +282,7 @@ public sealed class RiskExplainer
Confidence: assessment.OverallScore.Confidence,
TopFactors: ExplainFactors(assessment),
Recommendations: assessment.Recommendations,
GeneratedAt: DateTimeOffset.UtcNow);
GeneratedAt: _timeProvider.GetUtcNow());
}
private static string CategoryToString(RiskCategory category) => category switch
@@ -313,6 +327,17 @@ public sealed record RiskReport(
/// </summary>
public sealed class RiskAggregator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new risk aggregator.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public RiskAggregator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Aggregates assessments for a fleet-level view.
/// </summary>
@@ -322,7 +347,7 @@ public sealed class RiskAggregator
if (assessmentList.Count == 0)
{
return FleetRiskSummary.Empty;
return FleetRiskSummary.CreateEmpty(_timeProvider);
}
var distribution = assessmentList
@@ -349,7 +374,7 @@ public sealed class RiskAggregator
Distribution: distribution.ToImmutableDictionary(),
CategoryBreakdown: categoryBreakdown.ToImmutableDictionary(),
TopRisks: topRisks,
AggregatedAt: DateTimeOffset.UtcNow);
AggregatedAt: _timeProvider.GetUtcNow());
}
}
@@ -373,16 +398,22 @@ public sealed record FleetRiskSummary(
DateTimeOffset AggregatedAt)
{
/// <summary>
/// Empty summary.
/// Empty summary with specified timestamp.
/// </summary>
public static FleetRiskSummary Empty => new(
public static FleetRiskSummary CreateEmpty(TimeProvider? timeProvider = null) => new(
TotalSubjects: 0,
AverageScore: 0,
AverageConfidence: 0,
Distribution: ImmutableDictionary<RiskLevel, int>.Empty,
CategoryBreakdown: ImmutableDictionary<RiskCategory, int>.Empty,
TopRisks: ImmutableArray<RiskSummaryItem>.Empty,
AggregatedAt: DateTimeOffset.UtcNow);
AggregatedAt: (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Empty summary (uses current time).
/// </summary>
[Obsolete("Use CreateEmpty(TimeProvider) for deterministic timestamps")]
public static FleetRiskSummary Empty => CreateEmpty();
/// <summary>
/// Count of critical/high risk subjects.

View File

@@ -20,31 +20,32 @@ public sealed record RiskScore(
/// <summary>
/// Creates a zero risk score.
/// </summary>
public static RiskScore Zero => new(0.0f, RiskCategory.Unknown, 1.0f, DateTimeOffset.UtcNow);
public static RiskScore Zero(TimeProvider? timeProvider = null)
=> new(0.0f, RiskCategory.Unknown, 1.0f, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a critical risk score.
/// </summary>
public static RiskScore Critical(RiskCategory category, float confidence = 0.9f)
=> new(1.0f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore Critical(RiskCategory category, float confidence = 0.9f, TimeProvider? timeProvider = null)
=> new(1.0f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a high risk score.
/// </summary>
public static RiskScore High(RiskCategory category, float confidence = 0.85f)
=> new(0.85f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore High(RiskCategory category, float confidence = 0.85f, TimeProvider? timeProvider = null)
=> new(0.85f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a medium risk score.
/// </summary>
public static RiskScore Medium(RiskCategory category, float confidence = 0.8f)
=> new(0.5f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore Medium(RiskCategory category, float confidence = 0.8f, TimeProvider? timeProvider = null)
=> new(0.5f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a low risk score.
/// </summary>
public static RiskScore Low(RiskCategory category, float confidence = 0.75f)
=> new(0.2f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore Low(RiskCategory category, float confidence = 0.75f, TimeProvider? timeProvider = null)
=> new(0.2f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Descriptive risk level based on score.
@@ -349,14 +350,18 @@ public sealed record RiskAssessment(
/// <summary>
/// Creates an empty assessment for a subject with no risk data.
/// </summary>
public static RiskAssessment Empty(string subjectId, SubjectType subjectType) => new(
SubjectId: subjectId,
SubjectType: subjectType,
OverallScore: RiskScore.Zero,
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
public static RiskAssessment Empty(string subjectId, SubjectType subjectType, TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
return new(
SubjectId: subjectId,
SubjectType: subjectType,
OverallScore: RiskScore.Zero(tp),
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: tp.GetUtcNow());
}
}
/// <summary>

View File

@@ -16,21 +16,25 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer
private readonly IEntryTraceAnalyzer _baseAnalyzer;
private readonly SemanticEntrypointOrchestrator _orchestrator;
private readonly ILogger<SemanticEntryTraceAnalyzer> _logger;
private readonly TimeProvider _timeProvider;
public SemanticEntryTraceAnalyzer(
IEntryTraceAnalyzer baseAnalyzer,
SemanticEntrypointOrchestrator orchestrator,
ILogger<SemanticEntryTraceAnalyzer> logger)
ILogger<SemanticEntryTraceAnalyzer> logger,
TimeProvider? timeProvider = null)
{
_baseAnalyzer = baseAnalyzer ?? throw new ArgumentNullException(nameof(baseAnalyzer));
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public SemanticEntryTraceAnalyzer(
IEntryTraceAnalyzer baseAnalyzer,
ILogger<SemanticEntryTraceAnalyzer> logger)
: this(baseAnalyzer, new SemanticEntrypointOrchestrator(), logger)
ILogger<SemanticEntryTraceAnalyzer> logger,
TimeProvider? timeProvider = null)
: this(baseAnalyzer, new SemanticEntrypointOrchestrator(), logger, timeProvider)
{
}
@@ -52,7 +56,7 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer
var traceResult = new EntryTraceResult(
context.ScanId,
context.ImageDigest,
DateTimeOffset.UtcNow,
_timeProvider.GetUtcNow(),
graph,
SerializeToNdjson(graph));
@@ -98,7 +102,7 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer
TraceResult = traceResult,
SemanticEntrypoint = semanticResult,
AnalysisResult = analysisResult,
AnalyzedAt = DateTimeOffset.UtcNow
AnalyzedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.Scanner.EntryTrace.Semantic;
@@ -202,7 +203,7 @@ public sealed class SemanticEntrypointBuilder
FrameworkVersion = _frameworkVersion,
RuntimeVersion = _runtimeVersion,
Metadata = _metadata.Count > 0 ? _metadata.ToImmutableDictionary() : null,
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.EntryTrace.FileSystem;
using StellaOps.Scanner.EntryTrace.Semantic.Adapters;
using StellaOps.Scanner.EntryTrace.Semantic.Analysis;
@@ -226,7 +227,7 @@ public sealed class SemanticEntrypointOrchestrator
FrameworkVersion = adapterResult.FrameworkVersion,
RuntimeVersion = adapterResult.RuntimeVersion,
Metadata = metadata.ToImmutableDictionary(),
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
@@ -83,7 +84,7 @@ public static class EntryTraceNdjsonWriter
writer.WriteNumber("edges", graph.Edges.Length);
writer.WriteNumber("targets", graph.Plans.Length);
writer.WriteNumber("warnings", graph.Diagnostics.Length);
writer.WriteString("generated_at", metadata.GeneratedAtUtc.UtcDateTime.ToString("O"));
writer.WriteString("generated_at", metadata.GeneratedAtUtc.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(metadata.Source))
{
writer.WriteString("source", metadata.Source);

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -101,7 +102,7 @@ public sealed class InMemoryTemporalEntrypointStore : ITemporalEntrypointStore
var prunedGraph = graph with
{
Snapshots = prunedSnapshots,
UpdatedAt = DateTime.UtcNow.ToString("O")
UpdatedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
_graphs[serviceId] = prunedGraph;
@@ -117,7 +118,7 @@ public sealed class InMemoryTemporalEntrypointStore : ITemporalEntrypointStore
CurrentVersion = snapshot.Version,
PreviousVersion = null,
Delta = null,
UpdatedAt = DateTime.UtcNow.ToString("O")
UpdatedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
}
@@ -164,7 +165,7 @@ public sealed class InMemoryTemporalEntrypointStore : ITemporalEntrypointStore
CurrentVersion = newSnapshot.Version,
PreviousVersion = previousSnapshot?.Version,
Delta = delta,
UpdatedAt = DateTime.UtcNow.ToString("O")
UpdatedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.EntryTrace.Semantic;
namespace StellaOps.Scanner.EntryTrace.Temporal;
@@ -231,7 +232,7 @@ public sealed class TemporalEntrypointGraphBuilder
CurrentVersion = _currentVersion,
PreviousVersion = _previousVersion,
Delta = _delta,
UpdatedAt = DateTime.UtcNow.ToString("O"),
UpdatedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
Metadata = _metadata.Count > 0
? _metadata.ToImmutableDictionary()
: null

View File

@@ -28,6 +28,7 @@ public sealed class FuncProofBuilder
private ICryptoHash? _cryptoHash;
private FuncProofGenerationOptions _options = new();
private TimeProvider _timeProvider = TimeProvider.System;
private string? _buildId;
private string? _buildIdType;
private string? _fileSha256;
@@ -50,6 +51,16 @@ public sealed class FuncProofBuilder
return this;
}
/// <summary>
/// Sets the TimeProvider for deterministic timestamp generation.
/// If not set, defaults to TimeProvider.System.
/// </summary>
public FuncProofBuilder WithTimeProvider(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
return this;
}
/// <summary>
/// Sets the generation options for configurable parameters.
/// </summary>
@@ -212,7 +223,7 @@ public sealed class FuncProofBuilder
Functions = functions,
Traces = traces,
Meta = _metadata,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
GeneratorVersion = _generatorVersion
};

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
@@ -227,7 +228,7 @@ public sealed class FuncProofTransparencyService : IFuncProofTransparencyService
EntryLocation = entry.EntryLocation,
LogIndex = entry.LogIndex,
InclusionProofUrl = entry.InclusionProofUrl,
RecordedAt = _timeProvider.GetUtcNow().ToString("O")
RecordedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
}
catch (HttpRequestException ex) when (opts.AllowOffline)

View File

@@ -50,6 +50,12 @@ public interface IAssumptionCollector
public sealed class AssumptionCollector : IAssumptionCollector
{
private readonly Dictionary<(AssumptionCategory, string), Assumption> _assumptions = new();
private readonly TimeProvider _timeProvider;
public AssumptionCollector(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public void Record(
@@ -107,7 +113,7 @@ public sealed class AssumptionCollector : IAssumptionCollector
{
Id = Guid.NewGuid().ToString("N"),
Assumptions = [.. _assumptions.Values],
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = _timeProvider.GetUtcNow(),
ContextId = contextId
};
}

View File

@@ -0,0 +1,26 @@
# AGENTS - Scanner Gate Library
## Roles
- Backend engineer: .NET 10 gate policy, DI wiring, configuration, and determinism.
- QA / bench engineer: tests for policy evaluation, caching, audit logging, and config validation.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/scanner/architecture.md
- src/Scanner/AGENTS.md
- Current sprint file under docs/implplan/SPRINT_*.md
## Working Directory & Boundaries
- Primary scope: src/Scanner/__Libraries/StellaOps.Scanner.Gate
- Test scope: src/Scanner/__Tests/StellaOps.Scanner.Gate.Tests (create if missing)
- Avoid cross-module edits unless explicitly allowed in the sprint file.
## Determinism and Safety
- Inject TimeProvider and IGuidGenerator; no DateTime.UtcNow or Guid.NewGuid in production code.
- Use InvariantCulture for parsing/formatting and stable ordering for rule evaluation.
## Testing
- Cover policy evaluation, options validation, caching behavior, and audit logging.
- Use deterministic fixtures and fixed time providers in tests.

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// CachingVexObservationProvider.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Caching wrapper for VEX observation provider with batch prefetch.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Caching wrapper for <see cref="IVexObservationProvider"/> that supports batch prefetch.
/// Implements short TTL bounded cache for gate throughput optimization.
/// </summary>
public sealed class CachingVexObservationProvider : IVexObservationBatchProvider, IDisposable
{
private readonly IVexObservationQuery _query;
private readonly string _tenantId;
private readonly MemoryCache _cache;
private readonly TimeSpan _cacheTtl;
private readonly ILogger<CachingVexObservationProvider> _logger;
private readonly SemaphoreSlim _prefetchLock = new(1, 1);
/// <summary>
/// Default cache size limit (number of entries).
/// </summary>
public const int DefaultCacheSizeLimit = 10_000;
/// <summary>
/// Default cache TTL.
/// </summary>
public static readonly TimeSpan DefaultCacheTtl = TimeSpan.FromMinutes(5);
public CachingVexObservationProvider(
IVexObservationQuery query,
string tenantId,
ILogger<CachingVexObservationProvider> logger,
TimeSpan? cacheTtl = null,
int? cacheSizeLimit = null)
{
_query = query;
_tenantId = tenantId;
_logger = logger;
_cacheTtl = cacheTtl ?? DefaultCacheTtl;
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = cacheSizeLimit ?? DefaultCacheSizeLimit,
});
}
/// <inheritdoc />
public async Task<VexObservationResult?> GetVexStatusAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(vulnerabilityId, purl);
if (_cache.TryGetValue(cacheKey, out VexObservationResult? cached))
{
_logger.LogTrace("VEX cache hit: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
return cached;
}
_logger.LogTrace("VEX cache miss: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
var queryResult = await _query.GetEffectiveStatusAsync(
_tenantId,
vulnerabilityId,
purl,
cancellationToken);
if (queryResult is null)
{
return null;
}
var result = MapToObservationResult(queryResult);
CacheResult(cacheKey, result);
return result;
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementInfo>> GetStatementsAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default)
{
var statements = await _query.GetStatementsAsync(
_tenantId,
vulnerabilityId,
purl,
cancellationToken);
return statements
.Select(s => new VexStatementInfo
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = s.Status,
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
})
.ToList();
}
/// <inheritdoc />
public async Task PrefetchAsync(
IReadOnlyList<VexLookupKey> keys,
CancellationToken cancellationToken = default)
{
if (keys.Count == 0)
{
return;
}
// Deduplicate and find keys not in cache
var uncachedKeys = keys
.DistinctBy(k => BuildCacheKey(k.VulnerabilityId, k.Purl))
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.Purl), out _))
.Select(k => new VexQueryKey(k.VulnerabilityId, k.Purl))
.ToList();
if (uncachedKeys.Count == 0)
{
_logger.LogDebug("Prefetch: all {Count} keys already cached", keys.Count);
return;
}
_logger.LogDebug(
"Prefetch: fetching {UncachedCount} of {TotalCount} keys",
uncachedKeys.Count,
keys.Count);
await _prefetchLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
uncachedKeys = uncachedKeys
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.ProductId), out _))
.ToList();
if (uncachedKeys.Count == 0)
{
return;
}
var batchResults = await _query.BatchLookupAsync(
_tenantId,
uncachedKeys,
cancellationToken);
foreach (var (key, result) in batchResults)
{
var cacheKey = BuildCacheKey(key.VulnerabilityId, key.ProductId);
var observationResult = MapToObservationResult(result);
CacheResult(cacheKey, observationResult);
}
_logger.LogDebug(
"Prefetch: cached {ResultCount} results",
batchResults.Count);
}
finally
{
_prefetchLock.Release();
}
}
/// <summary>
/// Gets cache statistics.
/// </summary>
public CacheStatistics GetStatistics() => new()
{
CurrentEntryCount = _cache.Count,
};
/// <inheritdoc />
public void Dispose()
{
_cache.Dispose();
_prefetchLock.Dispose();
}
private static string BuildCacheKey(string vulnerabilityId, string productId) =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"vex:{0}:{1}",
vulnerabilityId.ToUpperInvariant(),
productId.ToLowerInvariant());
private static VexObservationResult MapToObservationResult(VexObservationQueryResult queryResult) =>
new()
{
Status = queryResult.Status,
Justification = queryResult.Justification,
Confidence = queryResult.Confidence,
BackportHints = queryResult.BackportHints,
};
private void CacheResult(string cacheKey, VexObservationResult result)
{
var options = new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = _cacheTtl,
AbsoluteExpirationRelativeToNow = _cacheTtl * 2,
};
_cache.Set(cacheKey, result, options);
}
}
/// <summary>
/// Cache statistics for monitoring.
/// </summary>
public sealed record CacheStatistics
{
/// <summary>
/// Current number of entries in cache.
/// </summary>
public int CurrentEntryCount { get; init; }
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// IVexGateService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Interface for VEX gate evaluation service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Service for evaluating findings against VEX evidence and policy rules.
/// Determines whether findings should pass, warn, or block before triage.
/// </summary>
public interface IVexGateService
{
/// <summary>
/// Evaluates a single finding against VEX evidence and policy rules.
/// </summary>
/// <param name="finding">Finding to evaluate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate evaluation result.</returns>
Task<VexGateResult> EvaluateAsync(
VexGateFinding finding,
CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates multiple findings in batch for efficiency.
/// </summary>
/// <param name="findings">Findings to evaluate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate evaluation results for each finding.</returns>
Task<ImmutableArray<GatedFinding>> EvaluateBatchAsync(
IReadOnlyList<VexGateFinding> findings,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for pluggable VEX gate policy evaluation.
/// </summary>
public interface IVexGatePolicy
{
/// <summary>
/// Gets the current policy configuration.
/// </summary>
VexGatePolicy Policy { get; }
/// <summary>
/// Evaluates evidence against policy rules and returns the decision.
/// </summary>
/// <param name="evidence">Evidence to evaluate.</param>
/// <returns>Tuple of (decision, matched rule ID, rationale).</returns>
(VexGateDecision Decision, string RuleId, string Rationale) Evaluate(VexGateEvidence evidence);
}
/// <summary>
/// Input finding for VEX gate evaluation.
/// </summary>
public sealed record VexGateFinding
{
/// <summary>
/// Unique identifier for the finding.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// CVE or vulnerability identifier.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Image digest containing the component.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
public string? SeverityLevel { get; init; }
/// <summary>
/// Whether reachability has been analyzed.
/// </summary>
public bool? IsReachable { get; init; }
/// <summary>
/// Whether compensating controls are in place.
/// </summary>
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Whether the vulnerability is known to be exploitable.
/// </summary>
public bool? IsExploitable { get; init; }
}
/// <summary>
/// Finding with gate evaluation result.
/// </summary>
public sealed record GatedFinding
{
/// <summary>
/// Reference to the original finding.
/// </summary>
public required VexGateFinding Finding { get; init; }
/// <summary>
/// Gate evaluation result.
/// </summary>
public required VexGateResult GateResult { get; init; }
}

View File

@@ -0,0 +1,150 @@
// -----------------------------------------------------------------------------
// IVexObservationQuery.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Query interface for VEX observations used by gate service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Query interface for VEX observations.
/// Abstracts data access for gate service lookups.
/// </summary>
public interface IVexObservationQuery
{
/// <summary>
/// Looks up the effective VEX status for a vulnerability/product combination.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="vulnerabilityId">CVE or vulnerability ID.</param>
/// <param name="productId">PURL or product identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>VEX observation result or null if not found.</returns>
Task<VexObservationQueryResult?> GetEffectiveStatusAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability/product combination.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="vulnerabilityId">CVE or vulnerability ID.</param>
/// <param name="productId">PURL or product identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of VEX statement information.</returns>
Task<IReadOnlyList<VexStatementQueryResult>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Performs batch lookup of VEX statuses for multiple vulnerability/product pairs.
/// More efficient than individual lookups for gate evaluation.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="queries">List of vulnerability/product pairs to look up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping query keys to results.</returns>
Task<IReadOnlyDictionary<VexQueryKey, VexObservationQueryResult>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexQueryKey> queries,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Key for VEX query lookups.
/// </summary>
public sealed record VexQueryKey(string VulnerabilityId, string ProductId)
{
/// <summary>
/// Creates a normalized key for consistent lookup.
/// </summary>
public string ToNormalizedKey() =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0}|{1}",
VulnerabilityId.ToUpperInvariant(),
ProductId.ToLowerInvariant());
}
/// <summary>
/// Result from VEX observation query.
/// </summary>
public sealed record VexObservationQueryResult
{
/// <summary>
/// Effective VEX status.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification if status is NotAffected.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Confidence score for this status (0.0 to 1.0).
/// </summary>
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Backport hints if status is Fixed.
/// </summary>
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Source of the statement (vendor name or issuer).
/// </summary>
public string? Source { get; init; }
/// <summary>
/// When the effective status was last updated.
/// </summary>
public DateTimeOffset LastUpdated { get; init; }
}
/// <summary>
/// Individual VEX statement query result.
/// </summary>
public sealed record VexStatementQueryResult
{
/// <summary>
/// Statement identifier.
/// </summary>
public required string StatementId { get; init; }
/// <summary>
/// Issuer of the statement.
/// </summary>
public required string IssuerId { get; init; }
/// <summary>
/// VEX status in the statement.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification if status is NotAffected.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// When the statement was issued.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight for this statement.
/// </summary>
public double TrustWeight { get; init; } = 1.0;
/// <summary>
/// Source URL for the statement.
/// </summary>
public string? SourceUrl { get; init; }
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Scanner.Gate</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,305 @@
// -----------------------------------------------------------------------------
// VexGateAuditLogger.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T023
// Description: Audit logging for VEX gate decisions (compliance requirement).
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Interface for audit logging VEX gate decisions.
/// </summary>
public interface IVexGateAuditLogger
{
/// <summary>
/// Logs a gate evaluation event.
/// </summary>
void LogEvaluation(VexGateAuditEntry entry);
/// <summary>
/// Logs a batch gate evaluation summary.
/// </summary>
void LogBatchSummary(VexGateBatchAuditEntry entry);
}
/// <summary>
/// Audit entry for a single gate evaluation.
/// </summary>
public sealed record VexGateAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("auditId")]
public required string AuditId { get; init; }
/// <summary>
/// Scan job ID.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Finding ID that was evaluated.
/// </summary>
[JsonPropertyName("findingId")]
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability ID (CVE).
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Gate decision made.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Policy rule that matched.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// Policy version used.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Rationale for the decision.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// Evidence that contributed to the decision.
/// </summary>
[JsonPropertyName("evidence")]
public VexGateEvidenceSummary? Evidence { get; init; }
/// <summary>
/// Number of VEX statements consulted.
/// </summary>
[JsonPropertyName("statementCount")]
public int StatementCount { get; init; }
/// <summary>
/// Confidence score of the decision.
/// </summary>
[JsonPropertyName("confidenceScore")]
public double ConfidenceScore { get; init; }
/// <summary>
/// When the evaluation was performed (UTC).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Source IP or identifier of the requester (for compliance).
/// </summary>
[JsonPropertyName("sourceContext")]
public string? SourceContext { get; init; }
}
/// <summary>
/// Summarized evidence for audit logging.
/// </summary>
public sealed record VexGateEvidenceSummary
{
[JsonPropertyName("vendorStatus")]
public string? VendorStatus { get; init; }
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
[JsonPropertyName("hasCompensatingControl")]
public bool HasCompensatingControl { get; init; }
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
}
/// <summary>
/// Audit entry for a batch gate evaluation.
/// </summary>
public sealed record VexGateBatchAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("auditId")]
public required string AuditId { get; init; }
/// <summary>
/// Scan job ID.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Total findings evaluated.
/// </summary>
[JsonPropertyName("totalFindings")]
public int TotalFindings { get; init; }
/// <summary>
/// Number that passed.
/// </summary>
[JsonPropertyName("passedCount")]
public int PassedCount { get; init; }
/// <summary>
/// Number with warnings.
/// </summary>
[JsonPropertyName("warnedCount")]
public int WarnedCount { get; init; }
/// <summary>
/// Number blocked.
/// </summary>
[JsonPropertyName("blockedCount")]
public int BlockedCount { get; init; }
/// <summary>
/// Policy version used.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Whether gate was bypassed.
/// </summary>
[JsonPropertyName("bypassed")]
public bool Bypassed { get; init; }
/// <summary>
/// Evaluation duration in milliseconds.
/// </summary>
[JsonPropertyName("durationMs")]
public double DurationMs { get; init; }
/// <summary>
/// When the batch evaluation was performed (UTC).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Source context for compliance.
/// </summary>
[JsonPropertyName("sourceContext")]
public string? SourceContext { get; init; }
}
/// <summary>
/// Default implementation using structured logging.
/// </summary>
public sealed class VexGateAuditLogger : IVexGateAuditLogger
{
private readonly ILogger<VexGateAuditLogger> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public VexGateAuditLogger(ILogger<VexGateAuditLogger> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public void LogEvaluation(VexGateAuditEntry entry)
{
// Log as structured event for compliance systems to consume
_logger.LogInformation(
"VEX_GATE_AUDIT: {AuditId} | Scan={ScanId} | Finding={FindingId} | CVE={VulnerabilityId} | " +
"Decision={Decision} | Rule={PolicyRuleMatched} | Confidence={ConfidenceScore:F2} | " +
"Evidence=[Reachable={IsReachable}, Exploitable={IsExploitable}]",
entry.AuditId,
entry.ScanId,
entry.FindingId,
entry.VulnerabilityId,
entry.Decision,
entry.PolicyRuleMatched,
entry.ConfidenceScore,
entry.Evidence?.IsReachable ?? false,
entry.Evidence?.IsExploitable ?? false);
// Also log full JSON for audit trail
if (_logger.IsEnabled(LogLevel.Debug))
{
var json = JsonSerializer.Serialize(entry, JsonOptions);
_logger.LogDebug("VEX_GATE_AUDIT_DETAIL: {AuditJson}", json);
}
}
/// <inheritdoc />
public void LogBatchSummary(VexGateBatchAuditEntry entry)
{
_logger.LogInformation(
"VEX_GATE_BATCH_AUDIT: {AuditId} | Scan={ScanId} | Total={TotalFindings} | " +
"Passed={PassedCount} | Warned={WarnedCount} | Blocked={BlockedCount} | " +
"Bypassed={Bypassed} | Duration={DurationMs}ms",
entry.AuditId,
entry.ScanId,
entry.TotalFindings,
entry.PassedCount,
entry.WarnedCount,
entry.BlockedCount,
entry.Bypassed,
entry.DurationMs);
// Full JSON for audit trail
if (_logger.IsEnabled(LogLevel.Debug))
{
var json = JsonSerializer.Serialize(entry, JsonOptions);
_logger.LogDebug("VEX_GATE_BATCH_AUDIT_DETAIL: {AuditJson}", json);
}
}
}
/// <summary>
/// No-op audit logger for testing or when auditing is disabled.
/// </summary>
public sealed class NullVexGateAuditLogger : IVexGateAuditLogger
{
public static readonly NullVexGateAuditLogger Instance = new();
private NullVexGateAuditLogger() { }
public void LogEvaluation(VexGateAuditEntry entry) { }
public void LogBatchSummary(VexGateBatchAuditEntry entry) { }
}

View File

@@ -0,0 +1,38 @@
// -----------------------------------------------------------------------------
// VexGateDecision.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate decision enum for pre-triage filtering.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Decision outcome from VEX gate evaluation.
/// Determines whether a finding proceeds to triage and with what flags.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexGateDecision>))]
public enum VexGateDecision
{
/// <summary>
/// Finding cleared by VEX evidence - no action needed.
/// Typically when vendor status is NotAffected with sufficient trust.
/// </summary>
[JsonStringEnumMemberName("pass")]
Pass,
/// <summary>
/// Finding has partial evidence - proceed with caution.
/// Used when evidence is inconclusive or conditions partially met.
/// </summary>
[JsonStringEnumMemberName("warn")]
Warn,
/// <summary>
/// Finding requires immediate attention - exploitable and reachable.
/// Highest priority for triage queue.
/// </summary>
[JsonStringEnumMemberName("block")]
Block
}

View File

@@ -0,0 +1,263 @@
// -----------------------------------------------------------------------------
// VexGateExcititorAdapter.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Adapter bridging VexGateService with Excititor VEX statements.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Adapter that implements <see cref="IVexObservationQuery"/> by querying Excititor.
/// This is a reference implementation that can be used when Excititor is available.
/// </summary>
/// <remarks>
/// The actual Excititor integration requires a project reference to Excititor.Persistence.
/// This adapter provides the contract and can be implemented in a separate assembly
/// that has access to both Scanner.Gate and Excititor.Persistence.
/// </remarks>
public sealed class VexGateExcititorAdapter : IVexObservationQuery
{
private readonly IVexStatementDataSource _dataSource;
private readonly ILogger<VexGateExcititorAdapter> _logger;
public VexGateExcititorAdapter(
IVexStatementDataSource dataSource,
ILogger<VexGateExcititorAdapter> logger)
{
_dataSource = dataSource;
_logger = logger;
}
/// <inheritdoc />
public async Task<VexObservationQueryResult?> GetEffectiveStatusAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Looking up effective VEX status: tenant={TenantId}, vuln={VulnerabilityId}, product={ProductId}",
tenantId, vulnerabilityId, productId);
var statement = await _dataSource.GetEffectiveStatementAsync(
tenantId,
vulnerabilityId,
productId,
cancellationToken);
if (statement is null)
{
return null;
}
return new VexObservationQueryResult
{
Status = MapStatus(statement.Status),
Justification = MapJustification(statement.Justification),
Confidence = statement.TrustWeight,
BackportHints = statement.BackportHints,
Source = statement.Source,
LastUpdated = statement.LastUpdated,
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementQueryResult>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default)
{
var statements = await _dataSource.GetStatementsAsync(
tenantId,
vulnerabilityId,
productId,
cancellationToken);
return statements
.Select(s => new VexStatementQueryResult
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = MapStatus(s.Status),
Justification = MapJustification(s.Justification),
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
SourceUrl = s.SourceUrl,
})
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<VexQueryKey, VexObservationQueryResult>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexQueryKey> queries,
CancellationToken cancellationToken = default)
{
if (queries.Count == 0)
{
return ImmutableDictionary<VexQueryKey, VexObservationQueryResult>.Empty;
}
_logger.LogDebug(
"Batch lookup of {Count} VEX queries for tenant {TenantId}",
queries.Count, tenantId);
var results = new Dictionary<VexQueryKey, VexObservationQueryResult>();
// Use batch lookup if data source supports it
if (_dataSource is IVexStatementBatchDataSource batchSource)
{
var batchKeys = queries
.Select(q => new VexBatchKey(q.VulnerabilityId, q.ProductId))
.ToList();
var batchResults = await batchSource.BatchLookupAsync(
tenantId,
batchKeys,
cancellationToken);
foreach (var (key, statement) in batchResults)
{
var queryKey = new VexQueryKey(key.VulnerabilityId, key.ProductId);
results[queryKey] = new VexObservationQueryResult
{
Status = MapStatus(statement.Status),
Justification = MapJustification(statement.Justification),
Confidence = statement.TrustWeight,
BackportHints = statement.BackportHints,
Source = statement.Source,
LastUpdated = statement.LastUpdated,
};
}
}
else
{
// Fallback to individual lookups
foreach (var query in queries)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await GetEffectiveStatusAsync(
tenantId,
query.VulnerabilityId,
query.ProductId,
cancellationToken);
if (result is not null)
{
results[query] = result;
}
}
}
return results;
}
private static VexStatus MapStatus(VexStatementStatus status) => status switch
{
VexStatementStatus.NotAffected => VexStatus.NotAffected,
VexStatementStatus.Affected => VexStatus.Affected,
VexStatementStatus.Fixed => VexStatus.Fixed,
VexStatementStatus.UnderInvestigation => VexStatus.UnderInvestigation,
_ => VexStatus.UnderInvestigation,
};
private static VexJustification? MapJustification(VexStatementJustification? justification) =>
justification switch
{
VexStatementJustification.ComponentNotPresent => VexJustification.ComponentNotPresent,
VexStatementJustification.VulnerableCodeNotPresent => VexJustification.VulnerableCodeNotPresent,
VexStatementJustification.VulnerableCodeNotInExecutePath => VexJustification.VulnerableCodeNotInExecutePath,
VexStatementJustification.VulnerableCodeCannotBeControlledByAdversary => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
VexStatementJustification.InlineMitigationsAlreadyExist => VexJustification.InlineMitigationsAlreadyExist,
_ => null,
};
}
/// <summary>
/// Data source abstraction for VEX statements.
/// Implemented by Excititor persistence layer.
/// </summary>
public interface IVexStatementDataSource
{
/// <summary>
/// Gets the effective VEX statement for a vulnerability/product combination.
/// </summary>
Task<VexStatementData?> GetEffectiveStatementAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability/product combination.
/// </summary>
Task<IReadOnlyList<VexStatementData>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Extended interface for batch data source operations.
/// </summary>
public interface IVexStatementBatchDataSource : IVexStatementDataSource
{
/// <summary>
/// Performs batch lookup of VEX statements.
/// </summary>
Task<IReadOnlyDictionary<VexBatchKey, VexStatementData>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexBatchKey> keys,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Key for batch VEX lookups.
/// </summary>
public sealed record VexBatchKey(string VulnerabilityId, string ProductId);
/// <summary>
/// VEX statement data transfer object.
/// </summary>
public sealed record VexStatementData
{
public required string StatementId { get; init; }
public required string IssuerId { get; init; }
public required VexStatementStatus Status { get; init; }
public VexStatementJustification? Justification { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public DateTimeOffset LastUpdated { get; init; }
public double TrustWeight { get; init; } = 1.0;
public string? Source { get; init; }
public string? SourceUrl { get; init; }
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// VEX statement status (mirrors Excititor's VexStatus).
/// </summary>
public enum VexStatementStatus
{
NotAffected,
Affected,
Fixed,
UnderInvestigation
}
/// <summary>
/// VEX statement justification (mirrors Excititor's VexJustification).
/// </summary>
public enum VexStatementJustification
{
ComponentNotPresent,
VulnerableCodeNotPresent,
VulnerableCodeNotInExecutePath,
VulnerableCodeCannotBeControlledByAdversary,
InlineMitigationsAlreadyExist
}

View File

@@ -0,0 +1,379 @@
// -----------------------------------------------------------------------------
// VexGateOptions.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T028 - Add gate policy to tenant configuration
// Description: Configuration options for VEX gate, bindable from YAML/JSON config.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Configuration options for VEX gate service.
/// Binds to "VexGate" section in configuration files.
/// </summary>
public sealed class VexGateOptions : IValidatableObject
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "VexGate";
/// <summary>
/// Enable VEX-first gating. Default: false.
/// When disabled, all findings pass through to triage unchanged.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Default decision when no rules match. Default: Warn.
/// </summary>
public string DefaultDecision { get; set; } = "Warn";
/// <summary>
/// Policy version for audit/replay purposes.
/// Should be incremented when rules change.
/// </summary>
public string PolicyVersion { get; set; } = "1.0.0";
/// <summary>
/// Evaluation rules (ordered by priority, highest first).
/// </summary>
public List<VexGateRuleOptions> Rules { get; set; } = [];
/// <summary>
/// Caching settings for VEX observation lookups.
/// </summary>
public VexGateCacheOptions Cache { get; set; } = new();
/// <summary>
/// Audit logging settings.
/// </summary>
public VexGateAuditOptions Audit { get; set; } = new();
/// <summary>
/// Metrics settings.
/// </summary>
public VexGateMetricsOptions Metrics { get; set; } = new();
/// <summary>
/// Bypass settings for emergency scans.
/// </summary>
public VexGateBypassOptions Bypass { get; set; } = new();
/// <summary>
/// Converts this options instance to a VexGatePolicy.
/// </summary>
public VexGatePolicy ToPolicy()
{
var defaultDecision = ParseDecision(DefaultDecision);
var rules = Rules
.Select(r => r.ToRule())
.OrderByDescending(r => r.Priority)
.ToImmutableArray();
return new VexGatePolicy
{
DefaultDecision = defaultDecision,
Rules = rules,
};
}
/// <summary>
/// Creates options from a VexGatePolicy.
/// </summary>
public static VexGateOptions FromPolicy(VexGatePolicy policy)
{
return new VexGateOptions
{
Enabled = true,
DefaultDecision = policy.DefaultDecision.ToString(),
Rules = policy.Rules.Select(r => VexGateRuleOptions.FromRule(r)).ToList(),
};
}
private static VexGateDecision ParseDecision(string value)
{
return value.ToUpperInvariant() switch
{
"PASS" => VexGateDecision.Pass,
"WARN" => VexGateDecision.Warn,
"BLOCK" => VexGateDecision.Block,
_ => VexGateDecision.Warn,
};
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Enabled && Rules.Count == 0)
{
yield return new ValidationResult(
"At least one rule is required when VexGate is enabled",
[nameof(Rules)]);
}
var ruleIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rule in Rules)
{
if (string.IsNullOrWhiteSpace(rule.RuleId))
{
yield return new ValidationResult(
"Rule ID is required for all rules",
[nameof(Rules)]);
}
else if (!ruleIds.Add(rule.RuleId))
{
yield return new ValidationResult(
$"Duplicate rule ID: {rule.RuleId}",
[nameof(Rules)]);
}
}
if (Cache.TtlSeconds <= 0)
{
yield return new ValidationResult(
"Cache TTL must be positive",
[nameof(Cache)]);
}
if (Cache.MaxEntries <= 0)
{
yield return new ValidationResult(
"Cache max entries must be positive",
[nameof(Cache)]);
}
}
}
/// <summary>
/// Configuration options for a single VEX gate rule.
/// </summary>
public sealed class VexGateRuleOptions
{
/// <summary>
/// Unique identifier for this rule.
/// </summary>
[Required]
public string RuleId { get; set; } = string.Empty;
/// <summary>
/// Priority order (higher values evaluated first).
/// </summary>
public int Priority { get; set; } = 0;
/// <summary>
/// Decision to apply when this rule matches.
/// </summary>
[Required]
public string Decision { get; set; } = "Warn";
/// <summary>
/// Condition that must match for this rule to apply.
/// </summary>
public VexGateConditionOptions Condition { get; set; } = new();
/// <summary>
/// Converts to a VexGatePolicyRule.
/// </summary>
public VexGatePolicyRule ToRule()
{
return new VexGatePolicyRule
{
RuleId = RuleId,
Priority = Priority,
Decision = ParseDecision(Decision),
Condition = Condition.ToCondition(),
};
}
/// <summary>
/// Creates options from a VexGatePolicyRule.
/// </summary>
public static VexGateRuleOptions FromRule(VexGatePolicyRule rule)
{
return new VexGateRuleOptions
{
RuleId = rule.RuleId,
Priority = rule.Priority,
Decision = rule.Decision.ToString(),
Condition = VexGateConditionOptions.FromCondition(rule.Condition),
};
}
private static VexGateDecision ParseDecision(string value)
{
return value.ToUpperInvariant() switch
{
"PASS" => VexGateDecision.Pass,
"WARN" => VexGateDecision.Warn,
"BLOCK" => VexGateDecision.Block,
_ => VexGateDecision.Warn,
};
}
}
/// <summary>
/// Configuration options for a rule condition.
/// </summary>
public sealed class VexGateConditionOptions
{
/// <summary>
/// Required VEX vendor status.
/// Options: not_affected, fixed, affected, under_investigation.
/// </summary>
public string? VendorStatus { get; set; }
/// <summary>
/// Whether the vulnerability must be exploitable.
/// </summary>
public bool? IsExploitable { get; set; }
/// <summary>
/// Whether the vulnerable code must be reachable.
/// </summary>
public bool? IsReachable { get; set; }
/// <summary>
/// Whether compensating controls must be present.
/// </summary>
public bool? HasCompensatingControl { get; set; }
/// <summary>
/// Whether the CVE is in KEV (Known Exploited Vulnerabilities).
/// </summary>
public bool? IsKnownExploited { get; set; }
/// <summary>
/// Required severity levels (any match).
/// </summary>
public List<string>? SeverityLevels { get; set; }
/// <summary>
/// Minimum confidence score required.
/// </summary>
public double? ConfidenceThreshold { get; set; }
/// <summary>
/// Converts to a VexGatePolicyCondition.
/// </summary>
public VexGatePolicyCondition ToCondition()
{
return new VexGatePolicyCondition
{
VendorStatus = ParseVexStatus(VendorStatus),
IsExploitable = IsExploitable,
IsReachable = IsReachable,
HasCompensatingControl = HasCompensatingControl,
SeverityLevels = SeverityLevels?.ToArray(),
MinConfidence = ConfidenceThreshold,
};
}
/// <summary>
/// Creates options from a VexGatePolicyCondition.
/// </summary>
public static VexGateConditionOptions FromCondition(VexGatePolicyCondition condition)
{
return new VexGateConditionOptions
{
VendorStatus = condition.VendorStatus?.ToString().ToLowerInvariant(),
IsExploitable = condition.IsExploitable,
IsReachable = condition.IsReachable,
HasCompensatingControl = condition.HasCompensatingControl,
SeverityLevels = condition.SeverityLevels?.ToList(),
ConfidenceThreshold = condition.MinConfidence,
};
}
private static VexStatus? ParseVexStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
return value.ToLowerInvariant() switch
{
"not_affected" or "notaffected" => VexStatus.NotAffected,
"fixed" => VexStatus.Fixed,
"affected" => VexStatus.Affected,
"under_investigation" or "underinvestigation" => VexStatus.UnderInvestigation,
_ => null,
};
}
}
/// <summary>
/// Cache configuration options.
/// </summary>
public sealed class VexGateCacheOptions
{
/// <summary>
/// TTL for cached VEX observations (seconds). Default: 300.
/// </summary>
public int TtlSeconds { get; set; } = 300;
/// <summary>
/// Maximum cache entries. Default: 10000.
/// </summary>
public int MaxEntries { get; set; } = 10000;
}
/// <summary>
/// Audit logging configuration options.
/// </summary>
public sealed class VexGateAuditOptions
{
/// <summary>
/// Enable structured audit logging for compliance. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Include full evidence in audit logs. Default: true.
/// </summary>
public bool IncludeEvidence { get; set; } = true;
/// <summary>
/// Log level for gate decisions. Default: Information.
/// </summary>
public string LogLevel { get; set; } = "Information";
}
/// <summary>
/// Metrics configuration options.
/// </summary>
public sealed class VexGateMetricsOptions
{
/// <summary>
/// Enable OpenTelemetry metrics. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Histogram buckets for evaluation latency (milliseconds).
/// </summary>
public List<double> LatencyBuckets { get; set; } = [1, 5, 10, 25, 50, 100, 250];
}
/// <summary>
/// Bypass configuration options.
/// </summary>
public sealed class VexGateBypassOptions
{
/// <summary>
/// Allow gate bypass via CLI flag (--bypass-gate). Default: true.
/// </summary>
public bool AllowCliBypass { get; set; } = true;
/// <summary>
/// Require specific reason when bypassing. Default: false.
/// </summary>
public bool RequireReason { get; set; } = false;
/// <summary>
/// Emit warning when bypass is used. Default: true.
/// </summary>
public bool WarnOnBypass { get; set; } = true;
}

View File

@@ -0,0 +1,201 @@
// -----------------------------------------------------------------------------
// VexGatePolicy.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate policy configuration models.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// VEX gate policy defining rules for gate decisions.
/// Rules are evaluated in priority order (highest first).
/// </summary>
public sealed record VexGatePolicy
{
/// <summary>
/// Ordered list of policy rules.
/// </summary>
[JsonPropertyName("rules")]
public required ImmutableArray<VexGatePolicyRule> Rules { get; init; }
/// <summary>
/// Default decision when no rules match.
/// </summary>
[JsonPropertyName("defaultDecision")]
public required VexGateDecision DefaultDecision { get; init; }
/// <summary>
/// Creates the default gate policy per product advisory.
/// </summary>
public static VexGatePolicy Default => new()
{
DefaultDecision = VexGateDecision.Warn,
Rules = ImmutableArray.Create(
new VexGatePolicyRule
{
RuleId = "block-exploitable-reachable",
Priority = 100,
Condition = new VexGatePolicyCondition
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false,
},
Decision = VexGateDecision.Block,
},
new VexGatePolicyRule
{
RuleId = "warn-high-not-reachable",
Priority = 90,
Condition = new VexGatePolicyCondition
{
SeverityLevels = ["critical", "high"],
IsReachable = false,
},
Decision = VexGateDecision.Warn,
},
new VexGatePolicyRule
{
RuleId = "pass-vendor-not-affected",
Priority = 80,
Condition = new VexGatePolicyCondition
{
VendorStatus = VexStatus.NotAffected,
},
Decision = VexGateDecision.Pass,
},
new VexGatePolicyRule
{
RuleId = "pass-backport-confirmed",
Priority = 70,
Condition = new VexGatePolicyCondition
{
VendorStatus = VexStatus.Fixed,
},
Decision = VexGateDecision.Pass,
}
),
};
}
/// <summary>
/// A single policy rule for VEX gate evaluation.
/// </summary>
public sealed record VexGatePolicyRule
{
/// <summary>
/// Unique identifier for this rule.
/// </summary>
[JsonPropertyName("ruleId")]
public required string RuleId { get; init; }
/// <summary>
/// Condition that must match for this rule to apply.
/// </summary>
[JsonPropertyName("condition")]
public required VexGatePolicyCondition Condition { get; init; }
/// <summary>
/// Decision to apply when this rule matches.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Priority order (higher values evaluated first).
/// </summary>
[JsonPropertyName("priority")]
public required int Priority { get; init; }
}
/// <summary>
/// Condition for a policy rule to match.
/// All non-null properties must match for the condition to be satisfied.
/// </summary>
public sealed record VexGatePolicyCondition
{
/// <summary>
/// Required VEX vendor status.
/// </summary>
[JsonPropertyName("vendorStatus")]
public VexStatus? VendorStatus { get; init; }
/// <summary>
/// Whether the vulnerability must be exploitable.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool? IsExploitable { get; init; }
/// <summary>
/// Whether the vulnerable code must be reachable.
/// </summary>
[JsonPropertyName("isReachable")]
public bool? IsReachable { get; init; }
/// <summary>
/// Whether compensating controls must be present.
/// </summary>
[JsonPropertyName("hasCompensatingControl")]
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Required severity levels (any match).
/// </summary>
[JsonPropertyName("severityLevels")]
public string[]? SeverityLevels { get; init; }
/// <summary>
/// Minimum confidence score required.
/// </summary>
[JsonPropertyName("minConfidence")]
public double? MinConfidence { get; init; }
/// <summary>
/// Required VEX justification type.
/// </summary>
[JsonPropertyName("justification")]
public VexJustification? Justification { get; init; }
/// <summary>
/// Evaluates whether the evidence matches this condition.
/// </summary>
/// <param name="evidence">Evidence to evaluate.</param>
/// <returns>True if all specified conditions match.</returns>
public bool Matches(VexGateEvidence evidence)
{
if (VendorStatus is not null && evidence.VendorStatus != VendorStatus)
return false;
if (IsExploitable is not null && evidence.IsExploitable != IsExploitable)
return false;
if (IsReachable is not null && evidence.IsReachable != IsReachable)
return false;
if (HasCompensatingControl is not null && evidence.HasCompensatingControl != HasCompensatingControl)
return false;
if (SeverityLevels is not null && SeverityLevels.Length > 0)
{
if (evidence.SeverityLevel is null)
return false;
var matchesSeverity = SeverityLevels.Any(s =>
string.Equals(s, evidence.SeverityLevel, StringComparison.OrdinalIgnoreCase));
if (!matchesSeverity)
return false;
}
if (MinConfidence is not null && evidence.ConfidenceScore < MinConfidence)
return false;
if (Justification is not null && evidence.Justification != Justification)
return false;
return true;
}
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// VexGatePolicyEvaluator.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Policy evaluator for VEX gate decisions.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Default implementation of <see cref="IVexGatePolicy"/>.
/// Evaluates evidence against policy rules in priority order.
/// </summary>
public sealed class VexGatePolicyEvaluator : IVexGatePolicy
{
private readonly ILogger<VexGatePolicyEvaluator> _logger;
private readonly VexGatePolicy _policy;
public VexGatePolicyEvaluator(
IOptions<VexGatePolicyOptions> options,
ILogger<VexGatePolicyEvaluator> logger)
{
_logger = logger;
_policy = options.Value.Policy ?? VexGatePolicy.Default;
}
/// <summary>
/// Creates an evaluator with the default policy.
/// </summary>
public VexGatePolicyEvaluator(ILogger<VexGatePolicyEvaluator> logger)
{
_logger = logger;
_policy = VexGatePolicy.Default;
}
/// <inheritdoc />
public VexGatePolicy Policy => _policy;
/// <inheritdoc />
public (VexGateDecision Decision, string RuleId, string Rationale) Evaluate(VexGateEvidence evidence)
{
// Sort rules by priority descending and evaluate in order
var sortedRules = _policy.Rules
.OrderByDescending(r => r.Priority)
.ToList();
foreach (var rule in sortedRules)
{
if (rule.Condition.Matches(evidence))
{
var rationale = BuildRationale(rule, evidence);
_logger.LogDebug(
"VEX gate rule matched: {RuleId} -> {Decision} for evidence with vendor status {VendorStatus}",
rule.RuleId,
rule.Decision,
evidence.VendorStatus);
return (rule.Decision, rule.RuleId, rationale);
}
}
// No rule matched, return default
var defaultRationale = "No policy rule matched; applying default decision";
_logger.LogDebug(
"No VEX gate rule matched; defaulting to {Decision}",
_policy.DefaultDecision);
return (_policy.DefaultDecision, "default", defaultRationale);
}
private static string BuildRationale(VexGatePolicyRule rule, VexGateEvidence evidence)
{
return rule.RuleId switch
{
"block-exploitable-reachable" =>
"Exploitable + reachable, no compensating control",
"warn-high-not-reachable" =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0} severity but not reachable from entrypoints",
evidence.SeverityLevel ?? "High"),
"pass-vendor-not-affected" =>
"Vendor VEX statement declares not_affected",
"pass-backport-confirmed" =>
"Vendor VEX statement confirms fixed via backport",
_ => string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"Policy rule '{0}' matched",
rule.RuleId)
};
}
}
/// <summary>
/// Options for VEX gate policy configuration.
/// </summary>
public sealed class VexGatePolicyOptions
{
/// <summary>
/// Custom policy to use instead of default.
/// </summary>
public VexGatePolicy? Policy { get; set; }
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,144 @@
// -----------------------------------------------------------------------------
// VexGateResult.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate evaluation result with evidence.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Result of VEX gate evaluation for a single finding.
/// Contains the decision, rationale, and supporting evidence.
/// </summary>
public sealed record VexGateResult
{
/// <summary>
/// Gate decision: Pass, Warn, or Block.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Human-readable explanation of why this decision was made.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// ID of the policy rule that matched and produced this decision.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// VEX statements that contributed to this decision.
/// </summary>
[JsonPropertyName("contributingStatements")]
public required ImmutableArray<VexStatementRef> ContributingStatements { get; init; }
/// <summary>
/// Detailed evidence supporting the decision.
/// </summary>
[JsonPropertyName("evidence")]
public required VexGateEvidence Evidence { get; init; }
/// <summary>
/// When this evaluation was performed (UTC ISO-8601).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
}
/// <summary>
/// Evidence collected during VEX gate evaluation.
/// </summary>
public sealed record VexGateEvidence
{
/// <summary>
/// VEX status from vendor or authoritative source.
/// Null if no VEX statement found.
/// </summary>
[JsonPropertyName("vendorStatus")]
public VexStatus? VendorStatus { get; init; }
/// <summary>
/// Justification type from VEX statement.
/// </summary>
[JsonPropertyName("justification")]
public VexJustification? Justification { get; init; }
/// <summary>
/// Whether the vulnerable code is reachable from entrypoints.
/// </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>
/// Hints about backport fixes detected.
/// </summary>
[JsonPropertyName("backportHints")]
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether the vulnerability is exploitable based on available intelligence.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
}
/// <summary>
/// Reference to a VEX statement that contributed to a gate decision.
/// </summary>
public sealed record VexStatementRef
{
/// <summary>
/// Unique identifier for the VEX statement.
/// </summary>
[JsonPropertyName("statementId")]
public required string StatementId { get; init; }
/// <summary>
/// Issuer of the VEX statement.
/// </summary>
[JsonPropertyName("issuerId")]
public required string IssuerId { get; init; }
/// <summary>
/// VEX status declared in the statement.
/// </summary>
[JsonPropertyName("status")]
public required VexStatus Status { get; init; }
/// <summary>
/// When the statement was issued.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight of this statement in consensus (0.0 to 1.0).
/// </summary>
[JsonPropertyName("trustWeight")]
public double TrustWeight { get; init; }
}

View File

@@ -0,0 +1,249 @@
// -----------------------------------------------------------------------------
// VexGateService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate service implementation.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Default implementation of <see cref="IVexGateService"/>.
/// Evaluates findings against VEX evidence and policy rules.
/// </summary>
public sealed class VexGateService : IVexGateService
{
private readonly IVexGatePolicy _policyEvaluator;
private readonly IVexObservationProvider? _vexProvider;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VexGateService> _logger;
public VexGateService(
IVexGatePolicy policyEvaluator,
TimeProvider timeProvider,
ILogger<VexGateService> logger,
IVexObservationProvider? vexProvider = null)
{
_policyEvaluator = policyEvaluator;
_vexProvider = vexProvider;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<VexGateResult> EvaluateAsync(
VexGateFinding finding,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Evaluating VEX gate for finding {FindingId} ({VulnerabilityId})",
finding.FindingId,
finding.VulnerabilityId);
// Collect evidence from VEX provider and finding context
var evidence = await BuildEvidenceAsync(finding, cancellationToken);
// Evaluate against policy rules
var (decision, ruleId, rationale) = _policyEvaluator.Evaluate(evidence);
// Build statement references if we have VEX data
var contributingStatements = evidence.VendorStatus is not null
? await GetContributingStatementsAsync(
finding.VulnerabilityId,
finding.Purl,
cancellationToken)
: ImmutableArray<VexStatementRef>.Empty;
return new VexGateResult
{
Decision = decision,
Rationale = rationale,
PolicyRuleMatched = ruleId,
ContributingStatements = contributingStatements,
Evidence = evidence,
EvaluatedAt = _timeProvider.GetUtcNow(),
};
}
/// <inheritdoc />
public async Task<ImmutableArray<GatedFinding>> EvaluateBatchAsync(
IReadOnlyList<VexGateFinding> findings,
CancellationToken cancellationToken = default)
{
if (findings.Count == 0)
{
return ImmutableArray<GatedFinding>.Empty;
}
_logger.LogDebug("Evaluating VEX gate for {Count} findings in batch", findings.Count);
// Pre-fetch VEX data for all findings if provider supports batch
if (_vexProvider is IVexObservationBatchProvider batchProvider)
{
var queries = findings
.Select(f => new VexLookupKey(f.VulnerabilityId, f.Purl))
.Distinct()
.ToList();
await batchProvider.PrefetchAsync(queries, cancellationToken);
}
// Evaluate each finding
var results = new List<GatedFinding>(findings.Count);
foreach (var finding in findings)
{
cancellationToken.ThrowIfCancellationRequested();
var gateResult = await EvaluateAsync(finding, cancellationToken);
results.Add(new GatedFinding
{
Finding = finding,
GateResult = gateResult,
});
}
_logger.LogInformation(
"VEX gate batch complete: {Pass} passed, {Warn} warned, {Block} blocked",
results.Count(r => r.GateResult.Decision == VexGateDecision.Pass),
results.Count(r => r.GateResult.Decision == VexGateDecision.Warn),
results.Count(r => r.GateResult.Decision == VexGateDecision.Block));
return results.ToImmutableArray();
}
private async Task<VexGateEvidence> BuildEvidenceAsync(
VexGateFinding finding,
CancellationToken cancellationToken)
{
VexStatus? vendorStatus = null;
VexJustification? justification = null;
var backportHints = ImmutableArray<string>.Empty;
var confidenceScore = 0.5; // Default confidence
// Query VEX provider if available
if (_vexProvider is not null)
{
var vexResult = await _vexProvider.GetVexStatusAsync(
finding.VulnerabilityId,
finding.Purl,
cancellationToken);
if (vexResult is not null)
{
vendorStatus = vexResult.Status;
justification = vexResult.Justification;
confidenceScore = vexResult.Confidence;
backportHints = vexResult.BackportHints;
}
}
// Use exploitability from finding or infer from VEX status
var isExploitable = finding.IsExploitable ?? (vendorStatus == VexStatus.Affected);
return new VexGateEvidence
{
VendorStatus = vendorStatus,
Justification = justification,
IsReachable = finding.IsReachable ?? true, // Conservative: assume reachable if unknown
HasCompensatingControl = finding.HasCompensatingControl ?? false,
ConfidenceScore = confidenceScore,
BackportHints = backportHints,
IsExploitable = isExploitable,
SeverityLevel = finding.SeverityLevel,
};
}
private async Task<ImmutableArray<VexStatementRef>> GetContributingStatementsAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken)
{
if (_vexProvider is null)
{
return ImmutableArray<VexStatementRef>.Empty;
}
var statements = await _vexProvider.GetStatementsAsync(
vulnerabilityId,
purl,
cancellationToken);
return statements
.Select(s => new VexStatementRef
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = s.Status,
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
})
.ToImmutableArray();
}
}
/// <summary>
/// Key for VEX lookups.
/// </summary>
public sealed record VexLookupKey(string VulnerabilityId, string Purl);
/// <summary>
/// Result from VEX observation provider.
/// </summary>
public sealed record VexObservationResult
{
public required VexStatus Status { get; init; }
public VexJustification? Justification { get; init; }
public double Confidence { get; init; } = 1.0;
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// VEX statement info for contributing statements.
/// </summary>
public sealed record VexStatementInfo
{
public required string StatementId { get; init; }
public required string IssuerId { get; init; }
public required VexStatus Status { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public double TrustWeight { get; init; } = 1.0;
}
/// <summary>
/// Interface for VEX observation data provider.
/// Abstracts access to VEX statements from Excititor or other sources.
/// </summary>
public interface IVexObservationProvider
{
/// <summary>
/// Gets the VEX status for a vulnerability and component.
/// </summary>
Task<VexObservationResult?> GetVexStatusAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability and component.
/// </summary>
Task<IReadOnlyList<VexStatementInfo>> GetStatementsAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Extended interface for batch VEX observation prefetching.
/// </summary>
public interface IVexObservationBatchProvider : IVexObservationProvider
{
/// <summary>
/// Prefetches VEX data for multiple lookups.
/// </summary>
Task PrefetchAsync(
IReadOnlyList<VexLookupKey> keys,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,169 @@
// -----------------------------------------------------------------------------
// VexGateServiceCollectionExtensions.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T028 - Add gate policy to tenant configuration
// Description: Service collection extensions for registering VEX gate services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Extension methods for registering VEX gate services.
/// </summary>
public static class VexGateServiceCollectionExtensions
{
/// <summary>
/// Adds VEX gate services with configuration from the specified section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration root.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGate(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind and validate options
services.AddOptions<VexGateOptions>()
.Bind(configuration.GetSection(VexGateOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Register policy from options
services.AddSingleton<VexGatePolicy>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
if (!options.Value.Enabled)
{
// Return a permissive policy when disabled
return new VexGatePolicy
{
DefaultDecision = VexGateDecision.Pass,
Rules = [],
};
}
return options.Value.ToPolicy();
});
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with configured limits
services.AddSingleton<IMemoryCache>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
return new MemoryCache(new MemoryCacheOptions
{
SizeLimit = options.Value.Cache.MaxEntries,
});
});
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
/// <summary>
/// Adds VEX gate services with explicit options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">The options configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGate(
this IServiceCollection services,
Action<VexGateOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
// Configure and validate options
services.AddOptions<VexGateOptions>()
.Configure(configureOptions)
.ValidateDataAnnotations()
.ValidateOnStart();
// Register policy from options
services.AddSingleton<VexGatePolicy>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
if (!options.Value.Enabled)
{
return new VexGatePolicy
{
DefaultDecision = VexGateDecision.Pass,
Rules = [],
};
}
return options.Value.ToPolicy();
});
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with configured limits
services.AddSingleton<IMemoryCache>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
return new MemoryCache(new MemoryCacheOptions
{
SizeLimit = options.Value.Cache.MaxEntries,
});
});
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
/// <summary>
/// Adds VEX gate services with default policy.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGateWithDefaultPolicy(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Configure with default options
services.AddOptions<VexGateOptions>()
.Configure(options =>
{
options.Enabled = true;
var defaultPolicy = VexGatePolicy.Default;
options.DefaultDecision = defaultPolicy.DefaultDecision.ToString();
options.Rules = defaultPolicy.Rules
.Select(VexGateRuleOptions.FromRule)
.ToList();
})
.ValidateDataAnnotations()
.ValidateOnStart();
// Register default policy
services.AddSingleton<VexGatePolicy>(_ => VexGatePolicy.Default);
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with default limits
services.AddSingleton<IMemoryCache>(_ => new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 10000,
}));
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
}

View File

@@ -0,0 +1,78 @@
// -----------------------------------------------------------------------------
// VexTypes.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Local VEX type definitions for gate service independence.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// VEX status values per OpenVEX specification.
/// Local definition to avoid dependency on SmartDiff/Excititor.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexStatus>))]
public enum VexStatus
{
/// <summary>
/// The vulnerability is not exploitable in this context.
/// </summary>
[JsonStringEnumMemberName("not_affected")]
NotAffected,
/// <summary>
/// The vulnerability is exploitable.
/// </summary>
[JsonStringEnumMemberName("affected")]
Affected,
/// <summary>
/// The vulnerability has been fixed.
/// </summary>
[JsonStringEnumMemberName("fixed")]
Fixed,
/// <summary>
/// The vulnerability is under investigation.
/// </summary>
[JsonStringEnumMemberName("under_investigation")]
UnderInvestigation
}
/// <summary>
/// VEX justification codes per OpenVEX specification.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexJustification>))]
public enum VexJustification
{
/// <summary>
/// The vulnerable component is not present.
/// </summary>
[JsonStringEnumMemberName("component_not_present")]
ComponentNotPresent,
/// <summary>
/// The vulnerable code is not present.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_not_present")]
VulnerableCodeNotPresent,
/// <summary>
/// The vulnerable code is not in the execute path.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_not_in_execute_path")]
VulnerableCodeNotInExecutePath,
/// <summary>
/// The vulnerable code cannot be controlled by an adversary.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_cannot_be_controlled_by_adversary")]
VulnerableCodeCannotBeControlledByAdversary,
/// <summary>
/// Inline mitigations already exist.
/// </summary>
[JsonStringEnumMemberName("inline_mitigations_already_exist")]
InlineMitigationsAlreadyExist
}

View File

@@ -0,0 +1,25 @@
# AGENTS - Scanner.MaterialChanges Library
## Roles
- Backend engineer: maintain material change orchestration and card generation.
- QA / test engineer: validate deterministic report outputs and ordering.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/scanner/architecture.md
- src/Scanner/AGENTS.md
- Current sprint file under docs/implplan/SPRINT_*.md
## Working Directory & Boundaries
- Primary scope: src/Scanner/__Libraries/StellaOps.Scanner.MaterialChanges
- Related tests: src/Scanner/__Tests/StellaOps.Scanner.MaterialChanges.Tests
- Avoid cross-module edits unless explicitly noted in the sprint file.
## Determinism and Safety
- Ensure report ordering and hashes are deterministic (stable sort, canonical inputs).
- Avoid culture-sensitive comparisons when mapping severity or kinds.
## Testing
- Cover report ID determinism, summary aggregation, and option handling.

View File

@@ -0,0 +1,630 @@
// -----------------------------------------------------------------------------
// CardGenerators.cs
// Sprint: SPRINT_20260106_001_004_LB_material_changes_orchestrator
// Tasks: MCO-006 to MCO-010 - Card generator interfaces and implementations
// Description: Generates material change cards from various diff sources
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.MaterialChanges;
/// <summary>
/// Generates security-related change cards from SmartDiff.
/// </summary>
public interface ISecurityCardGenerator
{
Task<IReadOnlyList<MaterialChangeCard>> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct);
}
/// <summary>
/// Generates ABI-related change cards from SymbolDiff.
/// </summary>
public interface IAbiCardGenerator
{
Task<IReadOnlyList<MaterialChangeCard>> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct);
}
/// <summary>
/// Generates package-related change cards from ComponentDiff.
/// </summary>
public interface IPackageCardGenerator
{
Task<IReadOnlyList<MaterialChangeCard>> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct);
}
/// <summary>
/// Generates unknown-related change cards from Unknowns module.
/// </summary>
public interface IUnknownsCardGenerator
{
Task<(IReadOnlyList<MaterialChangeCard>, UnknownsSummary)> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct);
}
/// <summary>
/// Generates security cards from SmartDiff material risk changes.
/// </summary>
public sealed class SecurityCardGenerator : ISecurityCardGenerator
{
private readonly IMaterialRiskChangeProvider _smartDiff;
private readonly ILogger<SecurityCardGenerator> _logger;
public SecurityCardGenerator(
IMaterialRiskChangeProvider smartDiff,
ILogger<SecurityCardGenerator> logger)
{
_smartDiff = smartDiff;
_logger = logger;
}
public async Task<IReadOnlyList<MaterialChangeCard>> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct)
{
var changes = await _smartDiff.GetMaterialChangesAsync(
baseSnapshot.SnapshotId,
targetSnapshot.SnapshotId,
ct);
var cards = new List<MaterialChangeCard>();
foreach (var change in changes)
{
var priority = ComputeSecurityPriority(change);
var card = new MaterialChangeCard
{
CardId = ComputeCardId("sec", change.ChangeId),
Category = ChangeCategory.Security,
Scope = MapToScope(change.Scope),
Priority = priority,
What = new WhatChanged
{
Subject = change.Subject,
SubjectDisplay = change.SubjectDisplay,
ChangeType = change.RuleId,
Before = change.Before,
After = change.After,
Text = $"{change.SubjectDisplay}: {change.ChangeDescription}"
},
Why = new WhyItMatters
{
Impact = change.Impact,
Severity = change.Severity,
Context = change.CveId,
Text = FormatWhyText(change)
},
Action = new NextAction
{
Type = DetermineActionType(change),
ActionText = change.RecommendedAction ?? "Review change",
Link = change.CveId is not null ? $"https://nvd.nist.gov/vuln/detail/{change.CveId}" : null,
Text = change.RecommendedAction ?? "Review and assess impact"
},
Sources = [new ChangeSource { Module = "SmartDiff", SourceId = change.ChangeId }],
Cves = change.CveId is not null ? [change.CveId] : null
};
cards.Add(card);
}
_logger.LogDebug("Generated {Count} security cards", cards.Count);
return cards;
}
private static int ComputeSecurityPriority(MaterialRiskChange change)
{
// Base priority on severity and KEV status
var basePriority = change.Severity switch
{
"critical" => 95,
"high" => 80,
"medium" => 60,
"low" => 40,
_ => 30
};
// Boost if in KEV
if (change.IsInKev) basePriority = Math.Min(100, basePriority + 10);
// Boost if reachable
if (change.IsReachable) basePriority = Math.Min(100, basePriority + 5);
return basePriority;
}
private static ChangeScope MapToScope(string? scope) => scope switch
{
"package" => ChangeScope.Package,
"file" => ChangeScope.File,
"symbol" => ChangeScope.Symbol,
"layer" => ChangeScope.Layer,
_ => ChangeScope.Package
};
private static string FormatWhyText(MaterialRiskChange change)
{
var parts = new List<string> { $"Severity: {change.Severity}" };
if (change.IsInKev)
parts.Add("actively exploited (KEV)");
if (change.IsReachable)
parts.Add("reachable from entry points");
if (change.CveId is not null)
parts.Add(change.CveId);
return string.Join("; ", parts);
}
private static string DetermineActionType(MaterialRiskChange change) => change.Severity switch
{
"critical" => "urgent-upgrade",
"high" => "upgrade",
_ => "review"
};
private static string ComputeCardId(string prefix, string sourceId)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{prefix}:{sourceId}"));
return $"{prefix}-{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
}
/// <summary>
/// Material risk change from SmartDiff.
/// </summary>
public sealed record MaterialRiskChange
{
public required string ChangeId { get; init; }
public required string RuleId { get; init; }
public required string Subject { get; init; }
public required string SubjectDisplay { get; init; }
public string? Scope { get; init; }
public required string ChangeDescription { get; init; }
public string? Before { get; init; }
public string? After { get; init; }
public required string Impact { get; init; }
public required string Severity { get; init; }
public string? CveId { get; init; }
public bool IsInKev { get; init; }
public bool IsReachable { get; init; }
public string? RecommendedAction { get; init; }
}
/// <summary>
/// Provides material risk changes from SmartDiff.
/// </summary>
public interface IMaterialRiskChangeProvider
{
Task<IReadOnlyList<MaterialRiskChange>> GetMaterialChangesAsync(
string baseSnapshotId,
string targetSnapshotId,
CancellationToken ct);
}
/// <summary>
/// Generates ABI cards from symbol table diff.
/// </summary>
public sealed class AbiCardGenerator : IAbiCardGenerator
{
private readonly ISymbolDiffProvider _symbolDiff;
private readonly ILogger<AbiCardGenerator> _logger;
public AbiCardGenerator(
ISymbolDiffProvider symbolDiff,
ILogger<AbiCardGenerator> logger)
{
_symbolDiff = symbolDiff;
_logger = logger;
}
public async Task<IReadOnlyList<MaterialChangeCard>> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct)
{
var diff = await _symbolDiff.GetSymbolDiffAsync(
baseSnapshot.SnapshotId,
targetSnapshot.SnapshotId,
ct);
if (diff is null) return [];
var cards = new List<MaterialChangeCard>();
// Breaking changes get highest priority
foreach (var breaking in diff.BreakingChanges)
{
var card = new MaterialChangeCard
{
CardId = ComputeCardId("abi", breaking.SymbolName),
Category = ChangeCategory.Abi,
Scope = ChangeScope.Symbol,
Priority = 85,
What = new WhatChanged
{
Subject = breaking.SymbolName,
SubjectDisplay = breaking.DemangledName ?? breaking.SymbolName,
ChangeType = breaking.ChangeType,
Before = breaking.OldSignature,
After = breaking.NewSignature,
Text = $"{breaking.DemangledName ?? breaking.SymbolName}: {breaking.ChangeType}"
},
Why = new WhyItMatters
{
Impact = "ABI breaking change",
Severity = "high",
Context = breaking.BinaryPath,
Text = $"ABI breaking change in {breaking.BinaryPath}; may cause runtime failures"
},
Action = new NextAction
{
Type = "investigate",
ActionText = "Review callers and update if needed",
Text = "Check all callers of this symbol for compatibility"
},
Sources = [new ChangeSource { Module = "SymbolDiff", SourceId = diff.DiffId }]
};
cards.Add(card);
}
// Add removed exports as medium priority
foreach (var removed in diff.RemovedExports)
{
var card = new MaterialChangeCard
{
CardId = ComputeCardId("abi", $"removed:{removed.SymbolName}"),
Category = ChangeCategory.Abi,
Scope = ChangeScope.Symbol,
Priority = 75,
What = new WhatChanged
{
Subject = removed.SymbolName,
SubjectDisplay = removed.DemangledName ?? removed.SymbolName,
ChangeType = "removed",
Before = removed.SymbolName,
After = null,
Text = $"{removed.DemangledName ?? removed.SymbolName}: removed export"
},
Why = new WhyItMatters
{
Impact = "Export removed",
Severity = "high",
Context = removed.BinaryPath,
Text = $"Export removed from {removed.BinaryPath}; callers will fail"
},
Action = new NextAction
{
Type = "investigate",
ActionText = "Find replacement or update callers",
Text = "Check if symbol was renamed or removed intentionally"
},
Sources = [new ChangeSource { Module = "SymbolDiff", SourceId = diff.DiffId }]
};
cards.Add(card);
}
_logger.LogDebug("Generated {Count} ABI cards", cards.Count);
return cards;
}
private static string ComputeCardId(string prefix, string sourceId)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{prefix}:{sourceId}"));
return $"{prefix}-{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
}
/// <summary>
/// Symbol diff result.
/// </summary>
public sealed record SymbolDiffResult
{
public required string DiffId { get; init; }
public required IReadOnlyList<BreakingSymbolChange> BreakingChanges { get; init; }
public required IReadOnlyList<SymbolInfo> RemovedExports { get; init; }
public required IReadOnlyList<SymbolInfo> AddedExports { get; init; }
}
public sealed record BreakingSymbolChange
{
public required string SymbolName { get; init; }
public string? DemangledName { get; init; }
public required string ChangeType { get; init; }
public string? OldSignature { get; init; }
public string? NewSignature { get; init; }
public required string BinaryPath { get; init; }
}
public sealed record SymbolInfo
{
public required string SymbolName { get; init; }
public string? DemangledName { get; init; }
public required string BinaryPath { get; init; }
}
/// <summary>
/// Provides symbol diff from BinaryIndex.
/// </summary>
public interface ISymbolDiffProvider
{
Task<SymbolDiffResult?> GetSymbolDiffAsync(
string baseSnapshotId,
string targetSnapshotId,
CancellationToken ct);
}
/// <summary>
/// Generates package cards from component diff.
/// </summary>
public sealed class PackageCardGenerator : IPackageCardGenerator
{
private readonly IComponentDiffProvider _componentDiff;
private readonly ILogger<PackageCardGenerator> _logger;
public PackageCardGenerator(
IComponentDiffProvider componentDiff,
ILogger<PackageCardGenerator> logger)
{
_componentDiff = componentDiff;
_logger = logger;
}
public async Task<IReadOnlyList<MaterialChangeCard>> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct)
{
var diff = await _componentDiff.GetComponentDiffAsync(
baseSnapshot.SnapshotId,
targetSnapshot.SnapshotId,
ct);
var cards = new List<MaterialChangeCard>();
foreach (var change in diff.Changes)
{
var priority = change.ChangeType switch
{
"major-upgrade" => 55,
"downgrade" => 60,
"added" => 45,
"removed" => 50,
"minor-upgrade" => 30,
"patch-upgrade" => 20,
_ => 25
};
var card = new MaterialChangeCard
{
CardId = ComputeCardId("pkg", change.Purl),
Category = ChangeCategory.Package,
Scope = ChangeScope.Package,
Priority = priority,
What = new WhatChanged
{
Subject = change.Purl,
SubjectDisplay = change.PackageName,
ChangeType = change.ChangeType,
Before = change.OldVersion,
After = change.NewVersion,
Text = $"{change.PackageName}: {change.OldVersion ?? "(none)"} -> {change.NewVersion ?? "(removed)"}"
},
Why = new WhyItMatters
{
Impact = change.Impact ?? "Dependency change",
Severity = priority >= 50 ? "medium" : "low",
Text = change.ImpactDescription ?? $"Package {change.ChangeType}"
},
Action = new NextAction
{
Type = "review",
ActionText = "Review changelog for breaking changes",
Text = "Check release notes and update tests if needed"
},
Sources = [new ChangeSource { Module = "ComponentDiff", SourceId = diff.DiffId }]
};
cards.Add(card);
}
_logger.LogDebug("Generated {Count} package cards", cards.Count);
return cards;
}
private static string ComputeCardId(string prefix, string sourceId)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{prefix}:{sourceId}"));
return $"{prefix}-{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
}
/// <summary>
/// Component diff result.
/// </summary>
public sealed record ComponentDiffResult
{
public required string DiffId { get; init; }
public required IReadOnlyList<ComponentChange> Changes { get; init; }
}
public sealed record ComponentChange
{
public required string Purl { get; init; }
public required string PackageName { get; init; }
public required string ChangeType { get; init; }
public string? OldVersion { get; init; }
public string? NewVersion { get; init; }
public string? Impact { get; init; }
public string? ImpactDescription { get; init; }
}
/// <summary>
/// Provides component diff from Scanner.Diff.
/// </summary>
public interface IComponentDiffProvider
{
Task<ComponentDiffResult> GetComponentDiffAsync(
string baseSnapshotId,
string targetSnapshotId,
CancellationToken ct);
}
/// <summary>
/// Generates unknown cards from Unknowns module.
/// </summary>
public sealed class UnknownsCardGenerator : IUnknownsCardGenerator
{
private readonly IUnknownsDiffProvider _unknownsDiff;
private readonly ILogger<UnknownsCardGenerator> _logger;
public UnknownsCardGenerator(
IUnknownsDiffProvider unknownsDiff,
ILogger<UnknownsCardGenerator> logger)
{
_unknownsDiff = unknownsDiff;
_logger = logger;
}
public async Task<(IReadOnlyList<MaterialChangeCard>, UnknownsSummary)> GenerateCardsAsync(
SnapshotInfo baseSnapshot,
SnapshotInfo targetSnapshot,
CancellationToken ct)
{
var diff = await _unknownsDiff.GetUnknownsDiffAsync(
baseSnapshot.SnapshotId,
targetSnapshot.SnapshotId,
ct);
var cards = new List<MaterialChangeCard>();
foreach (var unknown in diff.NewUnknowns)
{
var priority = unknown.RiskLevel switch
{
"high" => 65,
"medium" => 45,
_ => 25
};
var card = new MaterialChangeCard
{
CardId = ComputeCardId("unk", unknown.UnknownId),
Category = ChangeCategory.Unknown,
Scope = MapScope(unknown.Kind),
Priority = priority,
What = new WhatChanged
{
Subject = unknown.Subject,
SubjectDisplay = unknown.SubjectDisplay,
ChangeType = "new-unknown",
Text = $"New unknown {unknown.Kind}: {unknown.SubjectDisplay}"
},
Why = new WhyItMatters
{
Impact = "Unknown component",
Severity = unknown.RiskLevel ?? "medium",
Context = unknown.ProvenanceHint,
Text = $"Unknown {unknown.Kind} discovered; {unknown.ProvenanceHint ?? "no provenance hints"}"
},
Action = new NextAction
{
Type = "investigate",
ActionText = unknown.SuggestedAction ?? "Investigate origin and purpose",
Text = unknown.SuggestedAction ?? "Determine if this component is expected"
},
Sources = [new ChangeSource { Module = "Unknowns", SourceId = unknown.UnknownId }],
RelatedUnknowns = [new RelatedUnknown
{
UnknownId = unknown.UnknownId,
Kind = unknown.Kind,
Hint = unknown.ProvenanceHint
}]
};
cards.Add(card);
}
var summary = new UnknownsSummary
{
Total = diff.TotalUnknowns,
New = diff.NewUnknowns.Count,
Resolved = diff.ResolvedUnknowns.Count,
ByKind = diff.NewUnknowns
.GroupBy(u => u.Kind)
.ToDictionary(g => g.Key, g => g.Count())
};
_logger.LogDebug("Generated {Count} unknown cards", cards.Count);
return (cards, summary);
}
private static ChangeScope MapScope(string kind) => kind switch
{
"binary" => ChangeScope.File,
"package" => ChangeScope.Package,
"symbol" => ChangeScope.Symbol,
_ => ChangeScope.File
};
private static string ComputeCardId(string prefix, string sourceId)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{prefix}:{sourceId}"));
return $"{prefix}-{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
}
/// <summary>
/// Unknown item from Unknowns module.
/// </summary>
public sealed record UnknownItem
{
public required string UnknownId { get; init; }
public required string Kind { get; init; }
public required string Subject { get; init; }
public required string SubjectDisplay { get; init; }
public string? RiskLevel { get; init; }
public string? ProvenanceHint { get; init; }
public string? SuggestedAction { get; init; }
}
/// <summary>
/// Unknowns diff result.
/// </summary>
public sealed record UnknownsDiffResult
{
public required int TotalUnknowns { get; init; }
public required IReadOnlyList<UnknownItem> NewUnknowns { get; init; }
public required IReadOnlyList<UnknownItem> ResolvedUnknowns { get; init; }
}
/// <summary>
/// Provides unknowns diff from Unknowns module.
/// </summary>
public interface IUnknownsDiffProvider
{
Task<UnknownsDiffResult> GetUnknownsDiffAsync(
string baseSnapshotId,
string targetSnapshotId,
CancellationToken ct);
}

View File

@@ -0,0 +1,68 @@
// -----------------------------------------------------------------------------
// IMaterialChangesOrchestrator.cs
// Sprint: SPRINT_20260106_001_004_LB_material_changes_orchestrator
// Task: MCO-005 - Define IMaterialChangesOrchestrator interface
// Description: Interface for orchestrating material changes from multiple sources
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.MaterialChanges;
/// <summary>
/// Orchestrates material changes from multiple diff sources.
/// </summary>
public interface IMaterialChangesOrchestrator
{
/// <summary>
/// Generate a unified material changes report.
/// </summary>
Task<MaterialChangesReport> GenerateReportAsync(
string baseSnapshotId,
string targetSnapshotId,
MaterialChangesOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Get a single change card by ID.
/// </summary>
Task<MaterialChangeCard?> GetCardAsync(
string reportId,
string cardId,
CancellationToken ct = default);
/// <summary>
/// Filter cards by category and scope.
/// </summary>
Task<IReadOnlyList<MaterialChangeCard>> FilterCardsAsync(
string reportId,
ChangeCategory? category = null,
ChangeScope? scope = null,
int? minPriority = null,
CancellationToken ct = default);
}
/// <summary>
/// Options for material changes generation.
/// </summary>
public sealed record MaterialChangesOptions
{
/// <summary>Include security changes (default: true).</summary>
public bool IncludeSecurity { get; init; } = true;
/// <summary>Include ABI changes (default: true).</summary>
public bool IncludeAbi { get; init; } = true;
/// <summary>Include package changes (default: true).</summary>
public bool IncludePackage { get; init; } = true;
/// <summary>Include file changes (default: true).</summary>
public bool IncludeFile { get; init; } = true;
/// <summary>Include unknowns (default: true).</summary>
public bool IncludeUnknowns { get; init; } = true;
/// <summary>Minimum priority to include (0-100, default: 0).</summary>
public int MinPriority { get; init; } = 0;
/// <summary>Maximum number of cards to return (default: 100).</summary>
public int MaxCards { get; init; } = 100;
}

View File

@@ -0,0 +1,263 @@
// -----------------------------------------------------------------------------
// MaterialChangesOrchestrator.cs
// Sprint: SPRINT_20260106_001_004_LB_material_changes_orchestrator
// Tasks: MCO-006 to MCO-013 - Implement MaterialChangesOrchestrator
// Description: Orchestrates material changes from multiple diff sources
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.MaterialChanges;
/// <summary>
/// Orchestrates material changes from multiple diff sources.
/// </summary>
public sealed class MaterialChangesOrchestrator : IMaterialChangesOrchestrator
{
private readonly ISecurityCardGenerator _securityGenerator;
private readonly IAbiCardGenerator _abiGenerator;
private readonly IPackageCardGenerator _packageGenerator;
private readonly IUnknownsCardGenerator _unknownsGenerator;
private readonly ISnapshotProvider _snapshotProvider;
private readonly IReportCache _reportCache;
private readonly TimeProvider _timeProvider;
private readonly ILogger<MaterialChangesOrchestrator> _logger;
public MaterialChangesOrchestrator(
ISecurityCardGenerator securityGenerator,
IAbiCardGenerator abiGenerator,
IPackageCardGenerator packageGenerator,
IUnknownsCardGenerator unknownsGenerator,
ISnapshotProvider snapshotProvider,
IReportCache reportCache,
TimeProvider timeProvider,
ILogger<MaterialChangesOrchestrator> logger)
{
_securityGenerator = securityGenerator;
_abiGenerator = abiGenerator;
_packageGenerator = packageGenerator;
_unknownsGenerator = unknownsGenerator;
_snapshotProvider = snapshotProvider;
_reportCache = reportCache;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<MaterialChangesReport> GenerateReportAsync(
string baseSnapshotId,
string targetSnapshotId,
MaterialChangesOptions? options = null,
CancellationToken ct = default)
{
options ??= new MaterialChangesOptions();
_logger.LogInformation(
"Generating material changes report: {Base} -> {Target}",
baseSnapshotId, targetSnapshotId);
var baseSnapshot = await _snapshotProvider.GetSnapshotAsync(baseSnapshotId, ct)
?? throw new ArgumentException($"Base snapshot not found: {baseSnapshotId}");
var targetSnapshot = await _snapshotProvider.GetSnapshotAsync(targetSnapshotId, ct)
?? throw new ArgumentException($"Target snapshot not found: {targetSnapshotId}");
var allCards = new List<MaterialChangeCard>();
var inputDigests = new ReportInputDigests
{
BaseSbomDigest = baseSnapshot.SbomDigest,
TargetSbomDigest = targetSnapshot.SbomDigest
};
// Generate cards from each source in parallel
var securityTask = options.IncludeSecurity
? _securityGenerator.GenerateCardsAsync(baseSnapshot, targetSnapshot, ct)
: Task.FromResult<IReadOnlyList<MaterialChangeCard>>([]);
var abiTask = options.IncludeAbi
? _abiGenerator.GenerateCardsAsync(baseSnapshot, targetSnapshot, ct)
: Task.FromResult<IReadOnlyList<MaterialChangeCard>>([]);
var packageTask = options.IncludePackage
? _packageGenerator.GenerateCardsAsync(baseSnapshot, targetSnapshot, ct)
: Task.FromResult<IReadOnlyList<MaterialChangeCard>>([]);
var unknownsTask = options.IncludeUnknowns
? _unknownsGenerator.GenerateCardsAsync(baseSnapshot, targetSnapshot, ct)
: Task.FromResult<(IReadOnlyList<MaterialChangeCard>, UnknownsSummary)>(
([], new UnknownsSummary { Total = 0, New = 0, Resolved = 0 }));
await Task.WhenAll(securityTask, abiTask, packageTask, unknownsTask);
allCards.AddRange(await securityTask);
allCards.AddRange(await abiTask);
allCards.AddRange(await packageTask);
var (unknownCards, unknownsSummary) = await unknownsTask;
allCards.AddRange(unknownCards);
// Filter by priority
var filteredCards = allCards
.Where(c => c.Priority >= options.MinPriority)
.OrderByDescending(c => c.Priority)
.ThenBy(c => c.Category)
.Take(options.MaxCards)
.ToList();
// Compute summary
var summary = ComputeSummary(filteredCards);
// Compute content-addressed report ID
var reportId = ComputeReportId(baseSnapshotId, targetSnapshotId, filteredCards);
var report = new MaterialChangesReport
{
ReportId = reportId,
Base = ToSnapshotReference(baseSnapshot),
Target = ToSnapshotReference(targetSnapshot),
Changes = filteredCards,
Summary = summary,
Unknowns = unknownsSummary,
GeneratedAt = _timeProvider.GetUtcNow(),
InputDigests = inputDigests
};
// Cache the report
await _reportCache.StoreAsync(report, ct);
_logger.LogInformation(
"Generated material changes report {ReportId}: {CardCount} cards",
reportId, filteredCards.Count);
return report;
}
/// <inheritdoc />
public async Task<MaterialChangeCard?> GetCardAsync(
string reportId,
string cardId,
CancellationToken ct = default)
{
var report = await _reportCache.GetAsync(reportId, ct);
if (report is null) return null;
return report.Changes.FirstOrDefault(c => c.CardId == cardId);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MaterialChangeCard>> FilterCardsAsync(
string reportId,
ChangeCategory? category = null,
ChangeScope? scope = null,
int? minPriority = null,
CancellationToken ct = default)
{
var report = await _reportCache.GetAsync(reportId, ct);
if (report is null) return [];
IEnumerable<MaterialChangeCard> cards = report.Changes;
if (category.HasValue)
cards = cards.Where(c => c.Category == category.Value);
if (scope.HasValue)
cards = cards.Where(c => c.Scope == scope.Value);
if (minPriority.HasValue)
cards = cards.Where(c => c.Priority >= minPriority.Value);
return cards.ToList();
}
private static ChangesSummary ComputeSummary(IReadOnlyList<MaterialChangeCard> cards)
{
var byCategory = cards
.GroupBy(c => c.Category)
.ToDictionary(g => g.Key, g => g.Count());
var byScope = cards
.GroupBy(c => c.Scope)
.ToDictionary(g => g.Key, g => g.Count());
var byPriority = new PrioritySummary
{
Critical = cards.Count(c => c.Priority >= 90),
High = cards.Count(c => c.Priority >= 70 && c.Priority < 90),
Medium = cards.Count(c => c.Priority >= 40 && c.Priority < 70),
Low = cards.Count(c => c.Priority < 40)
};
return new ChangesSummary
{
Total = cards.Count,
ByCategory = byCategory,
ByScope = byScope,
ByPriority = byPriority
};
}
private static string ComputeReportId(
string baseSnapshotId,
string targetSnapshotId,
IReadOnlyList<MaterialChangeCard> cards)
{
var sb = new StringBuilder();
sb.Append(baseSnapshotId);
sb.Append('|');
sb.Append(targetSnapshotId);
sb.Append('|');
foreach (var card in cards.OrderBy(c => c.CardId))
{
sb.Append(card.CardId);
sb.Append(';');
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static SnapshotReference ToSnapshotReference(SnapshotInfo snapshot)
{
return new SnapshotReference
{
SnapshotId = snapshot.SnapshotId,
ArtifactDigest = snapshot.ArtifactDigest,
ArtifactName = snapshot.ArtifactName,
ScannedAt = snapshot.ScannedAt
};
}
}
/// <summary>
/// Snapshot information for the orchestrator.
/// </summary>
public sealed record SnapshotInfo
{
public required string SnapshotId { get; init; }
public required string ArtifactDigest { get; init; }
public string? ArtifactName { get; init; }
public required DateTimeOffset ScannedAt { get; init; }
public required string SbomDigest { get; init; }
}
/// <summary>
/// Provides snapshot information.
/// </summary>
public interface ISnapshotProvider
{
Task<SnapshotInfo?> GetSnapshotAsync(string snapshotId, CancellationToken ct);
}
/// <summary>
/// Cache for material changes reports.
/// </summary>
public interface IReportCache
{
Task StoreAsync(MaterialChangesReport report, CancellationToken ct);
Task<MaterialChangesReport?> GetAsync(string reportId, CancellationToken ct);
}

View File

@@ -0,0 +1,306 @@
// -----------------------------------------------------------------------------
// MaterialChangesReport.cs
// Sprint: SPRINT_20260106_001_004_LB_material_changes_orchestrator
// Tasks: MCO-002, MCO-003, MCO-004 - Define MaterialChangesReport and related records
// Description: Unified material changes report combining all diff sources
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.MaterialChanges;
/// <summary>
/// Unified material changes report combining all diff sources.
/// </summary>
public sealed record MaterialChangesReport
{
/// <summary>Content-addressed report ID.</summary>
[JsonPropertyName("report_id")]
public required string ReportId { get; init; }
/// <summary>Report schema version.</summary>
[JsonPropertyName("schema_version")]
public string SchemaVersion { get; init; } = "1.0";
/// <summary>Base snapshot reference.</summary>
[JsonPropertyName("base")]
public required SnapshotReference Base { get; init; }
/// <summary>Target snapshot reference.</summary>
[JsonPropertyName("target")]
public required SnapshotReference Target { get; init; }
/// <summary>All material changes as compact cards.</summary>
[JsonPropertyName("changes")]
public required IReadOnlyList<MaterialChangeCard> Changes { get; init; }
/// <summary>Summary counts by category.</summary>
[JsonPropertyName("summary")]
public required ChangesSummary Summary { get; init; }
/// <summary>Unknowns encountered during analysis.</summary>
[JsonPropertyName("unknowns")]
public required UnknownsSummary Unknowns { get; init; }
/// <summary>When this report was generated (UTC).</summary>
[JsonPropertyName("generated_at")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Input digests for reproducibility.</summary>
[JsonPropertyName("input_digests")]
public required ReportInputDigests InputDigests { get; init; }
}
/// <summary>Reference to a scan snapshot.</summary>
public sealed record SnapshotReference
{
[JsonPropertyName("snapshot_id")]
public required string SnapshotId { get; init; }
[JsonPropertyName("artifact_digest")]
public required string ArtifactDigest { get; init; }
[JsonPropertyName("artifact_name")]
public string? ArtifactName { get; init; }
[JsonPropertyName("scanned_at")]
public required DateTimeOffset ScannedAt { get; init; }
}
/// <summary>
/// A compact card representing a single material change.
/// Format: what changed -> why it matters -> next action
/// </summary>
public sealed record MaterialChangeCard
{
/// <summary>Unique card ID within the report.</summary>
[JsonPropertyName("card_id")]
public required string CardId { get; init; }
/// <summary>Category of change.</summary>
[JsonPropertyName("category")]
public required ChangeCategory Category { get; init; }
/// <summary>Scope: package, file, symbol, or layer.</summary>
[JsonPropertyName("scope")]
public required ChangeScope Scope { get; init; }
/// <summary>Priority score (0-100, higher = more urgent).</summary>
[JsonPropertyName("priority")]
public required int Priority { get; init; }
/// <summary>What changed (first line).</summary>
[JsonPropertyName("what")]
public required WhatChanged What { get; init; }
/// <summary>Why it matters (second line).</summary>
[JsonPropertyName("why")]
public required WhyItMatters Why { get; init; }
/// <summary>Recommended next action (third line).</summary>
[JsonPropertyName("action")]
public required NextAction Action { get; init; }
/// <summary>Source modules that contributed to this card.</summary>
[JsonPropertyName("sources")]
public required IReadOnlyList<ChangeSource> Sources { get; init; }
/// <summary>Related CVEs (if applicable).</summary>
[JsonPropertyName("cves")]
public IReadOnlyList<string>? Cves { get; init; }
/// <summary>Unknown items related to this change.</summary>
[JsonPropertyName("related_unknowns")]
public IReadOnlyList<RelatedUnknown>? RelatedUnknowns { get; init; }
}
/// <summary>Category of change.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChangeCategory
{
/// <summary>Security-relevant change (CVE, VEX, reachability).</summary>
Security,
/// <summary>ABI/symbol change that may affect compatibility.</summary>
Abi,
/// <summary>Package version or dependency change.</summary>
Package,
/// <summary>File content change.</summary>
File,
/// <summary>Unknown or ambiguous change.</summary>
Unknown
}
/// <summary>Scope of change.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChangeScope
{
Package,
File,
Symbol,
Layer
}
/// <summary>What changed (the subject of the change).</summary>
public sealed record WhatChanged
{
/// <summary>Subject identifier (PURL, path, symbol name).</summary>
[JsonPropertyName("subject")]
public required string Subject { get; init; }
/// <summary>Human-readable subject name.</summary>
[JsonPropertyName("subject_display")]
public required string SubjectDisplay { get; init; }
/// <summary>Type of change.</summary>
[JsonPropertyName("change_type")]
public required string ChangeType { get; init; }
/// <summary>Before value (if applicable).</summary>
[JsonPropertyName("before")]
public string? Before { get; init; }
/// <summary>After value (if applicable).</summary>
[JsonPropertyName("after")]
public string? After { get; init; }
/// <summary>Rendered text for display.</summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>Why this change matters.</summary>
public sealed record WhyItMatters
{
/// <summary>Impact category.</summary>
[JsonPropertyName("impact")]
public required string Impact { get; init; }
/// <summary>Severity level.</summary>
[JsonPropertyName("severity")]
public required string Severity { get; init; }
/// <summary>Additional context (CVE link, ABI breaking, etc.).</summary>
[JsonPropertyName("context")]
public string? Context { get; init; }
/// <summary>Rendered text for display.</summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>Recommended next action.</summary>
public sealed record NextAction
{
/// <summary>Action type: review, upgrade, investigate, accept, etc.</summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>Specific action to take.</summary>
[JsonPropertyName("action")]
public required string ActionText { get; init; }
/// <summary>Link to more information (KB article, advisory, etc.).</summary>
[JsonPropertyName("link")]
public string? Link { get; init; }
/// <summary>Rendered text for display.</summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>Source module that contributed to the change.</summary>
public sealed record ChangeSource
{
[JsonPropertyName("module")]
public required string Module { get; init; }
[JsonPropertyName("source_id")]
public required string SourceId { get; init; }
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
}
/// <summary>Related unknown item.</summary>
public sealed record RelatedUnknown
{
[JsonPropertyName("unknown_id")]
public required string UnknownId { get; init; }
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("hint")]
public string? Hint { get; init; }
}
/// <summary>Summary of changes by category.</summary>
public sealed record ChangesSummary
{
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("by_category")]
public required IReadOnlyDictionary<ChangeCategory, int> ByCategory { get; init; }
[JsonPropertyName("by_scope")]
public required IReadOnlyDictionary<ChangeScope, int> ByScope { get; init; }
[JsonPropertyName("by_priority")]
public required PrioritySummary ByPriority { get; init; }
}
/// <summary>Priority breakdown.</summary>
public sealed record PrioritySummary
{
[JsonPropertyName("critical")]
public int Critical { get; init; }
[JsonPropertyName("high")]
public int High { get; init; }
[JsonPropertyName("medium")]
public int Medium { get; init; }
[JsonPropertyName("low")]
public int Low { get; init; }
}
/// <summary>Unknowns summary for the report.</summary>
public sealed record UnknownsSummary
{
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("new")]
public int New { get; init; }
[JsonPropertyName("resolved")]
public int Resolved { get; init; }
[JsonPropertyName("by_kind")]
public IReadOnlyDictionary<string, int>? ByKind { get; init; }
}
/// <summary>Input digests for reproducibility.</summary>
public sealed record ReportInputDigests
{
[JsonPropertyName("base_sbom_digest")]
public required string BaseSbomDigest { get; init; }
[JsonPropertyName("target_sbom_digest")]
public required string TargetSbomDigest { get; init; }
[JsonPropertyName("smart_diff_digest")]
public string? SmartDiffDigest { get; init; }
[JsonPropertyName("symbol_diff_digest")]
public string? SymbolDiffDigest { get; init; }
[JsonPropertyName("unknowns_digest")]
public string? UnknownsDigest { get; init; }
}

View File

@@ -0,0 +1,82 @@
// -----------------------------------------------------------------------------
// MaterialChangesServiceExtensions.cs
// Sprint: SPRINT_20260106_001_004_LB_material_changes_orchestrator
// Task: MCO-015 - Add service registration extensions
// Description: DI registration for material changes orchestrator
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Scanner.MaterialChanges;
/// <summary>
/// Service collection extensions for material changes orchestrator.
/// </summary>
public static class MaterialChangesServiceExtensions
{
/// <summary>
/// Adds material changes orchestrator and related services.
/// </summary>
public static IServiceCollection AddMaterialChangesOrchestrator(this IServiceCollection services)
{
// Core orchestrator
services.AddSingleton<IMaterialChangesOrchestrator, MaterialChangesOrchestrator>();
// Card generators
services.AddSingleton<ISecurityCardGenerator, SecurityCardGenerator>();
services.AddSingleton<IAbiCardGenerator, AbiCardGenerator>();
services.AddSingleton<IPackageCardGenerator, PackageCardGenerator>();
services.AddSingleton<IUnknownsCardGenerator, UnknownsCardGenerator>();
// Cache
services.AddSingleton<IReportCache, InMemoryReportCache>();
return services;
}
/// <summary>
/// Adds custom snapshot provider.
/// </summary>
public static IServiceCollection AddSnapshotProvider<TProvider>(this IServiceCollection services)
where TProvider : class, ISnapshotProvider
{
services.AddSingleton<ISnapshotProvider, TProvider>();
return services;
}
/// <summary>
/// Adds custom report cache.
/// </summary>
public static IServiceCollection AddReportCache<TCache>(this IServiceCollection services)
where TCache : class, IReportCache
{
services.AddSingleton<IReportCache, TCache>();
return services;
}
}
/// <summary>
/// In-memory report cache for development and testing.
/// </summary>
public sealed class InMemoryReportCache : IReportCache
{
private readonly Dictionary<string, MaterialChangesReport> _cache = new();
private readonly object _lock = new();
public Task StoreAsync(MaterialChangesReport report, CancellationToken ct)
{
lock (_lock)
{
_cache[report.ReportId] = report;
}
return Task.CompletedTask;
}
public Task<MaterialChangesReport?> GetAsync(string reportId, CancellationToken ct)
{
lock (_lock)
{
return Task.FromResult(_cache.GetValueOrDefault(reportId));
}
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Scanner.MaterialChanges</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Diff\StellaOps.Scanner.Diff.csproj" />
<ProjectReference Include="..\..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Builders\StellaOps.BinaryIndex.Builders.csproj" />
<ProjectReference Include="..\..\..\..\Unknowns\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -14,13 +14,16 @@ public sealed class ProofAwareVexGenerator
{
private readonly ILogger<ProofAwareVexGenerator> _logger;
private readonly BackportProofService _proofService;
private readonly TimeProvider _timeProvider;
public ProofAwareVexGenerator(
ILogger<ProofAwareVexGenerator> logger,
BackportProofService proofService)
BackportProofService proofService,
TimeProvider? timeProvider = null)
{
_logger = logger;
_proofService = proofService;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -74,7 +77,7 @@ public sealed class ProofAwareVexGenerator
Statement = statement,
ProofPayload = proofPayload,
Proof = proof,
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = _timeProvider.GetUtcNow()
};
}
@@ -135,7 +138,7 @@ public sealed class ProofAwareVexGenerator
Statement = statement,
ProofPayload = proofPayload,
Proof = unknownProof,
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -12,9 +12,9 @@ Deliver deterministic reachability analysis, slice generation, and evidence arti
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/scanner/architecture.md`
- `docs/reachability/DELIVERY_GUIDE.md`
- `docs/reachability/slice-schema.md`
- `docs/reachability/replay-verification.md`
- `docs/modules/reach-graph/guides/DELIVERY_GUIDE.md`
- `docs/modules/reach-graph/guides/slice-schema.md`
- `docs/modules/reach-graph/guides/replay-verification.md`
## Working Directory & Boundaries
- Primary scope: `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/`

View File

@@ -18,7 +18,17 @@ public sealed record BoundaryExtractionContext
/// <summary>
/// Empty context for simple extractions.
/// </summary>
public static readonly BoundaryExtractionContext Empty = new();
/// <remarks>Uses system time. For deterministic timestamps, use <see cref="CreateEmpty"/>.</remarks>
[Obsolete("Use CreateEmpty(TimeProvider) for deterministic timestamps")]
public static BoundaryExtractionContext Empty => CreateEmpty();
/// <summary>
/// Creates an empty context for simple extractions.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
/// <returns>An empty boundary extraction context.</returns>
public static BoundaryExtractionContext CreateEmpty(TimeProvider? timeProvider = null) =>
new() { Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow() };
/// <summary>
/// Environment identifier (e.g., "production", "staging").
@@ -53,7 +63,7 @@ public sealed record BoundaryExtractionContext
public string? NetworkZone { get; init; }
/// <summary>
/// Known port bindings (port protocol).
/// Known port bindings (port to protocol).
/// </summary>
public IReadOnlyDictionary<int, string> PortBindings { get; init; } =
new Dictionary<int, string>();
@@ -61,7 +71,7 @@ public sealed record BoundaryExtractionContext
/// <summary>
/// Timestamp for the context (for cache invalidation).
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Source of this context (e.g., "k8s", "iac", "runtime").
@@ -71,20 +81,28 @@ public sealed record BoundaryExtractionContext
/// <summary>
/// Creates a context from detected gates.
/// </summary>
public static BoundaryExtractionContext FromGates(IReadOnlyList<DetectedGate> gates) =>
new() { DetectedGates = gates };
/// <param name="gates">The detected gates.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public static BoundaryExtractionContext FromGates(IReadOnlyList<DetectedGate> gates, TimeProvider? timeProvider = null) =>
new() { DetectedGates = gates, Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow() };
/// <summary>
/// Creates a context with environment hints.
/// </summary>
/// <param name="environmentId">The environment identifier.</param>
/// <param name="isInternetFacing">Whether the service is internet-facing.</param>
/// <param name="networkZone">The network zone.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public static BoundaryExtractionContext ForEnvironment(
string environmentId,
bool? isInternetFacing = null,
string? networkZone = null) =>
string? networkZone = null,
TimeProvider? timeProvider = null) =>
new()
{
EnvironmentId = environmentId,
IsInternetFacing = isInternetFacing,
NetworkZone = networkZone
NetworkZone = networkZone,
Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow()
};
}

View File

@@ -86,22 +86,22 @@ public sealed record GraphDelta
AddedEdges.Count > 0 || RemovedEdges.Count > 0;
/// <summary>
/// Nodes added in current graph (ΔV+).
/// Nodes added in current graph (delta V+).
/// </summary>
public IReadOnlySet<string> AddedNodes { get; init; } = new HashSet<string>();
/// <summary>
/// Nodes removed from previous graph (ΔV-).
/// Nodes removed from previous graph (delta V-).
/// </summary>
public IReadOnlySet<string> RemovedNodes { get; init; } = new HashSet<string>();
/// <summary>
/// Edges added in current graph (ΔE+).
/// Edges added in current graph (delta E+).
/// </summary>
public IReadOnlyList<GraphEdge> AddedEdges { get; init; } = [];
/// <summary>
/// Edges removed from previous graph (ΔE-).
/// Edges removed from previous graph (delta E-).
/// </summary>
public IReadOnlyList<GraphEdge> RemovedEdges { get; init; } = [];

View File

@@ -123,19 +123,22 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer
private readonly IImpactSetCalculator _impactCalculator;
private readonly IStateFlipDetector _stateFlipDetector;
private readonly ILogger<IncrementalReachabilityService> _logger;
private readonly TimeProvider _timeProvider;
public IncrementalReachabilityService(
IReachabilityCache cache,
IGraphDeltaComputer deltaComputer,
IImpactSetCalculator impactCalculator,
IStateFlipDetector stateFlipDetector,
ILogger<IncrementalReachabilityService> logger)
ILogger<IncrementalReachabilityService> logger,
TimeProvider? timeProvider = null)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_deltaComputer = deltaComputer ?? throw new ArgumentNullException(nameof(deltaComputer));
_impactCalculator = impactCalculator ?? throw new ArgumentNullException(nameof(impactCalculator));
_stateFlipDetector = stateFlipDetector ?? throw new ArgumentNullException(nameof(stateFlipDetector));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -265,7 +268,7 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer
private List<ReachablePairResult> ComputeFullReachability(IncrementalReachabilityRequest request)
{
var results = new List<ReachablePairResult>();
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
// Build forward adjacency for BFS
var adj = new Dictionary<string, List<string>>();
@@ -323,7 +326,7 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer
CancellationToken cancellationToken)
{
var results = new Dictionary<(string, string), ReachablePairResult>();
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
// Copy unaffected results from previous
foreach (var prev in previousResults)

View File

@@ -21,13 +21,16 @@ public sealed class PostgresReachabilityCache : IReachabilityCache
{
private readonly string _connectionString;
private readonly ILogger<PostgresReachabilityCache> _logger;
private readonly TimeProvider _timeProvider;
public PostgresReachabilityCache(
string connectionString,
ILogger<PostgresReachabilityCache> logger)
ILogger<PostgresReachabilityCache> logger,
TimeProvider? timeProvider = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -102,7 +105,7 @@ public sealed class PostgresReachabilityCache : IReachabilityCache
ServiceId = serviceId,
GraphHash = graphHash,
CachedAt = cachedAt,
TimeToLive = expiresAt.HasValue ? expiresAt.Value - DateTimeOffset.UtcNow : null,
TimeToLive = expiresAt.HasValue ? expiresAt.Value - _timeProvider.GetUtcNow() : null,
ReachablePairs = pairs,
EntryPointCount = entryPointCount,
SinkCount = sinkCount
@@ -143,7 +146,7 @@ public sealed class PostgresReachabilityCache : IReachabilityCache
}
var expiresAt = entry.TimeToLive.HasValue
? (object)DateTimeOffset.UtcNow.Add(entry.TimeToLive.Value)
? (object)_timeProvider.GetUtcNow().Add(entry.TimeToLive.Value)
: DBNull.Value;
const string insertEntrySql = """

View File

@@ -396,7 +396,7 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
{
Level = PrAnnotationLevel.Error,
Title = "New Reachable Vulnerability Path",
Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} {flip.SinkMethodKey}",
Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} -> {flip.SinkMethodKey}",
FilePath = flip.SourceFile,
StartLine = flip.StartLine,
EndLine = flip.EndLine
@@ -440,7 +440,7 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
foreach (var flip in decision.BlockingFlips.Take(10))
{
sb.AppendLine($"- `{flip.EntryMethodKey}` `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
}
if (decision.BlockingFlips.Count > 10)

View File

@@ -225,8 +225,9 @@ public sealed class EdgeBundleBuilder
return this;
}
public EdgeBundle Build()
public EdgeBundle Build(TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
var canonical = _edges
.Select(e => e.Trimmed())
.OrderBy(e => e.From, StringComparer.Ordinal)
@@ -241,7 +242,7 @@ public sealed class EdgeBundleBuilder
GraphHash: _graphHash,
BundleReason: _bundleReason,
Edges: canonical,
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: tp.GetUtcNow(),
CustomReason: _customReason);
}

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