Merge remote changes (theirs)
This commit is contained in:
@@ -13,7 +13,7 @@ Provide advisory feed integration and offline bundles for CVE-to-symbol mapping
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/modules/concelier/architecture.md`
|
||||
- `docs/reachability/slice-schema.md`
|
||||
- `docs/modules/reach-graph/guides/slice-schema.md`
|
||||
|
||||
## Working Directory & Boundaries
|
||||
- Primary scope: `src/Scanner/__Libraries/StellaOps.Scanner.Advisory/`
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class AdvisoryClient : IAdvisoryClient
|
||||
|
||||
var normalized = cveId.Trim().ToUpperInvariant();
|
||||
var cacheKey = $"advisory:cve:{normalized}";
|
||||
if (_cache.TryGetValue(cacheKey, out AdvisorySymbolMapping cached))
|
||||
if (_cache.TryGetValue(cacheKey, out AdvisorySymbolMapping? cached) && cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,20 @@ internal sealed class DenoRuntimeTraceRecorder
|
||||
{
|
||||
private readonly List<DenoRuntimeEvent> _events = new();
|
||||
private readonly string _rootPath;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DenoRuntimeTraceRecorder(string rootPath)
|
||||
public DenoRuntimeTraceRecorder(string rootPath, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
_rootPath = Path.GetFullPath(rootPath);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public void AddModuleLoad(string absoluteModulePath, string reason, IEnumerable<string> permissions, string? origin = null, DateTimeOffset? timestamp = null)
|
||||
{
|
||||
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
|
||||
var evt = new DenoModuleLoadEvent(
|
||||
Ts: timestamp ?? DateTimeOffset.UtcNow,
|
||||
Ts: timestamp ?? _timeProvider.GetUtcNow(),
|
||||
Module: identity,
|
||||
Reason: reason ?? string.Empty,
|
||||
Permissions: NormalizePermissions(permissions),
|
||||
@@ -32,7 +34,7 @@ internal sealed class DenoRuntimeTraceRecorder
|
||||
{
|
||||
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
|
||||
var evt = new DenoPermissionUseEvent(
|
||||
Ts: timestamp ?? DateTimeOffset.UtcNow,
|
||||
Ts: timestamp ?? _timeProvider.GetUtcNow(),
|
||||
Permission: permission ?? string.Empty,
|
||||
Module: identity,
|
||||
Details: details ?? string.Empty);
|
||||
@@ -42,7 +44,7 @@ internal sealed class DenoRuntimeTraceRecorder
|
||||
public void AddNpmResolution(string specifier, string package, string version, string resolved, bool exists, DateTimeOffset? timestamp = null)
|
||||
{
|
||||
_events.Add(new DenoNpmResolutionEvent(
|
||||
Ts: timestamp ?? DateTimeOffset.UtcNow,
|
||||
Ts: timestamp ?? _timeProvider.GetUtcNow(),
|
||||
Specifier: specifier ?? string.Empty,
|
||||
Package: package ?? string.Empty,
|
||||
Version: version ?? string.Empty,
|
||||
@@ -54,7 +56,7 @@ internal sealed class DenoRuntimeTraceRecorder
|
||||
{
|
||||
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
|
||||
_events.Add(new DenoWasmLoadEvent(
|
||||
Ts: timestamp ?? DateTimeOffset.UtcNow,
|
||||
Ts: timestamp ?? _timeProvider.GetUtcNow(),
|
||||
Module: identity,
|
||||
Importer: importerRelativePath ?? string.Empty,
|
||||
Reason: reason ?? string.Empty));
|
||||
|
||||
@@ -19,12 +19,14 @@ internal sealed class DotNetCallgraphBuilder
|
||||
private readonly Dictionary<string, string> _typeToAssemblyPath = new();
|
||||
private readonly Dictionary<string, string?> _assemblyToPurl = new();
|
||||
private readonly string _contextDigest;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private int _assemblyCount;
|
||||
private int _typeCount;
|
||||
|
||||
public DotNetCallgraphBuilder(string contextDigest)
|
||||
public DotNetCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_contextDigest = contextDigest;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -114,7 +116,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
var contentHash = DotNetGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
|
||||
|
||||
var metadata = new DotNetGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratedAt: _timeProvider.GetUtcNow(),
|
||||
GeneratorVersion: DotNetGraphIdentifiers.GetGeneratorVersion(),
|
||||
ContextDigest: _contextDigest,
|
||||
AssemblyCount: _assemblyCount,
|
||||
|
||||
@@ -16,12 +16,14 @@ internal sealed class JavaCallgraphBuilder
|
||||
private readonly List<JavaUnknown> _unknowns = new();
|
||||
private readonly Dictionary<string, string> _classToJarPath = new();
|
||||
private readonly string _contextDigest;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private int _jarCount;
|
||||
private int _classCount;
|
||||
|
||||
public JavaCallgraphBuilder(string contextDigest)
|
||||
public JavaCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_contextDigest = contextDigest;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -177,7 +179,7 @@ internal sealed class JavaCallgraphBuilder
|
||||
var contentHash = JavaGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
|
||||
|
||||
var metadata = new JavaGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratedAt: _timeProvider.GetUtcNow(),
|
||||
GeneratorVersion: JavaGraphIdentifiers.GetGeneratorVersion(),
|
||||
ContextDigest: _contextDigest,
|
||||
JarCount: _jarCount,
|
||||
|
||||
@@ -28,13 +28,14 @@ internal static class JavaEntrypointAocWriter
|
||||
string tenantId,
|
||||
string scanId,
|
||||
Stream outputStream,
|
||||
CancellationToken cancellationToken)
|
||||
TimeProvider? timeProvider = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolution);
|
||||
ArgumentNullException.ThrowIfNull(outputStream);
|
||||
|
||||
using var writer = new StreamWriter(outputStream, Encoding.UTF8, leaveOpen: true);
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
|
||||
// Write header record
|
||||
var header = new AocHeader
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Resolver;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Runtime;
|
||||
@@ -187,7 +188,7 @@ internal static class JavaRuntimeIngestor
|
||||
ResolutionPath: ImmutableArray.Create("runtime-trace"),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("runtime.invocation_count", entry.InvocationCount.ToString())
|
||||
.Add("runtime.first_seen", entry.FirstSeen.ToString("O")));
|
||||
.Add("runtime.first_seen", entry.FirstSeen.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
private static JavaResolutionStatistics RecalculateStatistics(
|
||||
|
||||
@@ -249,7 +249,7 @@ internal static class PythonDistributionLoader
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddFileEvidence(LanguageAnalyzerContext context, string path, string source, ICollection<LanguageComponentEvidence> evidence)
|
||||
private static void AddFileEvidence(LanguageAnalyzerContext context, string? path, string source, ICollection<LanguageComponentEvidence> evidence)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
|
||||
|
||||
@@ -15,7 +16,6 @@ internal sealed class PythonRuntimeEvidenceCollector
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly List<PythonRuntimeEvent> _events = [];
|
||||
private readonly Dictionary<string, string> _pathHashes = new();
|
||||
private readonly HashSet<string> _loadedModules = new(StringComparer.Ordinal);
|
||||
@@ -26,15 +26,6 @@ internal sealed class PythonRuntimeEvidenceCollector
|
||||
private string? _pythonVersion;
|
||||
private string? _platform;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PythonRuntimeEvidenceCollector"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
public PythonRuntimeEvidenceCollector(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JSON line from the runtime evidence output.
|
||||
/// </summary>
|
||||
@@ -399,8 +390,8 @@ internal sealed class PythonRuntimeEvidenceCollector
|
||||
ThreadId: null));
|
||||
}
|
||||
|
||||
private string GetUtcTimestamp()
|
||||
private static string GetUtcTimestamp()
|
||||
{
|
||||
return _timeProvider.GetUtcNow().ToString("O");
|
||||
return DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,20 @@
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Python.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Python.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
|
||||
@@ -15,11 +15,13 @@ internal sealed class NativeCallgraphBuilder
|
||||
private readonly List<NativeUnknown> _unknowns = new();
|
||||
private readonly Dictionary<ulong, string> _addressToSymbolId = new();
|
||||
private readonly string _layerDigest;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private int _binaryCount;
|
||||
|
||||
public NativeCallgraphBuilder(string layerDigest)
|
||||
public NativeCallgraphBuilder(string layerDigest, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_layerDigest = layerDigest;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,7 +82,7 @@ internal sealed class NativeCallgraphBuilder
|
||||
var contentHash = NativeGraphIdentifiers.ComputeGraphHash(functions, edges, roots);
|
||||
|
||||
var metadata = new NativeGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratedAt: _timeProvider.GetUtcNow(),
|
||||
GeneratorVersion: NativeGraphIdentifiers.GetGeneratorVersion(),
|
||||
LayerDigest: _layerDigest,
|
||||
BinaryCount: _binaryCount,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Internal.Graph;
|
||||
|
||||
/// <summary>
|
||||
@@ -26,7 +28,7 @@ internal static class NativeGraphDsseWriter
|
||||
Version: "1.0.0",
|
||||
LayerDigest: graph.LayerDigest,
|
||||
ContentHash: graph.ContentHash,
|
||||
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O"),
|
||||
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
GeneratorVersion: graph.Metadata.GeneratorVersion,
|
||||
BinaryCount: graph.Metadata.BinaryCount,
|
||||
FunctionCount: graph.Metadata.FunctionCount,
|
||||
@@ -126,7 +128,7 @@ internal static class NativeGraphDsseWriter
|
||||
LayerDigest: graph.LayerDigest,
|
||||
ContentHash: graph.ContentHash,
|
||||
Metadata: new NdjsonMetadataPayload(
|
||||
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O"),
|
||||
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
GeneratorVersion: graph.Metadata.GeneratorVersion,
|
||||
BinaryCount: graph.Metadata.BinaryCount,
|
||||
FunctionCount: graph.Metadata.FunctionCount,
|
||||
|
||||
@@ -42,6 +42,11 @@ public sealed class SecretsAnalyzer : ILanguageAnalyzer
|
||||
/// </summary>
|
||||
public SecretRuleset? Ruleset => _ruleset;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ruleset version string for tracking and reporting.
|
||||
/// </summary>
|
||||
public string RulesetVersion => _ruleset?.Version ?? "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Sets the ruleset to use for detection.
|
||||
/// Called by SecretsAnalyzerHost after loading the bundle.
|
||||
@@ -51,6 +56,43 @@ public sealed class SecretsAnalyzer : ILanguageAnalyzer
|
||||
_ruleset = ruleset ?? throw new ArgumentNullException(nameof(ruleset));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes raw file content for secrets. Adapter for Worker stage executor.
|
||||
/// </summary>
|
||||
public async ValueTask<List<SecretLeakEvidence>> AnalyzeAsync(
|
||||
byte[] content,
|
||||
string relativePath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!IsEnabled || content is null || content.Length == 0)
|
||||
{
|
||||
return new List<SecretLeakEvidence>();
|
||||
}
|
||||
|
||||
var findings = new List<SecretLeakEvidence>();
|
||||
|
||||
foreach (var rule in _ruleset!.GetRulesForFile(relativePath))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var matches = await _detector.DetectAsync(content, relativePath, rule, ct);
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var confidence = MapScoreToConfidence(match.ConfidenceScore);
|
||||
if (confidence < _options.Value.MinConfidence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var evidence = SecretLeakEvidence.FromMatch(match, _masker, _ruleset!, _timeProvider);
|
||||
findings.Add(evidence);
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
public async ValueTask AnalyzeAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
|
||||
@@ -192,6 +192,17 @@ public enum ClaimStatus
|
||||
/// </summary>
|
||||
public sealed class BattlecardGenerator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new battlecard generator.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
public BattlecardGenerator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a markdown battlecard from claims and metrics.
|
||||
/// </summary>
|
||||
@@ -201,7 +212,7 @@ public sealed class BattlecardGenerator
|
||||
|
||||
sb.AppendLine("# Stella Ops Scanner - Competitive Battlecard");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*Generated: {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC*");
|
||||
sb.AppendLine($"*Generated: {_timeProvider.GetUtcNow():yyyy-MM-dd HH:mm:ss} UTC*");
|
||||
sb.AppendLine();
|
||||
|
||||
// Key Differentiators
|
||||
|
||||
@@ -8,6 +8,17 @@ namespace StellaOps.Scanner.Benchmark.Metrics;
|
||||
/// </summary>
|
||||
public sealed class MetricsCalculator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new metrics calculator.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
public MetricsCalculator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates metrics for a single image.
|
||||
/// </summary>
|
||||
@@ -49,7 +60,7 @@ public sealed class MetricsCalculator
|
||||
FalsePositives = fp,
|
||||
TrueNegatives = tn,
|
||||
FalseNegatives = fn,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +85,7 @@ public sealed class MetricsCalculator
|
||||
TotalTrueNegatives = totalTn,
|
||||
TotalFalseNegatives = totalFn,
|
||||
PerImageMetrics = perImageMetrics,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ Provide deterministic call graph extraction for supported languages and native b
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/reachability/DELIVERY_GUIDE.md`
|
||||
- `docs/reachability/binary-reachability-schema.md`
|
||||
- `docs/modules/reach-graph/guides/DELIVERY_GUIDE.md`
|
||||
- `docs/modules/reach-graph/guides/binary-reachability-schema.md`
|
||||
|
||||
## Working Directory & Boundaries
|
||||
- Primary scope: `src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/`
|
||||
|
||||
@@ -513,8 +513,10 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
|
||||
var shStrTab = reader.ReadBytes((int)shStrTabSize);
|
||||
|
||||
// Find symbol and string tables for resolving names
|
||||
// Note: symtab/strtab values are captured for future use with static symbols
|
||||
long symtabOffset = 0, strtabOffset = 0;
|
||||
long symtabSize = 0;
|
||||
_ = (symtabOffset, strtabOffset, symtabSize); // Suppress unused warnings
|
||||
int symtabEntrySize = is64Bit ? 24 : 16;
|
||||
|
||||
// Find .dynsym and .dynstr for dynamic relocations
|
||||
|
||||
@@ -54,4 +54,10 @@ public static class ScanAnalysisKeys
|
||||
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
public const string SecretFindings = "analysis.secrets.findings";
|
||||
public const string SecretRulesetVersion = "analysis.secrets.ruleset.version";
|
||||
|
||||
// Sprint: SPRINT_20260106_003_002 - VEX Gate Service
|
||||
public const string VexGateResults = "analysis.vexgate.results";
|
||||
public const string VexGateSummary = "analysis.vexgate.summary";
|
||||
public const string VexGatePolicyVersion = "analysis.vexgate.policy.version";
|
||||
public const string VexGateBypassed = "analysis.vexgate.bypassed";
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ public sealed record EpssEvidence
|
||||
double percentile,
|
||||
DateOnly modelDate,
|
||||
string? source = null,
|
||||
bool fromCache = false)
|
||||
bool fromCache = false,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
return new EpssEvidence
|
||||
{
|
||||
@@ -78,7 +79,7 @@ public sealed record EpssEvidence
|
||||
Score = score,
|
||||
Percentile = percentile,
|
||||
ModelDate = modelDate,
|
||||
CapturedAt = DateTimeOffset.UtcNow,
|
||||
CapturedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(),
|
||||
Source = source,
|
||||
FromCache = fromCache
|
||||
};
|
||||
|
||||
@@ -336,10 +336,6 @@ public sealed class DefaultFalsificationConditionGenerator : IFalsificationCondi
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultFalsificationConditionGenerator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
public DefaultFalsificationConditionGenerator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
@@ -300,10 +300,6 @@ public sealed class ZeroDayWindowCalculator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ZeroDayWindowCalculator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
public ZeroDayWindowCalculator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
@@ -102,11 +102,11 @@ public sealed class ProofBundleWriterOptions
|
||||
/// Default implementation of IProofBundleWriter.
|
||||
/// Creates ZIP bundles with the following structure:
|
||||
/// bundle.zip/
|
||||
/// ├── manifest.json # Canonical JSON scan manifest
|
||||
/// ├── manifest.dsse.json # DSSE envelope for manifest
|
||||
/// ├── score_proof.json # ProofLedger nodes array
|
||||
/// ├── proof_root.dsse.json # DSSE envelope for root hash (optional)
|
||||
/// └── meta.json # Bundle metadata
|
||||
/// manifest.json - Canonical JSON scan manifest
|
||||
/// manifest.dsse.json - DSSE envelope for manifest
|
||||
/// score_proof.json - ProofLedger nodes array
|
||||
/// proof_root.dsse.json - DSSE envelope for root hash (optional)
|
||||
/// meta.json - Bundle metadata
|
||||
/// </summary>
|
||||
public sealed class ProofBundleWriter : IProofBundleWriter
|
||||
{
|
||||
@@ -120,7 +120,7 @@ public sealed class ProofBundleWriter : IProofBundleWriter
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public ProofBundleWriter(TimeProvider? timeProvider = null, ProofBundleWriterOptions? options = null)
|
||||
public ProofBundleWriter(ProofBundleWriterOptions? options = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? new ProofBundleWriterOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace StellaOps.Scanner.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Captures all inputs that affect a scan's results.
|
||||
/// Per advisory "Building a Deeper Moat Beyond Reachability" §12.
|
||||
/// Per advisory "Building a Deeper Moat Beyond Reachability" section 12.
|
||||
/// This manifest ensures reproducibility: same manifest + same seed = same results.
|
||||
/// </summary>
|
||||
/// <param name="ScanId">Unique identifier for this scan run.</param>
|
||||
@@ -55,8 +55,8 @@ public sealed record ScanManifest(
|
||||
/// <summary>
|
||||
/// Create a manifest builder with required fields.
|
||||
/// </summary>
|
||||
public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest) =>
|
||||
new(scanId, artifactDigest);
|
||||
public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null) =>
|
||||
new(scanId, artifactDigest, timeProvider);
|
||||
|
||||
/// <summary>
|
||||
/// Serialize to canonical JSON (for hashing).
|
||||
@@ -99,7 +99,8 @@ public sealed class ScanManifestBuilder
|
||||
{
|
||||
private readonly string _scanId;
|
||||
private readonly string _artifactDigest;
|
||||
private DateTimeOffset _createdAtUtc = DateTimeOffset.UtcNow;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private DateTimeOffset? _createdAtUtc;
|
||||
private string? _artifactPurl;
|
||||
private string _scannerVersion = "1.0.0";
|
||||
private string _workerVersion = "1.0.0";
|
||||
@@ -110,10 +111,11 @@ public sealed class ScanManifestBuilder
|
||||
private byte[] _seed = new byte[32];
|
||||
private readonly Dictionary<string, string> _knobs = [];
|
||||
|
||||
internal ScanManifestBuilder(string scanId, string artifactDigest)
|
||||
internal ScanManifestBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_scanId = scanId ?? throw new ArgumentNullException(nameof(scanId));
|
||||
_artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public ScanManifestBuilder WithCreatedAt(DateTimeOffset createdAtUtc)
|
||||
@@ -187,7 +189,7 @@ public sealed class ScanManifestBuilder
|
||||
|
||||
public ScanManifest Build() => new(
|
||||
ScanId: _scanId,
|
||||
CreatedAtUtc: _createdAtUtc,
|
||||
CreatedAtUtc: _createdAtUtc ?? _timeProvider.GetUtcNow(),
|
||||
ArtifactDigest: _artifactDigest,
|
||||
ArtifactPurl: _artifactPurl,
|
||||
ScannerVersion: _scannerVersion,
|
||||
|
||||
@@ -77,11 +77,11 @@ public sealed record ManifestVerificationResult(
|
||||
string? ErrorMessage = null,
|
||||
string? KeyId = null)
|
||||
{
|
||||
public static ManifestVerificationResult Success(ScanManifest manifest, DateTimeOffset verifiedAt, string? keyId = null) =>
|
||||
new(true, manifest, verifiedAt, null, keyId);
|
||||
public static ManifestVerificationResult Success(ScanManifest manifest, string? keyId = null, TimeProvider? timeProvider = null) =>
|
||||
new(true, manifest, (timeProvider ?? TimeProvider.System).GetUtcNow(), null, keyId);
|
||||
|
||||
public static ManifestVerificationResult Failure(DateTimeOffset verifiedAt, string error) =>
|
||||
new(false, null, verifiedAt, error);
|
||||
public static ManifestVerificationResult Failure(string error, TimeProvider? timeProvider = null) =>
|
||||
new(false, null, (timeProvider ?? TimeProvider.System).GetUtcNow(), error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}" };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
26
src/Scanner/__Libraries/StellaOps.Scanner.Gate/AGENTS.md
Normal file
26
src/Scanner/__Libraries/StellaOps.Scanner.Gate/AGENTS.md
Normal 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.
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
379
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateOptions.cs
Normal file
379
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateOptions.cs
Normal 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;
|
||||
}
|
||||
201
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGatePolicy.cs
Normal file
201
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGatePolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
144
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateResult.cs
Normal file
144
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateResult.cs
Normal 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; }
|
||||
}
|
||||
249
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateService.cs
Normal file
249
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateService.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
78
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexTypes.cs
Normal file
78
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexTypes.cs
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user