Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET. - Added `all-visibility-levels.json` to validate method visibility levels in .NET. - Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application. - Included `go-gin-api.json` for a Go Gin API application structure. - Added `java-spring-boot.json` for the Spring PetClinic application in Java. - Introduced `legacy-no-schema.json` for legacy application structure without schema. - Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
@@ -21,7 +21,9 @@ using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Cache;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
@@ -71,6 +73,13 @@ builder.Services.AddOptions<ScannerWebServiceOptions>()
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<IValidateOptions<OfflineKitOptions>, OfflineKitOptionsValidator>();
|
||||
builder.Services.AddOptions<OfflineKitOptions>()
|
||||
.Bind(builder.Configuration.GetSection(OfflineKitOptions.SectionName))
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<IPublicKeyLoader, FileSystemPublicKeyLoader>();
|
||||
builder.Services.AddSingleton<ITrustAnchorRegistry, TrustAnchorRegistry>();
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `SCAN-API-3101-001` | `docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md` | DOING | Align Scanner OpenAPI spec with current endpoints and include ProofSpine routes; compose into `src/Api/StellaOps.Api.OpenApi/stella.yaml`. |
|
||||
| `PROOFSPINE-3100-API` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Implement and test `/api/v1/spines/*` endpoints and wire verification output. |
|
||||
| `SCAN-AIRGAP-0340-001` | `docs/implplan/SPRINT_0340_0001_0001_scanner_offline_config.md` | BLOCKED | Offline kit verification wiring is blocked on an import pipeline + offline Rekor verifier. |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Scanner.Worker.Determinism.Calculators;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Bitwise Fidelity (BF) by comparing SHA-256 hashes of outputs.
|
||||
/// </summary>
|
||||
public sealed class BitwiseFidelityCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes BF by comparing hashes across replay runs.
|
||||
/// </summary>
|
||||
/// <param name="baselineHashes">Hashes from baseline run (artifact -> hash)</param>
|
||||
/// <param name="replayHashes">Hashes from each replay run</param>
|
||||
/// <returns>BF score and mismatch details</returns>
|
||||
public (double Score, int IdenticalCount, List<FidelityMismatch> Mismatches) Calculate(
|
||||
IReadOnlyDictionary<string, string> baselineHashes,
|
||||
IReadOnlyList<IReadOnlyDictionary<string, string>> replayHashes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baselineHashes);
|
||||
ArgumentNullException.ThrowIfNull(replayHashes);
|
||||
|
||||
if (replayHashes.Count == 0)
|
||||
return (1.0, 0, []);
|
||||
|
||||
var identicalCount = 0;
|
||||
var mismatches = new List<FidelityMismatch>();
|
||||
|
||||
for (var i = 0; i < replayHashes.Count; i++)
|
||||
{
|
||||
var replay = replayHashes[i];
|
||||
var identical = true;
|
||||
var diffArtifacts = new List<string>();
|
||||
|
||||
foreach (var (artifact, baselineHash) in baselineHashes)
|
||||
{
|
||||
if (!replay.TryGetValue(artifact, out var replayHash) ||
|
||||
!string.Equals(baselineHash, replayHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
identical = false;
|
||||
diffArtifacts.Add(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for artifacts in replay but not in baseline
|
||||
foreach (var artifact in replay.Keys)
|
||||
{
|
||||
if (!baselineHashes.ContainsKey(artifact) && !diffArtifacts.Contains(artifact))
|
||||
{
|
||||
identical = false;
|
||||
diffArtifacts.Add(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
if (identical)
|
||||
{
|
||||
identicalCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
mismatches.Add(new FidelityMismatch
|
||||
{
|
||||
RunIndex = i,
|
||||
Type = FidelityMismatchType.BitwiseOnly,
|
||||
Description = $"Hash mismatch in {diffArtifacts.Count} artifact(s)",
|
||||
AffectedArtifacts = diffArtifacts.OrderBy(a => a, StringComparer.Ordinal).ToList()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var score = (double)identicalCount / replayHashes.Count;
|
||||
return (score, identicalCount, mismatches);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace StellaOps.Scanner.Worker.Determinism.Calculators;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Policy Fidelity (PF) by comparing final policy decisions.
|
||||
/// </summary>
|
||||
public sealed class PolicyFidelityCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes PF by comparing policy decisions.
|
||||
/// </summary>
|
||||
public (double Score, int MatchCount, List<FidelityMismatch> Mismatches) Calculate(
|
||||
PolicyDecision baseline,
|
||||
IReadOnlyList<PolicyDecision> replays)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseline);
|
||||
ArgumentNullException.ThrowIfNull(replays);
|
||||
|
||||
if (replays.Count == 0)
|
||||
return (1.0, 0, []);
|
||||
|
||||
var matchCount = 0;
|
||||
var mismatches = new List<FidelityMismatch>();
|
||||
|
||||
for (var i = 0; i < replays.Count; i++)
|
||||
{
|
||||
var replay = replays[i];
|
||||
var (isMatch, differences) = CompareDecisions(baseline, replay);
|
||||
|
||||
if (isMatch)
|
||||
{
|
||||
matchCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
mismatches.Add(new FidelityMismatch
|
||||
{
|
||||
RunIndex = i,
|
||||
Type = FidelityMismatchType.PolicyDrift,
|
||||
Description = $"Policy decision differs: {string.Join(", ", differences)}",
|
||||
AffectedArtifacts = differences
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var score = (double)matchCount / replays.Count;
|
||||
return (score, matchCount, mismatches);
|
||||
}
|
||||
|
||||
private static (bool IsMatch, List<string> Differences) CompareDecisions(
|
||||
PolicyDecision a,
|
||||
PolicyDecision b)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
|
||||
// Compare overall outcome
|
||||
if (a.Passed != b.Passed)
|
||||
differences.Add($"outcome:{a.Passed}→{b.Passed}");
|
||||
|
||||
// Compare reason codes (order-independent)
|
||||
var aReasons = a.ReasonCodes.OrderBy(r => r, StringComparer.Ordinal).ToList();
|
||||
var bReasons = b.ReasonCodes.OrderBy(r => r, StringComparer.Ordinal).ToList();
|
||||
|
||||
if (!aReasons.SequenceEqual(bReasons))
|
||||
differences.Add("reason_codes");
|
||||
|
||||
// Compare violation count
|
||||
if (a.ViolationCount != b.ViolationCount)
|
||||
differences.Add($"violations:{a.ViolationCount}→{b.ViolationCount}");
|
||||
|
||||
// Compare block level
|
||||
if (!string.Equals(a.BlockLevel, b.BlockLevel, StringComparison.Ordinal))
|
||||
differences.Add($"block_level:{a.BlockLevel}→{b.BlockLevel}");
|
||||
|
||||
return (differences.Count == 0, differences);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a policy decision for fidelity comparison.
|
||||
/// </summary>
|
||||
public sealed record PolicyDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the policy evaluation passed (true) or failed (false).
|
||||
/// </summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of reason codes explaining the decision.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> ReasonCodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of policy violations.
|
||||
/// </summary>
|
||||
public required int ViolationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Block level: "none", "warn", "block"
|
||||
/// </summary>
|
||||
public required string BlockLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy hash used for this decision.
|
||||
/// </summary>
|
||||
public string? PolicyHash { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace StellaOps.Scanner.Worker.Determinism.Calculators;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Semantic Fidelity (SF) by comparing normalized object structures.
|
||||
/// Ignores formatting differences; compares packages, versions, CVEs, severities, verdicts.
|
||||
/// </summary>
|
||||
public sealed class SemanticFidelityCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes SF by comparing normalized findings.
|
||||
/// </summary>
|
||||
public (double Score, int MatchCount, List<FidelityMismatch> Mismatches) Calculate(
|
||||
NormalizedFindings baseline,
|
||||
IReadOnlyList<NormalizedFindings> replays)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseline);
|
||||
ArgumentNullException.ThrowIfNull(replays);
|
||||
|
||||
if (replays.Count == 0)
|
||||
return (1.0, 0, []);
|
||||
|
||||
var matchCount = 0;
|
||||
var mismatches = new List<FidelityMismatch>();
|
||||
|
||||
for (var i = 0; i < replays.Count; i++)
|
||||
{
|
||||
var replay = replays[i];
|
||||
var (isMatch, differences) = CompareNormalized(baseline, replay);
|
||||
|
||||
if (isMatch)
|
||||
{
|
||||
matchCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
mismatches.Add(new FidelityMismatch
|
||||
{
|
||||
RunIndex = i,
|
||||
Type = FidelityMismatchType.SemanticOnly,
|
||||
Description = $"Semantic differences: {string.Join(", ", differences)}",
|
||||
AffectedArtifacts = differences
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var score = (double)matchCount / replays.Count;
|
||||
return (score, matchCount, mismatches);
|
||||
}
|
||||
|
||||
private static (bool IsMatch, List<string> Differences) CompareNormalized(
|
||||
NormalizedFindings a,
|
||||
NormalizedFindings b)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
|
||||
// Compare package sets (order-independent)
|
||||
var aPackages = a.Packages.OrderBy(p => p.Purl, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.Version, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var bPackages = b.Packages.OrderBy(p => p.Purl, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.Version, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (!aPackages.SequenceEqual(bPackages))
|
||||
differences.Add("packages");
|
||||
|
||||
// Compare CVE sets (order-independent)
|
||||
var aCves = a.Cves.OrderBy(c => c, StringComparer.Ordinal).ToList();
|
||||
var bCves = b.Cves.OrderBy(c => c, StringComparer.Ordinal).ToList();
|
||||
|
||||
if (!aCves.SequenceEqual(bCves))
|
||||
differences.Add("cves");
|
||||
|
||||
// Compare severity counts (order-independent)
|
||||
var aSeverities = a.SeverityCounts.OrderBy(kvp => kvp.Key, StringComparer.Ordinal).ToList();
|
||||
var bSeverities = b.SeverityCounts.OrderBy(kvp => kvp.Key, StringComparer.Ordinal).ToList();
|
||||
|
||||
if (!aSeverities.SequenceEqual(bSeverities))
|
||||
differences.Add("severities");
|
||||
|
||||
// Compare verdicts (order-independent)
|
||||
var aVerdicts = a.Verdicts.OrderBy(v => v.Key, StringComparer.Ordinal).ToList();
|
||||
var bVerdicts = b.Verdicts.OrderBy(v => v.Key, StringComparer.Ordinal).ToList();
|
||||
|
||||
if (!aVerdicts.SequenceEqual(bVerdicts))
|
||||
differences.Add("verdicts");
|
||||
|
||||
return (differences.Count == 0, differences);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized findings for semantic comparison.
|
||||
/// </summary>
|
||||
public sealed record NormalizedFindings
|
||||
{
|
||||
public required IReadOnlyList<NormalizedPackage> Packages { get; init; }
|
||||
public required IReadOnlySet<string> Cves { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> SeverityCounts { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Verdicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized package representation for comparison.
|
||||
/// </summary>
|
||||
public sealed record NormalizedPackage(string Purl, string Version) : IEquatable<NormalizedPackage>;
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// Three-tier fidelity metrics for deterministic reproducibility measurement.
|
||||
/// All scores are ratios in range [0.0, 1.0].
|
||||
/// </summary>
|
||||
public sealed record FidelityMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Bitwise Fidelity (BF): identical_outputs / total_replays
|
||||
/// Target: >= 0.98 (general), >= 0.95 (regulated)
|
||||
/// </summary>
|
||||
public required double BitwiseFidelity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Semantic Fidelity (SF): normalized object comparison match ratio
|
||||
/// Allows formatting differences, compares: packages, versions, CVEs, severities, verdicts
|
||||
/// </summary>
|
||||
public required double SemanticFidelity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy Fidelity (PF): policy decision match ratio
|
||||
/// Compares: pass/fail + reason codes
|
||||
/// Target: ~1.0 unless policy changed intentionally
|
||||
/// </summary>
|
||||
public required double PolicyFidelity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of replay runs compared.
|
||||
/// </summary>
|
||||
public required int TotalReplays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bitwise-identical outputs.
|
||||
/// </summary>
|
||||
public required int IdenticalOutputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of semantically-equivalent outputs.
|
||||
/// </summary>
|
||||
public required int SemanticMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of policy-decision matches.
|
||||
/// </summary>
|
||||
public required int PolicyMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed timestamp (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic information for non-identical runs.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FidelityMismatch>? Mismatches { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic information about a fidelity mismatch.
|
||||
/// </summary>
|
||||
public sealed record FidelityMismatch
|
||||
{
|
||||
public required int RunIndex { get; init; }
|
||||
public required FidelityMismatchType Type { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public IReadOnlyList<string>? AffectedArtifacts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of fidelity mismatch.
|
||||
/// </summary>
|
||||
public enum FidelityMismatchType
|
||||
{
|
||||
/// <summary>Hash differs but content semantically equivalent</summary>
|
||||
BitwiseOnly,
|
||||
|
||||
/// <summary>Content differs but policy decision matches</summary>
|
||||
SemanticOnly,
|
||||
|
||||
/// <summary>Policy decision differs</summary>
|
||||
PolicyDrift,
|
||||
|
||||
/// <summary>All tiers differ</summary>
|
||||
Full
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using StellaOps.Scanner.Worker.Determinism.Calculators;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// Service that orchestrates fidelity metric calculation across all three tiers.
|
||||
/// </summary>
|
||||
public sealed class FidelityMetricsService
|
||||
{
|
||||
private readonly BitwiseFidelityCalculator _bitwiseCalculator;
|
||||
private readonly SemanticFidelityCalculator _semanticCalculator;
|
||||
private readonly PolicyFidelityCalculator _policyCalculator;
|
||||
|
||||
public FidelityMetricsService()
|
||||
{
|
||||
_bitwiseCalculator = new BitwiseFidelityCalculator();
|
||||
_semanticCalculator = new SemanticFidelityCalculator();
|
||||
_policyCalculator = new PolicyFidelityCalculator();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes all three fidelity metrics for a set of replay runs.
|
||||
/// </summary>
|
||||
/// <param name="baselineHashes">Artifact hashes from baseline run</param>
|
||||
/// <param name="replayHashes">Artifact hashes from each replay run</param>
|
||||
/// <param name="baselineFindings">Normalized findings from baseline</param>
|
||||
/// <param name="replayFindings">Normalized findings from each replay</param>
|
||||
/// <param name="baselineDecision">Policy decision from baseline</param>
|
||||
/// <param name="replayDecisions">Policy decisions from each replay</param>
|
||||
/// <returns>Complete fidelity metrics</returns>
|
||||
public FidelityMetrics Calculate(
|
||||
IReadOnlyDictionary<string, string> baselineHashes,
|
||||
IReadOnlyList<IReadOnlyDictionary<string, string>> replayHashes,
|
||||
NormalizedFindings baselineFindings,
|
||||
IReadOnlyList<NormalizedFindings> replayFindings,
|
||||
PolicyDecision baselineDecision,
|
||||
IReadOnlyList<PolicyDecision> replayDecisions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baselineHashes);
|
||||
ArgumentNullException.ThrowIfNull(replayHashes);
|
||||
ArgumentNullException.ThrowIfNull(baselineFindings);
|
||||
ArgumentNullException.ThrowIfNull(replayFindings);
|
||||
ArgumentNullException.ThrowIfNull(baselineDecision);
|
||||
ArgumentNullException.ThrowIfNull(replayDecisions);
|
||||
|
||||
// Calculate bitwise fidelity
|
||||
var (bfScore, bfIdentical, bfMismatches) = _bitwiseCalculator.Calculate(
|
||||
baselineHashes, replayHashes);
|
||||
|
||||
// Calculate semantic fidelity
|
||||
var (sfScore, sfMatches, sfMismatches) = _semanticCalculator.Calculate(
|
||||
baselineFindings, replayFindings);
|
||||
|
||||
// Calculate policy fidelity
|
||||
var (pfScore, pfMatches, pfMismatches) = _policyCalculator.Calculate(
|
||||
baselineDecision, replayDecisions);
|
||||
|
||||
// Combine mismatches with proper classification
|
||||
var allMismatches = CombineMismatches(bfMismatches, sfMismatches, pfMismatches);
|
||||
|
||||
return new FidelityMetrics
|
||||
{
|
||||
BitwiseFidelity = bfScore,
|
||||
SemanticFidelity = sfScore,
|
||||
PolicyFidelity = pfScore,
|
||||
TotalReplays = replayHashes.Count,
|
||||
IdenticalOutputs = bfIdentical,
|
||||
SemanticMatches = sfMatches,
|
||||
PolicyMatches = pfMatches,
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
Mismatches = allMismatches.Count > 0 ? allMismatches : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether the fidelity metrics meet the specified thresholds.
|
||||
/// </summary>
|
||||
/// <param name="metrics">Computed fidelity metrics</param>
|
||||
/// <param name="thresholds">Thresholds to check against</param>
|
||||
/// <param name="isRegulated">Whether this is a regulated project</param>
|
||||
/// <returns>Evaluation result with pass/fail and reason</returns>
|
||||
public FidelityEvaluation Evaluate(
|
||||
FidelityMetrics metrics,
|
||||
FidelityThresholds thresholds,
|
||||
bool isRegulated = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metrics);
|
||||
ArgumentNullException.ThrowIfNull(thresholds);
|
||||
|
||||
var failures = new List<string>();
|
||||
var bfThreshold = isRegulated
|
||||
? thresholds.BitwiseFidelityRegulated
|
||||
: thresholds.BitwiseFidelityGeneral;
|
||||
|
||||
if (metrics.BitwiseFidelity < bfThreshold)
|
||||
failures.Add($"BF {metrics.BitwiseFidelity:P2} < {bfThreshold:P2}");
|
||||
|
||||
if (metrics.SemanticFidelity < thresholds.SemanticFidelity)
|
||||
failures.Add($"SF {metrics.SemanticFidelity:P2} < {thresholds.SemanticFidelity:P2}");
|
||||
|
||||
if (metrics.PolicyFidelity < thresholds.PolicyFidelity)
|
||||
failures.Add($"PF {metrics.PolicyFidelity:P2} < {thresholds.PolicyFidelity:P2}");
|
||||
|
||||
var shouldBlock = metrics.BitwiseFidelity < thresholds.BitwiseFidelityBlockThreshold;
|
||||
|
||||
return new FidelityEvaluation
|
||||
{
|
||||
Passed = failures.Count == 0,
|
||||
ShouldBlockRelease = shouldBlock,
|
||||
FailureReasons = failures,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static List<FidelityMismatch> CombineMismatches(
|
||||
List<FidelityMismatch> bfMismatches,
|
||||
List<FidelityMismatch> sfMismatches,
|
||||
List<FidelityMismatch> pfMismatches)
|
||||
{
|
||||
var combined = new Dictionary<int, FidelityMismatch>();
|
||||
|
||||
// Start with bitwise mismatches
|
||||
foreach (var m in bfMismatches)
|
||||
{
|
||||
combined[m.RunIndex] = m;
|
||||
}
|
||||
|
||||
// Upgrade or add semantic mismatches
|
||||
foreach (var m in sfMismatches)
|
||||
{
|
||||
if (combined.TryGetValue(m.RunIndex, out var existing))
|
||||
{
|
||||
// Both bitwise and semantic differ
|
||||
combined[m.RunIndex] = existing with
|
||||
{
|
||||
Type = FidelityMismatchType.Full,
|
||||
Description = $"{existing.Description}; {m.Description}",
|
||||
AffectedArtifacts = (existing.AffectedArtifacts ?? [])
|
||||
.Concat(m.AffectedArtifacts ?? [])
|
||||
.Distinct()
|
||||
.OrderBy(a => a, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
combined[m.RunIndex] = m;
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade or add policy mismatches
|
||||
foreach (var m in pfMismatches)
|
||||
{
|
||||
if (combined.TryGetValue(m.RunIndex, out var existing))
|
||||
{
|
||||
var newType = existing.Type switch
|
||||
{
|
||||
FidelityMismatchType.Full => FidelityMismatchType.Full,
|
||||
_ => FidelityMismatchType.Full
|
||||
};
|
||||
|
||||
combined[m.RunIndex] = existing with
|
||||
{
|
||||
Type = newType,
|
||||
Description = $"{existing.Description}; {m.Description}",
|
||||
AffectedArtifacts = (existing.AffectedArtifacts ?? [])
|
||||
.Concat(m.AffectedArtifacts ?? [])
|
||||
.Distinct()
|
||||
.OrderBy(a => a, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
combined[m.RunIndex] = m;
|
||||
}
|
||||
}
|
||||
|
||||
return combined.Values
|
||||
.OrderBy(m => m.RunIndex)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating fidelity metrics against thresholds.
|
||||
/// </summary>
|
||||
public sealed record FidelityEvaluation
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether all thresholds were met.
|
||||
/// </summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the release should be blocked (BF below critical threshold).
|
||||
/// </summary>
|
||||
public required bool ShouldBlockRelease { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of threshold violations.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> FailureReasons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of evaluation.
|
||||
/// </summary>
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// SLO thresholds for fidelity metrics.
|
||||
/// </summary>
|
||||
public sealed record FidelityThresholds
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum BF for general workloads (default: 0.98)
|
||||
/// </summary>
|
||||
public double BitwiseFidelityGeneral { get; init; } = 0.98;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum BF for regulated projects (default: 0.95)
|
||||
/// </summary>
|
||||
public double BitwiseFidelityRegulated { get; init; } = 0.95;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum SF (default: 0.99)
|
||||
/// </summary>
|
||||
public double SemanticFidelity { get; init; } = 0.99;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum PF (default: 1.0 unless policy changed)
|
||||
/// </summary>
|
||||
public double PolicyFidelity { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Week-over-week BF drop that triggers warning (default: 0.02 = 2%)
|
||||
/// </summary>
|
||||
public double BitwiseFidelityWarnDrop { get; init; } = 0.02;
|
||||
|
||||
/// <summary>
|
||||
/// Overall BF that triggers page/block release (default: 0.90)
|
||||
/// </summary>
|
||||
public double BitwiseFidelityBlockThreshold { get; init; } = 0.90;
|
||||
|
||||
/// <summary>
|
||||
/// Default thresholds.
|
||||
/// </summary>
|
||||
public static FidelityThresholds Default => new();
|
||||
}
|
||||
@@ -173,6 +173,14 @@ internal sealed class DotNetCallgraphBuilder
|
||||
var isVirtual = (methodDef.Attributes & MethodAttributes.Virtual) != 0;
|
||||
var isGeneric = methodDef.GetGenericParameters().Count > 0;
|
||||
|
||||
// Extract visibility from MethodAttributes
|
||||
var visibility = ExtractVisibility(methodDef.Attributes);
|
||||
|
||||
// Determine if this method is an entrypoint candidate
|
||||
var isTypePublic = (typeDef.Attributes & TypeAttributes.Public) != 0 ||
|
||||
(typeDef.Attributes & TypeAttributes.NestedPublic) != 0;
|
||||
var isEntrypointCandidate = isPublic && isTypePublic && !methodName.StartsWith("<");
|
||||
|
||||
var node = new DotNetMethodNode(
|
||||
MethodId: methodId,
|
||||
AssemblyName: assemblyName,
|
||||
@@ -186,7 +194,9 @@ internal sealed class DotNetCallgraphBuilder
|
||||
IsStatic: isStatic,
|
||||
IsPublic: isPublic,
|
||||
IsVirtual: isVirtual,
|
||||
IsGeneric: isGeneric);
|
||||
IsGeneric: isGeneric,
|
||||
Visibility: visibility,
|
||||
IsEntrypointCandidate: isEntrypointCandidate);
|
||||
|
||||
_methods.TryAdd(methodId, node);
|
||||
|
||||
@@ -254,6 +264,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
!methodName.StartsWith("get_") && !methodName.StartsWith("set_") &&
|
||||
methodName != ".ctor")
|
||||
{
|
||||
var (routeTemplate, httpMethod) = ExtractRouteInfo(metadata, methodDef.GetCustomAttributes());
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
@@ -262,14 +273,29 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "ControllerAction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
RouteTemplate: routeTemplate,
|
||||
HttpMethod: httpMethod,
|
||||
Framework: DotNetEntrypointFramework.AspNetCore));
|
||||
}
|
||||
|
||||
// Test methods (xUnit, NUnit, MSTest)
|
||||
var testFramework = DotNetEntrypointFramework.Unknown;
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.FactAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.TheoryAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "NUnit.Framework.TestAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"))
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.TheoryAttribute"))
|
||||
{
|
||||
testFramework = DotNetEntrypointFramework.XUnit;
|
||||
}
|
||||
else if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "NUnit.Framework.TestAttribute"))
|
||||
{
|
||||
testFramework = DotNetEntrypointFramework.NUnit;
|
||||
}
|
||||
else if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"))
|
||||
{
|
||||
testFramework = DotNetEntrypointFramework.MSTest;
|
||||
}
|
||||
|
||||
if (testFramework != DotNetEntrypointFramework.Unknown)
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
@@ -279,7 +305,8 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "TestMethod",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
Framework: testFramework));
|
||||
}
|
||||
|
||||
// Azure Functions
|
||||
@@ -294,7 +321,8 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "AzureFunction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
Framework: DotNetEntrypointFramework.AzureFunctions));
|
||||
}
|
||||
|
||||
// AWS Lambda
|
||||
@@ -308,10 +336,120 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "LambdaHandler",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
Framework: DotNetEntrypointFramework.AwsLambda));
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? RouteTemplate, string? HttpMethod) ExtractRouteInfo(
|
||||
MetadataReader metadata,
|
||||
CustomAttributeHandleCollection attributes)
|
||||
{
|
||||
string? routeTemplate = null;
|
||||
string? httpMethod = null;
|
||||
|
||||
foreach (var attrHandle in attributes)
|
||||
{
|
||||
var attr = metadata.GetCustomAttribute(attrHandle);
|
||||
var ctorHandle = attr.Constructor;
|
||||
|
||||
string? typeName = null;
|
||||
switch (ctorHandle.Kind)
|
||||
{
|
||||
case HandleKind.MemberReference:
|
||||
var memberRef = metadata.GetMemberReference((MemberReferenceHandle)ctorHandle);
|
||||
if (memberRef.Parent.Kind == HandleKind.TypeReference)
|
||||
{
|
||||
var typeRef = metadata.GetTypeReference((TypeReferenceHandle)memberRef.Parent);
|
||||
typeName = GetTypeRefName(metadata, typeRef);
|
||||
}
|
||||
break;
|
||||
case HandleKind.MethodDefinition:
|
||||
var methodDef = metadata.GetMethodDefinition((MethodDefinitionHandle)ctorHandle);
|
||||
var declaringType = metadata.GetTypeDefinition(methodDef.GetDeclaringType());
|
||||
typeName = GetFullTypeName(metadata, declaringType);
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeName is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract route template from [Route] attribute
|
||||
if (typeName.Contains("RouteAttribute"))
|
||||
{
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
|
||||
// Extract HTTP method and optional route from Http*Attribute
|
||||
if (typeName.Contains("HttpGetAttribute"))
|
||||
{
|
||||
httpMethod = "GET";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpPostAttribute"))
|
||||
{
|
||||
httpMethod = "POST";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpPutAttribute"))
|
||||
{
|
||||
httpMethod = "PUT";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpDeleteAttribute"))
|
||||
{
|
||||
httpMethod = "DELETE";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpPatchAttribute"))
|
||||
{
|
||||
httpMethod = "PATCH";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
}
|
||||
|
||||
return (routeTemplate, httpMethod);
|
||||
}
|
||||
|
||||
private static string? TryExtractStringArgument(MetadataReader metadata, CustomAttribute attr)
|
||||
{
|
||||
// Simplified extraction - read first string argument from attribute blob
|
||||
// Full implementation would properly parse the custom attribute blob
|
||||
try
|
||||
{
|
||||
var value = attr.DecodeValue(new SimpleAttributeProvider());
|
||||
if (value.FixedArguments.Length > 0 &&
|
||||
value.FixedArguments[0].Value is string strValue &&
|
||||
!string.IsNullOrEmpty(strValue))
|
||||
{
|
||||
return strValue;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Attribute decoding failed - not critical
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple attribute type provider for decoding custom attributes.
|
||||
/// </summary>
|
||||
private sealed class SimpleAttributeProvider : ICustomAttributeTypeProvider<object?>
|
||||
{
|
||||
public object? GetPrimitiveType(PrimitiveTypeCode typeCode) => null;
|
||||
public object? GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => null;
|
||||
public object? GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) => null;
|
||||
public object? GetSZArrayType(object? elementType) => null;
|
||||
public object? GetSystemType() => typeof(Type);
|
||||
public object? GetTypeFromSerializedName(string name) => Type.GetType(name);
|
||||
public PrimitiveTypeCode GetUnderlyingEnumType(object? type) => PrimitiveTypeCode.Int32;
|
||||
public bool IsSystemType(object? type) => type is Type;
|
||||
}
|
||||
|
||||
private void ExtractCallEdgesFromType(
|
||||
MetadataReader metadata,
|
||||
TypeDefinition typeDef,
|
||||
@@ -390,15 +528,15 @@ internal sealed class DotNetCallgraphBuilder
|
||||
var token = BitConverter.ToInt32(ilBytes, offset);
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode switch
|
||||
var (edgeType, edgeReason) = opcode switch
|
||||
{
|
||||
0x28 => DotNetEdgeType.Call,
|
||||
0x6F => DotNetEdgeType.CallVirt,
|
||||
0x73 => DotNetEdgeType.NewObj,
|
||||
_ => DotNetEdgeType.Call,
|
||||
0x28 => (DotNetEdgeType.Call, DotNetEdgeReason.DirectCall),
|
||||
0x6F => (DotNetEdgeType.CallVirt, DotNetEdgeReason.VirtualCall),
|
||||
0x73 => (DotNetEdgeType.NewObj, DotNetEdgeReason.NewObj),
|
||||
_ => (DotNetEdgeType.Call, DotNetEdgeReason.DirectCall),
|
||||
};
|
||||
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, edgeReason, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0xFE06: // ldftn (0xFE 0x06)
|
||||
@@ -413,7 +551,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode == 0xFE06 ? DotNetEdgeType.LdFtn : DotNetEdgeType.LdVirtFtn;
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, DotNetEdgeReason.DelegateCreate, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0x29: // calli
|
||||
@@ -436,6 +574,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
CalleePurl: null,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: DotNetEdgeType.CallI,
|
||||
EdgeReason: DotNetEdgeReason.IndirectCall,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: false,
|
||||
Confidence: 0.2));
|
||||
@@ -470,6 +609,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
int token,
|
||||
int ilOffset,
|
||||
DotNetEdgeType edgeType,
|
||||
DotNetEdgeReason edgeReason,
|
||||
string assemblyName,
|
||||
string assemblyPath)
|
||||
{
|
||||
@@ -517,8 +657,8 @@ internal sealed class DotNetCallgraphBuilder
|
||||
case HandleKind.MethodSpecification:
|
||||
{
|
||||
var methodSpec = metadata.GetMethodSpecification((MethodSpecificationHandle)handle);
|
||||
// Recursively resolve the generic method
|
||||
AddCallEdge(metadata, callerId, MetadataTokens.GetToken(methodSpec.Method), ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
// Recursively resolve the generic method - use GenericInstantiation reason
|
||||
AddCallEdge(metadata, callerId, MetadataTokens.GetToken(methodSpec.Method), ilOffset, edgeType, DotNetEdgeReason.GenericInstantiation, assemblyName, assemblyPath);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
@@ -549,6 +689,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
CalleePurl: calleePurl,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: edgeType,
|
||||
EdgeReason: edgeReason,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.7));
|
||||
@@ -788,4 +929,19 @@ internal sealed class DotNetCallgraphBuilder
|
||||
_ => 1, // default for unrecognized
|
||||
};
|
||||
}
|
||||
|
||||
private static DotNetVisibility ExtractVisibility(MethodAttributes attributes)
|
||||
{
|
||||
var accessMask = attributes & MethodAttributes.MemberAccessMask;
|
||||
return accessMask switch
|
||||
{
|
||||
MethodAttributes.Public => DotNetVisibility.Public,
|
||||
MethodAttributes.Private => DotNetVisibility.Private,
|
||||
MethodAttributes.Family => DotNetVisibility.Protected,
|
||||
MethodAttributes.Assembly => DotNetVisibility.Internal,
|
||||
MethodAttributes.FamORAssem => DotNetVisibility.ProtectedInternal,
|
||||
MethodAttributes.FamANDAssem => DotNetVisibility.PrivateProtected,
|
||||
_ => DotNetVisibility.Private
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ public sealed record DotNetReachabilityGraph(
|
||||
/// <param name="IsPublic">Whether the method is public.</param>
|
||||
/// <param name="IsVirtual">Whether the method is virtual.</param>
|
||||
/// <param name="IsGeneric">Whether the method has generic parameters.</param>
|
||||
/// <param name="Visibility">Access visibility (public, private, protected, internal, etc.).</param>
|
||||
/// <param name="IsEntrypointCandidate">Whether this method could be an entrypoint (public, controller action, etc.).</param>
|
||||
public sealed record DotNetMethodNode(
|
||||
string MethodId,
|
||||
string AssemblyName,
|
||||
@@ -45,7 +47,33 @@ public sealed record DotNetMethodNode(
|
||||
bool IsStatic,
|
||||
bool IsPublic,
|
||||
bool IsVirtual,
|
||||
bool IsGeneric);
|
||||
bool IsGeneric,
|
||||
DotNetVisibility Visibility,
|
||||
bool IsEntrypointCandidate);
|
||||
|
||||
/// <summary>
|
||||
/// Access visibility levels for .NET methods.
|
||||
/// </summary>
|
||||
public enum DotNetVisibility
|
||||
{
|
||||
/// <summary>Accessible from anywhere.</summary>
|
||||
Public,
|
||||
|
||||
/// <summary>Accessible only within the same type.</summary>
|
||||
Private,
|
||||
|
||||
/// <summary>Accessible within the same type or derived types.</summary>
|
||||
Protected,
|
||||
|
||||
/// <summary>Accessible within the same assembly.</summary>
|
||||
Internal,
|
||||
|
||||
/// <summary>Accessible within the same assembly or derived types.</summary>
|
||||
ProtectedInternal,
|
||||
|
||||
/// <summary>Accessible only within derived types in the same assembly.</summary>
|
||||
PrivateProtected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A call edge in the .NET call graph.
|
||||
@@ -56,6 +84,7 @@ public sealed record DotNetMethodNode(
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeMethodDigest">Method digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (call instruction type).</param>
|
||||
/// <param name="EdgeReason">Semantic reason for the edge (DirectCall, VirtualCall, etc.).</param>
|
||||
/// <param name="ILOffset">IL offset where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
@@ -66,6 +95,7 @@ public sealed record DotNetCallEdge(
|
||||
string? CalleePurl,
|
||||
string? CalleeMethodDigest,
|
||||
DotNetEdgeType EdgeType,
|
||||
DotNetEdgeReason EdgeReason,
|
||||
int ILOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
@@ -103,6 +133,52 @@ public enum DotNetEdgeType
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic reason for why a .NET edge exists.
|
||||
/// Maps to the schema's EdgeReason enum for explainability.
|
||||
/// </summary>
|
||||
public enum DotNetEdgeReason
|
||||
{
|
||||
/// <summary>Direct method call (call opcode).</summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>Virtual/interface dispatch (callvirt opcode).</summary>
|
||||
VirtualCall,
|
||||
|
||||
/// <summary>Reflection-based invocation (Type.GetMethod, etc.).</summary>
|
||||
ReflectionString,
|
||||
|
||||
/// <summary>Dependency injection binding.</summary>
|
||||
DiBinding,
|
||||
|
||||
/// <summary>Dynamic import or late binding.</summary>
|
||||
DynamicImport,
|
||||
|
||||
/// <summary>Constructor/object instantiation (newobj opcode).</summary>
|
||||
NewObj,
|
||||
|
||||
/// <summary>Delegate/function pointer creation (ldftn, ldvirtftn).</summary>
|
||||
DelegateCreate,
|
||||
|
||||
/// <summary>Async/await continuation.</summary>
|
||||
AsyncContinuation,
|
||||
|
||||
/// <summary>Event handler subscription.</summary>
|
||||
EventHandler,
|
||||
|
||||
/// <summary>Generic type instantiation.</summary>
|
||||
GenericInstantiation,
|
||||
|
||||
/// <summary>Native interop (P/Invoke).</summary>
|
||||
NativeInterop,
|
||||
|
||||
/// <summary>Indirect call through function pointer (calli).</summary>
|
||||
IndirectCall,
|
||||
|
||||
/// <summary>Reason could not be determined.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the .NET call graph.
|
||||
/// </summary>
|
||||
@@ -114,6 +190,9 @@ public enum DotNetEdgeType
|
||||
/// <param name="Phase">Execution phase.</param>
|
||||
/// <param name="Order">Order within the phase.</param>
|
||||
/// <param name="IsResolved">Whether the target was successfully resolved.</param>
|
||||
/// <param name="RouteTemplate">HTTP route template if applicable (e.g., "/api/orders/{id}").</param>
|
||||
/// <param name="HttpMethod">HTTP method if applicable (GET, POST, etc.).</param>
|
||||
/// <param name="Framework">Framework exposing this entrypoint.</param>
|
||||
public sealed record DotNetSyntheticRoot(
|
||||
string RootId,
|
||||
string TargetId,
|
||||
@@ -122,7 +201,43 @@ public sealed record DotNetSyntheticRoot(
|
||||
string AssemblyPath,
|
||||
DotNetRootPhase Phase,
|
||||
int Order,
|
||||
bool IsResolved = true);
|
||||
bool IsResolved = true,
|
||||
string? RouteTemplate = null,
|
||||
string? HttpMethod = null,
|
||||
DotNetEntrypointFramework Framework = DotNetEntrypointFramework.Unknown);
|
||||
|
||||
/// <summary>
|
||||
/// Frameworks that expose .NET entrypoints.
|
||||
/// </summary>
|
||||
public enum DotNetEntrypointFramework
|
||||
{
|
||||
/// <summary>Unknown framework.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>ASP.NET Core MVC/WebAPI.</summary>
|
||||
AspNetCore,
|
||||
|
||||
/// <summary>ASP.NET Core Minimal APIs.</summary>
|
||||
MinimalApi,
|
||||
|
||||
/// <summary>gRPC for .NET.</summary>
|
||||
Grpc,
|
||||
|
||||
/// <summary>Azure Functions.</summary>
|
||||
AzureFunctions,
|
||||
|
||||
/// <summary>AWS Lambda.</summary>
|
||||
AwsLambda,
|
||||
|
||||
/// <summary>xUnit test framework.</summary>
|
||||
XUnit,
|
||||
|
||||
/// <summary>NUnit test framework.</summary>
|
||||
NUnit,
|
||||
|
||||
/// <summary>MSTest framework.</summary>
|
||||
MSTest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase for .NET synthetic roots.
|
||||
|
||||
@@ -108,12 +108,12 @@ internal sealed class JavaCallgraphBuilder
|
||||
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, calleeId, edge.InstructionOffset);
|
||||
var confidence = edge.Confidence == JavaReflectionConfidence.High ? 0.9 : 0.5;
|
||||
|
||||
var edgeType = edge.Reason switch
|
||||
var (edgeType, edgeReason) = edge.Reason switch
|
||||
{
|
||||
JavaReflectionReason.ClassForName => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ClassLoaderLoadClass => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ServiceLoaderLoad => JavaEdgeType.ServiceLoader,
|
||||
_ => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ClassForName => (JavaEdgeType.Reflection, JavaEdgeReason.ReflectionString),
|
||||
JavaReflectionReason.ClassLoaderLoadClass => (JavaEdgeType.Reflection, JavaEdgeReason.ReflectionString),
|
||||
JavaReflectionReason.ServiceLoaderLoad => (JavaEdgeType.ServiceLoader, JavaEdgeReason.ServiceLoader),
|
||||
_ => (JavaEdgeType.Reflection, JavaEdgeReason.ReflectionString),
|
||||
};
|
||||
|
||||
_edges.Add(new JavaCallEdge(
|
||||
@@ -123,6 +123,7 @@ internal sealed class JavaCallgraphBuilder
|
||||
CalleePurl: null, // Reflection targets often unknown
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: edgeType,
|
||||
EdgeReason: edgeReason,
|
||||
BytecodeOffset: edge.InstructionOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: confidence));
|
||||
@@ -229,6 +230,16 @@ internal sealed class JavaCallgraphBuilder
|
||||
var isSynthetic = (method.AccessFlags & 0x1000) != 0;
|
||||
var isBridge = (method.AccessFlags & 0x0040) != 0;
|
||||
|
||||
// Extract visibility from access flags
|
||||
var visibility = ExtractVisibility(method.AccessFlags);
|
||||
|
||||
// Determine if this method is an entrypoint candidate
|
||||
// Public non-synthetic methods that aren't constructors or accessors
|
||||
var isEntrypointCandidate = isPublic &&
|
||||
!isSynthetic &&
|
||||
!method.Name.StartsWith("<") &&
|
||||
!method.Name.StartsWith("lambda$");
|
||||
|
||||
var node = new JavaMethodNode(
|
||||
MethodId: methodId,
|
||||
ClassName: className,
|
||||
@@ -241,11 +252,34 @@ internal sealed class JavaCallgraphBuilder
|
||||
IsStatic: isStatic,
|
||||
IsPublic: isPublic,
|
||||
IsSynthetic: isSynthetic,
|
||||
IsBridge: isBridge);
|
||||
IsBridge: isBridge,
|
||||
Visibility: visibility,
|
||||
IsEntrypointCandidate: isEntrypointCandidate);
|
||||
|
||||
_methods.TryAdd(methodId, node);
|
||||
}
|
||||
|
||||
private static JavaVisibility ExtractVisibility(int accessFlags)
|
||||
{
|
||||
// ACC_PUBLIC = 0x0001, ACC_PRIVATE = 0x0002, ACC_PROTECTED = 0x0004
|
||||
if ((accessFlags & 0x0001) != 0)
|
||||
{
|
||||
return JavaVisibility.Public;
|
||||
}
|
||||
else if ((accessFlags & 0x0002) != 0)
|
||||
{
|
||||
return JavaVisibility.Private;
|
||||
}
|
||||
else if ((accessFlags & 0x0004) != 0)
|
||||
{
|
||||
return JavaVisibility.Protected;
|
||||
}
|
||||
else
|
||||
{
|
||||
return JavaVisibility.Package; // Package-private (default)
|
||||
}
|
||||
}
|
||||
|
||||
private void FindSyntheticRoots(string className, JavaClassFileParser.ClassFile classFile, string jarPath)
|
||||
{
|
||||
var rootOrder = 0;
|
||||
@@ -380,13 +414,14 @@ internal sealed class JavaCallgraphBuilder
|
||||
methodRef.Value.Name,
|
||||
methodRef.Value.Descriptor);
|
||||
|
||||
var edgeType = opcode switch
|
||||
var (edgeType, edgeReason) = opcode switch
|
||||
{
|
||||
0xB8 => JavaEdgeType.InvokeStatic,
|
||||
0xB6 => JavaEdgeType.InvokeVirtual,
|
||||
0xB7 => methodRef.Value.Name == "<init>" ? JavaEdgeType.Constructor : JavaEdgeType.InvokeSpecial,
|
||||
0xB9 => JavaEdgeType.InvokeInterface,
|
||||
_ => JavaEdgeType.InvokeVirtual,
|
||||
0xB8 => (JavaEdgeType.InvokeStatic, JavaEdgeReason.DirectCall),
|
||||
0xB6 => (JavaEdgeType.InvokeVirtual, JavaEdgeReason.VirtualCall),
|
||||
0xB7 when methodRef.Value.Name == "<init>" => (JavaEdgeType.Constructor, JavaEdgeReason.NewObj),
|
||||
0xB7 => (JavaEdgeType.InvokeSpecial, JavaEdgeReason.SuperCall),
|
||||
0xB9 => (JavaEdgeType.InvokeInterface, JavaEdgeReason.InterfaceCall),
|
||||
_ => (JavaEdgeType.InvokeVirtual, JavaEdgeReason.VirtualCall),
|
||||
};
|
||||
|
||||
// Check if target is resolved (known in our method set)
|
||||
@@ -403,6 +438,7 @@ internal sealed class JavaCallgraphBuilder
|
||||
CalleePurl: calleePurl,
|
||||
CalleeMethodDigest: null, // Would compute if method is in our set
|
||||
EdgeType: edgeType,
|
||||
EdgeReason: edgeReason,
|
||||
BytecodeOffset: instructionOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.7));
|
||||
@@ -448,6 +484,7 @@ internal sealed class JavaCallgraphBuilder
|
||||
CalleePurl: null,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: JavaEdgeType.InvokeDynamic,
|
||||
EdgeReason: JavaEdgeReason.DynamicImport,
|
||||
BytecodeOffset: instructionOffset,
|
||||
IsResolved: false,
|
||||
Confidence: 0.3));
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed record JavaReachabilityGraph(
|
||||
/// <param name="IsPublic">Whether the method is public.</param>
|
||||
/// <param name="IsSynthetic">Whether the method is synthetic (compiler-generated).</param>
|
||||
/// <param name="IsBridge">Whether the method is a bridge method.</param>
|
||||
/// <param name="Visibility">Access visibility (public, private, protected, package).</param>
|
||||
/// <param name="IsEntrypointCandidate">Whether this method could be an entrypoint (public, controller action, etc.).</param>
|
||||
public sealed record JavaMethodNode(
|
||||
string MethodId,
|
||||
string ClassName,
|
||||
@@ -43,7 +45,27 @@ public sealed record JavaMethodNode(
|
||||
bool IsStatic,
|
||||
bool IsPublic,
|
||||
bool IsSynthetic,
|
||||
bool IsBridge);
|
||||
bool IsBridge,
|
||||
JavaVisibility Visibility,
|
||||
bool IsEntrypointCandidate);
|
||||
|
||||
/// <summary>
|
||||
/// Access visibility levels for Java methods.
|
||||
/// </summary>
|
||||
public enum JavaVisibility
|
||||
{
|
||||
/// <summary>Accessible from anywhere.</summary>
|
||||
Public,
|
||||
|
||||
/// <summary>Accessible only within the same class.</summary>
|
||||
Private,
|
||||
|
||||
/// <summary>Accessible within the same package or subclasses.</summary>
|
||||
Protected,
|
||||
|
||||
/// <summary>Package-private (default access).</summary>
|
||||
Package
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A call edge in the Java call graph.
|
||||
@@ -54,6 +76,7 @@ public sealed record JavaMethodNode(
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeMethodDigest">Method digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (invoke type).</param>
|
||||
/// <param name="EdgeReason">Semantic reason for the edge (DirectCall, VirtualCall, etc.).</param>
|
||||
/// <param name="BytecodeOffset">Bytecode offset where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
@@ -64,6 +87,7 @@ public sealed record JavaCallEdge(
|
||||
string? CalleePurl,
|
||||
string? CalleeMethodDigest,
|
||||
JavaEdgeType EdgeType,
|
||||
JavaEdgeReason EdgeReason,
|
||||
int BytecodeOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
@@ -98,6 +122,46 @@ public enum JavaEdgeType
|
||||
Constructor,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic reason for why a Java edge exists.
|
||||
/// Maps to the schema's EdgeReason enum for explainability.
|
||||
/// </summary>
|
||||
public enum JavaEdgeReason
|
||||
{
|
||||
/// <summary>Direct static method call (invokestatic).</summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>Virtual method dispatch (invokevirtual, invokeinterface).</summary>
|
||||
VirtualCall,
|
||||
|
||||
/// <summary>Reflection-based invocation (Class.forName, Method.invoke).</summary>
|
||||
ReflectionString,
|
||||
|
||||
/// <summary>Dependency injection binding (Spring, Guice).</summary>
|
||||
DiBinding,
|
||||
|
||||
/// <summary>Dynamic lambda or method reference (invokedynamic).</summary>
|
||||
DynamicImport,
|
||||
|
||||
/// <summary>Constructor/object instantiation (invokespecial <init>).</summary>
|
||||
NewObj,
|
||||
|
||||
/// <summary>Super or private method call (invokespecial non-init).</summary>
|
||||
SuperCall,
|
||||
|
||||
/// <summary>ServiceLoader-based service discovery.</summary>
|
||||
ServiceLoader,
|
||||
|
||||
/// <summary>Interface method dispatch.</summary>
|
||||
InterfaceCall,
|
||||
|
||||
/// <summary>Native interop (JNI).</summary>
|
||||
NativeInterop,
|
||||
|
||||
/// <summary>Reason could not be determined.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the Java call graph.
|
||||
/// </summary>
|
||||
|
||||
@@ -258,6 +258,9 @@ internal sealed class NativeCallgraphBuilder
|
||||
var isResolved = targetSym.Value != 0 || targetSym.SectionIndex != 0;
|
||||
var calleePurl = isResolved ? GeneratePurl(elf.Path, targetSym.Name) : null;
|
||||
|
||||
// Determine edge reason based on whether target is external
|
||||
var edgeReason = isResolved ? NativeEdgeReason.DirectCall : NativeEdgeReason.NativeInterop;
|
||||
|
||||
_edges.Add(new NativeCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
@@ -265,6 +268,7 @@ internal sealed class NativeCallgraphBuilder
|
||||
CalleePurl: calleePurl,
|
||||
CalleeSymbolDigest: calleeDigest,
|
||||
EdgeType: NativeEdgeType.Relocation,
|
||||
EdgeReason: edgeReason,
|
||||
CallSiteOffset: reloc.Offset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.5));
|
||||
@@ -321,6 +325,7 @@ internal sealed class NativeCallgraphBuilder
|
||||
CalleePurl: GeneratePurl(elf.Path, targetSym.Name),
|
||||
CalleeSymbolDigest: targetDigest,
|
||||
EdgeType: NativeEdgeType.InitArray,
|
||||
EdgeReason: NativeEdgeReason.InitCallback,
|
||||
CallSiteOffset: (ulong)idx,
|
||||
IsResolved: true,
|
||||
Confidence: 1.0));
|
||||
|
||||
@@ -49,6 +49,7 @@ public sealed record NativeFunctionNode(
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeSymbolDigest">Symbol digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (direct, plt, got, reloc).</param>
|
||||
/// <param name="EdgeReason">Semantic reason for the edge (DirectCall, NativeInterop, etc.).</param>
|
||||
/// <param name="CallSiteOffset">Offset within caller where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
@@ -59,10 +60,30 @@ public sealed record NativeCallEdge(
|
||||
string? CalleePurl,
|
||||
string? CalleeSymbolDigest,
|
||||
NativeEdgeType EdgeType,
|
||||
NativeEdgeReason EdgeReason,
|
||||
ulong CallSiteOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Semantic reason for why a native edge exists.
|
||||
/// Maps to the schema's EdgeReason enum for explainability.
|
||||
/// </summary>
|
||||
public enum NativeEdgeReason
|
||||
{
|
||||
/// <summary>Direct function call within the same binary.</summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>Call through PLT/GOT to external library (native interop).</summary>
|
||||
NativeInterop,
|
||||
|
||||
/// <summary>Initialization or finalization callback.</summary>
|
||||
InitCallback,
|
||||
|
||||
/// <summary>Indirect call through function pointer (unknown target).</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of call edge.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for offline kit operations.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:OfflineKit";
|
||||
|
||||
/// <summary>
|
||||
/// Enables offline kit operations for this host.
|
||||
/// Default: false (opt-in)
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, import fails if DSSE/Rekor verification fails.
|
||||
/// When false, verification failures are logged as warnings but import proceeds.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool RequireDsse { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, Rekor verification uses only local snapshots.
|
||||
/// No online Rekor API calls are attempted.
|
||||
/// Default: true (for air-gap safety)
|
||||
/// </summary>
|
||||
public bool RekorOfflineMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// URL of the internal attestation verifier service.
|
||||
/// Optional; if not set, verification is performed locally.
|
||||
/// </summary>
|
||||
public string? AttestationVerifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchors for signature verification.
|
||||
/// Matched by PURL pattern; first match wins.
|
||||
/// </summary>
|
||||
public List<TrustAnchorConfig> TrustAnchors { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Path to directory containing trust root public keys.
|
||||
/// Keys are loaded by keyid reference from <see cref="TrustAnchors"/>.
|
||||
/// </summary>
|
||||
public string? TrustRootDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to offline Rekor snapshot directory.
|
||||
/// Contains checkpoint.sig and entries/*.jsonl
|
||||
/// </summary>
|
||||
public string? RekorSnapshotDirectory { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
public sealed class OfflineKitOptionsValidator : IValidateOptions<OfflineKitOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, OfflineKitOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("OfflineKit options must be provided.");
|
||||
}
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AttestationVerifier))
|
||||
{
|
||||
if (!Uri.TryCreate(options.AttestationVerifier, UriKind.Absolute, out _))
|
||||
{
|
||||
errors.Add("AttestationVerifier must be an absolute URI when provided.");
|
||||
}
|
||||
}
|
||||
|
||||
options.TrustAnchors ??= new List<TrustAnchorConfig>();
|
||||
|
||||
if (options.RequireDsse && options.TrustAnchors.Count == 0)
|
||||
{
|
||||
errors.Add("RequireDsse is true but no TrustAnchors are configured.");
|
||||
}
|
||||
|
||||
if (options.TrustAnchors.Count > 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.TrustRootDirectory))
|
||||
{
|
||||
errors.Add("TrustRootDirectory must be configured when TrustAnchors are present.");
|
||||
}
|
||||
else if (!Directory.Exists(options.TrustRootDirectory))
|
||||
{
|
||||
errors.Add($"TrustRootDirectory does not exist: {options.TrustRootDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.RekorOfflineMode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.RekorSnapshotDirectory))
|
||||
{
|
||||
errors.Add("RekorSnapshotDirectory must be configured when RekorOfflineMode is enabled.");
|
||||
}
|
||||
else if (!Directory.Exists(options.RekorSnapshotDirectory))
|
||||
{
|
||||
errors.Add($"RekorSnapshotDirectory does not exist: {options.RekorSnapshotDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var anchor in options.TrustAnchors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(anchor.AnchorId))
|
||||
{
|
||||
errors.Add("TrustAnchor has empty AnchorId.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(anchor.PurlPattern))
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' has empty PurlPattern.");
|
||||
}
|
||||
|
||||
anchor.AllowedKeyIds ??= new List<string>();
|
||||
if (anchor.AllowedKeyIds.Count == 0)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' has no AllowedKeyIds.");
|
||||
}
|
||||
|
||||
if (anchor.MinSignatures < 1)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' MinSignatures must be >= 1.");
|
||||
}
|
||||
else if (anchor.AllowedKeyIds.Count > 0 && anchor.MinSignatures > anchor.AllowedKeyIds.Count)
|
||||
{
|
||||
errors.Add(
|
||||
$"TrustAnchor '{anchor.AnchorId}' MinSignatures ({anchor.MinSignatures}) exceeds AllowedKeyIds count ({anchor.AllowedKeyIds.Count}).");
|
||||
}
|
||||
|
||||
foreach (var keyId in anchor.AllowedKeyIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' contains an empty AllowedKeyId entry.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = TrustAnchorRegistry.NormalizeKeyId(keyId);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' contains an empty AllowedKeyId entry.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
|| normalized.Contains(Path.DirectorySeparatorChar)
|
||||
|| normalized.Contains(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' contains invalid AllowedKeyId '{keyId}'.");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = new PurlPatternMatcher(anchor.PurlPattern);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' has invalid PurlPattern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var duplicateIds = options.TrustAnchors
|
||||
.Where(anchor => !string.IsNullOrWhiteSpace(anchor.AnchorId))
|
||||
.GroupBy(anchor => anchor.AnchorId.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(grouping => grouping.Count() > 1)
|
||||
.Select(grouping => grouping.Key)
|
||||
.ToList();
|
||||
|
||||
if (duplicateIds.Count > 0)
|
||||
{
|
||||
errors.Add($"Duplicate TrustAnchor AnchorIds: {string.Join(", ", duplicateIds)}");
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidateOptionsResult.Fail(errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor configuration for ecosystem-specific signing authorities.
|
||||
/// </summary>
|
||||
public sealed class TrustAnchorConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this trust anchor.
|
||||
/// Used in audit logs and error messages.
|
||||
/// </summary>
|
||||
public string AnchorId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// PURL pattern to match against.
|
||||
/// Supports glob patterns: "pkg:npm/*", "pkg:maven/org.apache.*", "*".
|
||||
/// Patterns are matched in order; first match wins.
|
||||
/// </summary>
|
||||
public string PurlPattern { get; set; } = "*";
|
||||
|
||||
/// <summary>
|
||||
/// List of allowed key fingerprints (SHA-256 of public key).
|
||||
/// Format: "sha256:hexstring" or just "hexstring".
|
||||
/// </summary>
|
||||
public List<string> AllowedKeyIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional description for documentation/UI purposes.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this anchor expires. Null = no expiry.
|
||||
/// After expiry, anchor is skipped with a warning.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum required signatures from this anchor.
|
||||
/// Default: 1 (at least one key must sign)
|
||||
/// </summary>
|
||||
public int MinSignatures { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Drift;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates FN-Drift rate with stratification.
|
||||
/// </summary>
|
||||
public sealed class FnDriftCalculator
|
||||
{
|
||||
private readonly IClassificationHistoryRepository _repository;
|
||||
private readonly ILogger<FnDriftCalculator> _logger;
|
||||
|
||||
public FnDriftCalculator(
|
||||
IClassificationHistoryRepository repository,
|
||||
ILogger<FnDriftCalculator> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes FN-Drift for a tenant over a rolling window.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant to calculate for</param>
|
||||
/// <param name="windowDays">Rolling window in days (default: 30)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>FN-Drift summary with stratification</returns>
|
||||
public async Task<FnDrift30dSummary> CalculateAsync(
|
||||
Guid tenantId,
|
||||
int windowDays = 30,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-windowDays);
|
||||
var changes = await _repository.GetChangesAsync(tenantId, since, cancellationToken);
|
||||
|
||||
var fnTransitions = changes.Where(c => c.IsFnTransition).ToList();
|
||||
var totalEvaluated = changes.Count;
|
||||
|
||||
var summary = new FnDrift30dSummary
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TotalFnTransitions = fnTransitions.Count,
|
||||
TotalEvaluated = totalEvaluated,
|
||||
FnDriftPercent = totalEvaluated > 0
|
||||
? Math.Round((decimal)fnTransitions.Count / totalEvaluated * 100, 4)
|
||||
: 0,
|
||||
FeedCaused = fnTransitions.Count(c => c.Cause == DriftCause.FeedDelta),
|
||||
RuleCaused = fnTransitions.Count(c => c.Cause == DriftCause.RuleDelta),
|
||||
LatticeCaused = fnTransitions.Count(c => c.Cause == DriftCause.LatticeDelta),
|
||||
ReachabilityCaused = fnTransitions.Count(c => c.Cause == DriftCause.ReachabilityDelta),
|
||||
EngineCaused = fnTransitions.Count(c => c.Cause == DriftCause.Engine)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"FN-Drift for tenant {TenantId}: {Percent}% ({FnCount}/{Total}), " +
|
||||
"Feed={Feed}, Rule={Rule}, Lattice={Lattice}, Reach={Reach}, Engine={Engine}",
|
||||
tenantId, summary.FnDriftPercent, summary.TotalFnTransitions, summary.TotalEvaluated,
|
||||
summary.FeedCaused, summary.RuleCaused, summary.LatticeCaused,
|
||||
summary.ReachabilityCaused, summary.EngineCaused);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the drift cause for a classification change.
|
||||
/// </summary>
|
||||
public DriftCause DetermineCause(
|
||||
string? previousFeedVersion,
|
||||
string? currentFeedVersion,
|
||||
string? previousRuleHash,
|
||||
string? currentRuleHash,
|
||||
string? previousLatticeHash,
|
||||
string? currentLatticeHash,
|
||||
bool? previousReachable,
|
||||
bool? currentReachable)
|
||||
{
|
||||
// Priority order: feed > rule > lattice > reachability > engine > other
|
||||
|
||||
// Check feed delta
|
||||
if (!string.Equals(previousFeedVersion, currentFeedVersion, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: feed_delta (prev={PrevFeed}, curr={CurrFeed})",
|
||||
previousFeedVersion, currentFeedVersion);
|
||||
return DriftCause.FeedDelta;
|
||||
}
|
||||
|
||||
// Check rule delta
|
||||
if (!string.Equals(previousRuleHash, currentRuleHash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: rule_delta (prev={PrevRule}, curr={CurrRule})",
|
||||
previousRuleHash, currentRuleHash);
|
||||
return DriftCause.RuleDelta;
|
||||
}
|
||||
|
||||
// Check lattice delta
|
||||
if (!string.Equals(previousLatticeHash, currentLatticeHash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: lattice_delta (prev={PrevLattice}, curr={CurrLattice})",
|
||||
previousLatticeHash, currentLatticeHash);
|
||||
return DriftCause.LatticeDelta;
|
||||
}
|
||||
|
||||
// Check reachability delta
|
||||
if (previousReachable != currentReachable)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: reachability_delta (prev={PrevReach}, curr={CurrReach})",
|
||||
previousReachable, currentReachable);
|
||||
return DriftCause.ReachabilityDelta;
|
||||
}
|
||||
|
||||
// If nothing external changed, it's an engine change or unknown
|
||||
_logger.LogDebug("Drift cause: other (no external cause identified)");
|
||||
return DriftCause.Other;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ClassificationChange record for a status transition.
|
||||
/// </summary>
|
||||
public ClassificationChange CreateChange(
|
||||
string artifactDigest,
|
||||
string vulnId,
|
||||
string packagePurl,
|
||||
Guid tenantId,
|
||||
Guid manifestId,
|
||||
Guid executionId,
|
||||
ClassificationStatus previousStatus,
|
||||
ClassificationStatus newStatus,
|
||||
DriftCause cause,
|
||||
IReadOnlyDictionary<string, string>? causeDetail = null)
|
||||
{
|
||||
return new ClassificationChange
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
VulnId = vulnId,
|
||||
PackagePurl = packagePurl,
|
||||
TenantId = tenantId,
|
||||
ManifestId = manifestId,
|
||||
ExecutionId = executionId,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = newStatus,
|
||||
Cause = cause,
|
||||
CauseDetail = causeDetail,
|
||||
ChangedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the FN-Drift rate exceeds the threshold.
|
||||
/// </summary>
|
||||
/// <param name="summary">The drift summary to check</param>
|
||||
/// <param name="thresholdPercent">Maximum acceptable FN-Drift rate (default: 5%)</param>
|
||||
/// <returns>True if drift rate exceeds threshold</returns>
|
||||
public bool ExceedsThreshold(FnDrift30dSummary summary, decimal thresholdPercent = 5.0m)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
var exceeds = summary.FnDriftPercent > thresholdPercent;
|
||||
|
||||
if (exceeds)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"FN-Drift for tenant {TenantId} exceeds threshold: {Percent}% > {Threshold}%",
|
||||
summary.TenantId, summary.FnDriftPercent, thresholdPercent);
|
||||
}
|
||||
|
||||
return exceeds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
public sealed class FileSystemPublicKeyLoader : IPublicKeyLoader
|
||||
{
|
||||
private static readonly string[] CandidateExtensions =
|
||||
{
|
||||
string.Empty,
|
||||
".pub",
|
||||
".pem",
|
||||
".der"
|
||||
};
|
||||
|
||||
public byte[]? LoadKey(string keyId, string? keyDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(keyDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keyId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
|| keyId.Contains(Path.DirectorySeparatorChar)
|
||||
|| keyId.Contains(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var extension in CandidateExtensions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(keyDirectory, keyId + extension);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
return TryParsePemPublicKey(bytes) ?? bytes;
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryParsePemPublicKey(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string Begin = "-----BEGIN PUBLIC KEY-----";
|
||||
const string End = "-----END PUBLIC KEY-----";
|
||||
|
||||
var beginIndex = text.IndexOf(Begin, StringComparison.Ordinal);
|
||||
if (beginIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endIndex = text.IndexOf(End, StringComparison.Ordinal);
|
||||
if (endIndex <= beginIndex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var base64 = text
|
||||
.Substring(beginIndex + Begin.Length, endIndex - (beginIndex + Begin.Length))
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal)
|
||||
.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
public interface IPublicKeyLoader
|
||||
{
|
||||
byte[]? LoadKey(string keyId, string? keyDirectory);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
public interface ITrustAnchorRegistry
|
||||
{
|
||||
TrustAnchorResolution? ResolveForPurl(string purl);
|
||||
|
||||
IReadOnlyList<TrustAnchorConfig> GetAllAnchors();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Package URLs against glob patterns.
|
||||
/// Supports:
|
||||
/// - Exact match: "pkg:npm/@scope/package@1.0.0"
|
||||
/// - Prefix wildcard: "pkg:npm/*"
|
||||
/// - Infix wildcard: "pkg:maven/org.apache.*"
|
||||
/// - Universal: "*"
|
||||
/// </summary>
|
||||
public sealed class PurlPatternMatcher
|
||||
{
|
||||
private readonly string _pattern;
|
||||
private readonly Regex _regex;
|
||||
|
||||
public PurlPatternMatcher(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
throw new ArgumentException("Pattern cannot be empty.", nameof(pattern));
|
||||
}
|
||||
|
||||
_pattern = pattern.Trim();
|
||||
_regex = CompilePattern(_pattern);
|
||||
}
|
||||
|
||||
public bool IsMatch(string? purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _regex.IsMatch(purl);
|
||||
}
|
||||
|
||||
public string Pattern => _pattern;
|
||||
|
||||
private static Regex CompilePattern(string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return new Regex("^.*$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
var escaped = Regex.Escape(pattern);
|
||||
escaped = escaped.Replace(@"\*", ".*", StringComparison.Ordinal);
|
||||
return new Regex($"^{escaped}$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for trust anchors with PURL-based resolution.
|
||||
/// Thread-safe and supports runtime reload.
|
||||
/// </summary>
|
||||
public sealed class TrustAnchorRegistry : ITrustAnchorRegistry
|
||||
{
|
||||
private readonly IOptionsMonitor<OfflineKitOptions> _options;
|
||||
private readonly IPublicKeyLoader _keyLoader;
|
||||
private readonly ILogger<TrustAnchorRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private IReadOnlyList<CompiledTrustAnchor>? _compiledAnchors;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public TrustAnchorRegistry(
|
||||
IOptionsMonitor<OfflineKitOptions> options,
|
||||
IPublicKeyLoader keyLoader,
|
||||
ILogger<TrustAnchorRegistry> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_keyLoader = keyLoader ?? throw new ArgumentNullException(nameof(keyLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
|
||||
_options.OnChange(_ => InvalidateCache());
|
||||
}
|
||||
|
||||
public TrustAnchorResolution? ResolveForPurl(string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_options.CurrentValue.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var anchors = GetCompiledAnchors();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
if (!anchor.Matcher.IsMatch(purl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (anchor.Config.ExpiresAt is { } expiresAt && expiresAt < now)
|
||||
{
|
||||
_logger.LogWarning("Trust anchor {AnchorId} has expired, skipping.", anchor.Config.AnchorId);
|
||||
continue;
|
||||
}
|
||||
|
||||
return new TrustAnchorResolution(
|
||||
AnchorId: anchor.Config.AnchorId,
|
||||
AllowedKeyIds: anchor.AllowedKeyIds,
|
||||
MinSignatures: anchor.Config.MinSignatures,
|
||||
PublicKeys: anchor.LoadedKeys);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<TrustAnchorConfig> GetAllAnchors()
|
||||
=> _options.CurrentValue.TrustAnchors.AsReadOnly();
|
||||
|
||||
private IReadOnlyList<CompiledTrustAnchor> GetCompiledAnchors()
|
||||
{
|
||||
if (_compiledAnchors is not null)
|
||||
{
|
||||
return _compiledAnchors;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_compiledAnchors is not null)
|
||||
{
|
||||
return _compiledAnchors;
|
||||
}
|
||||
|
||||
var config = _options.CurrentValue;
|
||||
config.TrustAnchors ??= new List<TrustAnchorConfig>();
|
||||
|
||||
var compiled = new List<CompiledTrustAnchor>(config.TrustAnchors.Count);
|
||||
foreach (var anchor in config.TrustAnchors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var matcher = new PurlPatternMatcher(anchor.PurlPattern);
|
||||
var (allowedKeyIds, keys) = LoadKeysForAnchor(anchor, config.TrustRootDirectory);
|
||||
compiled.Add(new CompiledTrustAnchor(anchor, matcher, allowedKeyIds, keys));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to compile trust anchor {AnchorId}.", anchor.AnchorId);
|
||||
}
|
||||
}
|
||||
|
||||
_compiledAnchors = compiled.AsReadOnly();
|
||||
return _compiledAnchors;
|
||||
}
|
||||
}
|
||||
|
||||
private (IReadOnlyList<string> AllowedKeyIds, IReadOnlyDictionary<string, byte[]> LoadedKeys) LoadKeysForAnchor(
|
||||
TrustAnchorConfig anchor,
|
||||
string? keyDirectory)
|
||||
{
|
||||
var normalizedKeyIds = new List<string>(anchor.AllowedKeyIds.Count);
|
||||
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var configuredKeyId in anchor.AllowedKeyIds)
|
||||
{
|
||||
var normalizedKeyId = NormalizeKeyId(configuredKeyId);
|
||||
if (string.IsNullOrWhiteSpace(normalizedKeyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedKeyIds.Add(normalizedKeyId);
|
||||
|
||||
var keyBytes = _keyLoader.LoadKey(normalizedKeyId, keyDirectory);
|
||||
if (keyBytes is null)
|
||||
{
|
||||
_logger.LogWarning("Key {KeyId} not found for anchor {AnchorId}.", configuredKeyId, anchor.AnchorId);
|
||||
continue;
|
||||
}
|
||||
|
||||
keys[normalizedKeyId] = keyBytes;
|
||||
keys[$"sha256:{normalizedKeyId}"] = keyBytes;
|
||||
}
|
||||
|
||||
return (normalizedKeyIds.AsReadOnly(), keys);
|
||||
}
|
||||
|
||||
internal static string NormalizeKeyId(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = keyId.Trim();
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = trimmed[7..];
|
||||
}
|
||||
|
||||
trimmed = trimmed.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return LooksLikeHex(trimmed)
|
||||
? trimmed.ToLowerInvariant()
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
private static bool LooksLikeHex(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var isHex = (character >= '0' && character <= '9')
|
||||
|| (character >= 'a' && character <= 'f')
|
||||
|| (character >= 'A' && character <= 'F');
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void InvalidateCache()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_compiledAnchors = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CompiledTrustAnchor(
|
||||
TrustAnchorConfig Config,
|
||||
PurlPatternMatcher Matcher,
|
||||
IReadOnlyList<string> AllowedKeyIds,
|
||||
IReadOnlyDictionary<string, byte[]> LoadedKeys);
|
||||
}
|
||||
|
||||
public sealed record TrustAnchorResolution(
|
||||
string AnchorId,
|
||||
IReadOnlyList<string> AllowedKeyIds,
|
||||
int MinSignatures,
|
||||
IReadOnlyDictionary<string, byte[]> PublicKeys);
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace StellaOps.Scanner.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a classification status change for FN-Drift tracking.
|
||||
/// </summary>
|
||||
public sealed record ClassificationChange
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
// Artifact identification
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
|
||||
// Scan context
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid ManifestId { get; init; }
|
||||
public required Guid ExecutionId { get; init; }
|
||||
|
||||
// Status transition
|
||||
public required ClassificationStatus PreviousStatus { get; init; }
|
||||
public required ClassificationStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this was a false-negative transition (unaffected/unknown -> affected)
|
||||
/// </summary>
|
||||
public bool IsFnTransition =>
|
||||
PreviousStatus is ClassificationStatus.Unaffected or ClassificationStatus.Unknown
|
||||
&& NewStatus == ClassificationStatus.Affected;
|
||||
|
||||
// Drift cause
|
||||
public required DriftCause Cause { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? CauseDetail { get; init; }
|
||||
|
||||
// Timestamp
|
||||
public DateTimeOffset ChangedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification status values.
|
||||
/// </summary>
|
||||
public enum ClassificationStatus
|
||||
{
|
||||
/// <summary>First scan, no previous status</summary>
|
||||
New,
|
||||
|
||||
/// <summary>Confirmed not affected</summary>
|
||||
Unaffected,
|
||||
|
||||
/// <summary>Status unknown/uncertain</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Confirmed affected</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Previously affected, now fixed</summary>
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stratification causes for FN-Drift analysis.
|
||||
/// </summary>
|
||||
public enum DriftCause
|
||||
{
|
||||
/// <summary>Vulnerability feed updated (NVD, GHSA, OVAL)</summary>
|
||||
FeedDelta,
|
||||
|
||||
/// <summary>Policy rules changed</summary>
|
||||
RuleDelta,
|
||||
|
||||
/// <summary>VEX lattice state changed</summary>
|
||||
LatticeDelta,
|
||||
|
||||
/// <summary>Reachability analysis changed</summary>
|
||||
ReachabilityDelta,
|
||||
|
||||
/// <summary>Scanner engine change (should be ~0)</summary>
|
||||
Engine,
|
||||
|
||||
/// <summary>Other/unknown cause</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FN-Drift statistics for a time period.
|
||||
/// </summary>
|
||||
public sealed record FnDriftStats
|
||||
{
|
||||
public required DateOnly DayBucket { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required DriftCause Cause { get; init; }
|
||||
|
||||
public required int TotalReclassified { get; init; }
|
||||
public required int FnCount { get; init; }
|
||||
public required decimal FnDriftPercent { get; init; }
|
||||
|
||||
// Stratification counts
|
||||
public required int FeedDeltaCount { get; init; }
|
||||
public required int RuleDeltaCount { get; init; }
|
||||
public required int LatticeDeltaCount { get; init; }
|
||||
public required int ReachabilityDeltaCount { get; init; }
|
||||
public required int EngineCount { get; init; }
|
||||
public required int OtherCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 30-day rolling FN-Drift summary.
|
||||
/// </summary>
|
||||
public sealed record FnDrift30dSummary
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required int TotalFnTransitions { get; init; }
|
||||
public required int TotalEvaluated { get; init; }
|
||||
public required decimal FnDriftPercent { get; init; }
|
||||
|
||||
// Stratification breakdown
|
||||
public required int FeedCaused { get; init; }
|
||||
public required int RuleCaused { get; init; }
|
||||
public required int LatticeCaused { get; init; }
|
||||
public required int ReachabilityCaused { get; init; }
|
||||
public required int EngineCaused { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Classification history for FN-Drift tracking
|
||||
-- Per advisory section 13.2
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- Artifact identification
|
||||
artifact_digest TEXT NOT NULL,
|
||||
vuln_id TEXT NOT NULL,
|
||||
package_purl TEXT NOT NULL,
|
||||
|
||||
-- Scan context
|
||||
tenant_id UUID NOT NULL,
|
||||
manifest_id UUID NOT NULL,
|
||||
execution_id UUID NOT NULL,
|
||||
|
||||
-- Status transition
|
||||
previous_status TEXT NOT NULL, -- 'new', 'unaffected', 'unknown', 'affected', 'fixed'
|
||||
new_status TEXT NOT NULL,
|
||||
is_fn_transition BOOLEAN NOT NULL GENERATED ALWAYS AS (
|
||||
previous_status IN ('unaffected', 'unknown') AND new_status = 'affected'
|
||||
) STORED,
|
||||
|
||||
-- Drift cause classification
|
||||
cause TEXT NOT NULL, -- 'feed_delta', 'rule_delta', 'lattice_delta', 'reachability_delta', 'engine', 'other'
|
||||
cause_detail JSONB, -- Additional context (e.g., feed version, rule hash)
|
||||
|
||||
-- Timestamps
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_previous_status CHECK (previous_status IN ('new', 'unaffected', 'unknown', 'affected', 'fixed')),
|
||||
CONSTRAINT valid_new_status CHECK (new_status IN ('unaffected', 'unknown', 'affected', 'fixed')),
|
||||
CONSTRAINT valid_cause CHECK (cause IN ('feed_delta', 'rule_delta', 'lattice_delta', 'reachability_delta', 'engine', 'other'))
|
||||
);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_artifact ON classification_history(artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_tenant ON classification_history(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_changed_at ON classification_history(changed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_fn_transition ON classification_history(is_fn_transition) WHERE is_fn_transition = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_cause ON classification_history(cause);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_vuln ON classification_history(vuln_id);
|
||||
|
||||
COMMENT ON TABLE classification_history IS 'Tracks vulnerability classification changes for FN-Drift analysis';
|
||||
COMMENT ON COLUMN classification_history.is_fn_transition IS 'True if this was a false-negative transition (unaffected/unknown -> affected)';
|
||||
COMMENT ON COLUMN classification_history.cause IS 'Stratification cause: feed_delta, rule_delta, lattice_delta, reachability_delta, engine, other';
|
||||
|
||||
-- Materialized view for FN-Drift statistics
|
||||
-- Aggregates classification_history for dashboard queries
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS fn_drift_stats AS
|
||||
SELECT
|
||||
date_trunc('day', changed_at)::date AS day_bucket,
|
||||
tenant_id,
|
||||
cause,
|
||||
|
||||
-- Total reclassifications
|
||||
COUNT(*) AS total_reclassified,
|
||||
|
||||
-- FN transitions (unaffected/unknown -> affected)
|
||||
COUNT(*) FILTER (WHERE is_fn_transition) AS fn_count,
|
||||
|
||||
-- FN-Drift rate
|
||||
ROUND(
|
||||
(COUNT(*) FILTER (WHERE is_fn_transition)::numeric /
|
||||
NULLIF(COUNT(*), 0)) * 100, 4
|
||||
) AS fn_drift_percent,
|
||||
|
||||
-- Stratification counts
|
||||
COUNT(*) FILTER (WHERE cause = 'feed_delta') AS feed_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'rule_delta') AS rule_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'lattice_delta') AS lattice_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'reachability_delta') AS reachability_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'engine') AS engine_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'other') AS other_count
|
||||
|
||||
FROM classification_history
|
||||
GROUP BY date_trunc('day', changed_at)::date, tenant_id, cause;
|
||||
|
||||
-- Index for efficient queries
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_fn_drift_stats_pk ON fn_drift_stats(day_bucket, tenant_id, cause);
|
||||
CREATE INDEX IF NOT EXISTS idx_fn_drift_stats_tenant ON fn_drift_stats(tenant_id);
|
||||
|
||||
-- View for 30-day rolling FN-Drift (per advisory definition)
|
||||
CREATE OR REPLACE VIEW fn_drift_30d AS
|
||||
SELECT
|
||||
tenant_id,
|
||||
SUM(fn_count)::int AS total_fn_transitions,
|
||||
SUM(total_reclassified)::int AS total_evaluated,
|
||||
ROUND(
|
||||
(SUM(fn_count)::numeric / NULLIF(SUM(total_reclassified), 0)) * 100, 4
|
||||
) AS fn_drift_percent,
|
||||
|
||||
-- Stratification breakdown
|
||||
SUM(feed_delta_count)::int AS feed_caused,
|
||||
SUM(rule_delta_count)::int AS rule_caused,
|
||||
SUM(lattice_delta_count)::int AS lattice_caused,
|
||||
SUM(reachability_delta_count)::int AS reachability_caused,
|
||||
SUM(engine_count)::int AS engine_caused
|
||||
|
||||
FROM fn_drift_stats
|
||||
WHERE day_bucket >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY tenant_id;
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW fn_drift_stats IS 'Daily FN-Drift statistics, refresh periodically';
|
||||
COMMENT ON VIEW fn_drift_30d IS 'Rolling 30-day FN-Drift rate per tenant';
|
||||
@@ -4,4 +4,5 @@ internal static class MigrationIds
|
||||
{
|
||||
public const string CreateTables = "001_create_tables.sql";
|
||||
public const string ProofSpineTables = "002_proof_spine_tables.sql";
|
||||
public const string ClassificationHistory = "003_classification_history.sql";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of classification history repository.
|
||||
/// </summary>
|
||||
public sealed class ClassificationHistoryRepository : RepositoryBase<ScannerDataSource>, IClassificationHistoryRepository
|
||||
{
|
||||
private const string Tenant = "";
|
||||
private string Table => $"{SchemaName}.classification_history";
|
||||
private string DriftStatsView => $"{SchemaName}.fn_drift_stats";
|
||||
private string Drift30dView => $"{SchemaName}.fn_drift_30d";
|
||||
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public ClassificationHistoryRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<ClassificationHistoryRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {Table}
|
||||
(artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, cause, cause_detail, changed_at)
|
||||
VALUES
|
||||
(@artifact_digest, @vuln_id, @package_purl, @tenant_id, @manifest_id, @execution_id,
|
||||
@previous_status, @new_status, @cause, @cause_detail::jsonb, @changed_at)
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddChangeParameters(cmd, change),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
|
||||
var changeList = changes.ToList();
|
||||
if (changeList.Count == 0) return;
|
||||
|
||||
// Use batch insert for better performance
|
||||
foreach (var change in changeList)
|
||||
{
|
||||
await InsertAsync(change, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE tenant_id = @tenant_id AND changed_at >= @since
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "since", since);
|
||||
},
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE artifact_digest = @artifact_digest
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "artifact_digest", artifactDigest),
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(
|
||||
string vulnId,
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnId);
|
||||
|
||||
var sql = tenantId.HasValue
|
||||
? $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE vuln_id = @vuln_id AND tenant_id = @tenant_id
|
||||
ORDER BY changed_at DESC
|
||||
"""
|
||||
: $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE vuln_id = @vuln_id
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vuln_id", vulnId);
|
||||
if (tenantId.HasValue)
|
||||
AddParameter(cmd, "tenant_id", tenantId.Value);
|
||||
},
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(
|
||||
Guid tenantId,
|
||||
DateOnly fromDate,
|
||||
DateOnly toDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
|
||||
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
|
||||
engine_count, other_count
|
||||
FROM {DriftStatsView}
|
||||
WHERE tenant_id = @tenant_id AND day_bucket >= @from_date AND day_bucket <= @to_date
|
||||
ORDER BY day_bucket DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from_date", fromDate);
|
||||
AddParameter(cmd, "to_date", toDate);
|
||||
},
|
||||
MapDriftStats,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT tenant_id, total_fn_transitions, total_evaluated, fn_drift_percent,
|
||||
feed_caused, rule_caused, lattice_caused, reachability_caused, engine_caused
|
||||
FROM {Drift30dView}
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapDrift30dSummary,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"REFRESH MATERIALIZED VIEW CONCURRENTLY {DriftStatsView}";
|
||||
|
||||
await ExecuteAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
static _ => { },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private void AddChangeParameters(NpgsqlCommand cmd, ClassificationChange change)
|
||||
{
|
||||
AddParameter(cmd, "artifact_digest", change.ArtifactDigest);
|
||||
AddParameter(cmd, "vuln_id", change.VulnId);
|
||||
AddParameter(cmd, "package_purl", change.PackagePurl);
|
||||
AddParameter(cmd, "tenant_id", change.TenantId);
|
||||
AddParameter(cmd, "manifest_id", change.ManifestId);
|
||||
AddParameter(cmd, "execution_id", change.ExecutionId);
|
||||
AddParameter(cmd, "previous_status", MapStatusToString(change.PreviousStatus));
|
||||
AddParameter(cmd, "new_status", MapStatusToString(change.NewStatus));
|
||||
AddParameter(cmd, "cause", MapCauseToString(change.Cause));
|
||||
AddParameter(cmd, "cause_detail", change.CauseDetail != null
|
||||
? JsonSerializer.Serialize(change.CauseDetail, JsonOptions)
|
||||
: null);
|
||||
AddParameter(cmd, "changed_at", change.ChangedAt);
|
||||
}
|
||||
|
||||
private static ClassificationChange MapChange(NpgsqlDataReader reader)
|
||||
{
|
||||
var causeDetailJson = reader.IsDBNull(11) ? null : reader.GetString(11);
|
||||
var causeDetail = causeDetailJson != null
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(causeDetailJson, JsonOptions)
|
||||
: null;
|
||||
|
||||
return new ClassificationChange
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
ArtifactDigest = reader.GetString(1),
|
||||
VulnId = reader.GetString(2),
|
||||
PackagePurl = reader.GetString(3),
|
||||
TenantId = reader.GetGuid(4),
|
||||
ManifestId = reader.GetGuid(5),
|
||||
ExecutionId = reader.GetGuid(6),
|
||||
PreviousStatus = MapStringToStatus(reader.GetString(7)),
|
||||
NewStatus = MapStringToStatus(reader.GetString(8)),
|
||||
// is_fn_transition is at index 9, but we compute it from PreviousStatus/NewStatus
|
||||
Cause = MapStringToCause(reader.GetString(10)),
|
||||
CauseDetail = causeDetail,
|
||||
ChangedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}
|
||||
|
||||
private static FnDriftStats MapDriftStats(NpgsqlDataReader reader)
|
||||
{
|
||||
return new FnDriftStats
|
||||
{
|
||||
DayBucket = DateOnly.FromDateTime(reader.GetDateTime(0)),
|
||||
TenantId = reader.GetGuid(1),
|
||||
Cause = MapStringToCause(reader.GetString(2)),
|
||||
TotalReclassified = reader.GetInt32(3),
|
||||
FnCount = reader.GetInt32(4),
|
||||
FnDriftPercent = reader.GetDecimal(5),
|
||||
FeedDeltaCount = reader.GetInt32(6),
|
||||
RuleDeltaCount = reader.GetInt32(7),
|
||||
LatticeDeltaCount = reader.GetInt32(8),
|
||||
ReachabilityDeltaCount = reader.GetInt32(9),
|
||||
EngineCount = reader.GetInt32(10),
|
||||
OtherCount = reader.GetInt32(11)
|
||||
};
|
||||
}
|
||||
|
||||
private static FnDrift30dSummary MapDrift30dSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new FnDrift30dSummary
|
||||
{
|
||||
TenantId = reader.GetGuid(0),
|
||||
TotalFnTransitions = reader.GetInt32(1),
|
||||
TotalEvaluated = reader.GetInt32(2),
|
||||
FnDriftPercent = reader.IsDBNull(3) ? 0 : reader.GetDecimal(3),
|
||||
FeedCaused = reader.GetInt32(4),
|
||||
RuleCaused = reader.GetInt32(5),
|
||||
LatticeCaused = reader.GetInt32(6),
|
||||
ReachabilityCaused = reader.GetInt32(7),
|
||||
EngineCaused = reader.GetInt32(8)
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapStatusToString(ClassificationStatus status) => status switch
|
||||
{
|
||||
ClassificationStatus.New => "new",
|
||||
ClassificationStatus.Unaffected => "unaffected",
|
||||
ClassificationStatus.Unknown => "unknown",
|
||||
ClassificationStatus.Affected => "affected",
|
||||
ClassificationStatus.Fixed => "fixed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static ClassificationStatus MapStringToStatus(string status) => status switch
|
||||
{
|
||||
"new" => ClassificationStatus.New,
|
||||
"unaffected" => ClassificationStatus.Unaffected,
|
||||
"unknown" => ClassificationStatus.Unknown,
|
||||
"affected" => ClassificationStatus.Affected,
|
||||
"fixed" => ClassificationStatus.Fixed,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static string MapCauseToString(DriftCause cause) => cause switch
|
||||
{
|
||||
DriftCause.FeedDelta => "feed_delta",
|
||||
DriftCause.RuleDelta => "rule_delta",
|
||||
DriftCause.LatticeDelta => "lattice_delta",
|
||||
DriftCause.ReachabilityDelta => "reachability_delta",
|
||||
DriftCause.Engine => "engine",
|
||||
DriftCause.Other => "other",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cause))
|
||||
};
|
||||
|
||||
private static DriftCause MapStringToCause(string cause) => cause switch
|
||||
{
|
||||
"feed_delta" => DriftCause.FeedDelta,
|
||||
"rule_delta" => DriftCause.RuleDelta,
|
||||
"lattice_delta" => DriftCause.LatticeDelta,
|
||||
"reachability_delta" => DriftCause.ReachabilityDelta,
|
||||
"engine" => DriftCause.Engine,
|
||||
"other" => DriftCause.Other,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cause))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for classification history operations.
|
||||
/// </summary>
|
||||
public interface IClassificationHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a classification status change.
|
||||
/// </summary>
|
||||
Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records multiple classification changes in a batch.
|
||||
/// </summary>
|
||||
Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a tenant since a given date.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a specific artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a specific vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(
|
||||
string vulnId,
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets FN-Drift statistics from the materialized view.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(
|
||||
Guid tenantId,
|
||||
DateOnly fromDate,
|
||||
DateOnly toDate,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets 30-day rolling FN-Drift summary for a tenant.
|
||||
/// </summary>
|
||||
Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the FN-Drift statistics materialized view.
|
||||
/// </summary>
|
||||
Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "node",
|
||||
"componentKey": "observation::node-phase22",
|
||||
"name": "Node Observation (Phase 22)",
|
||||
"type": "node-observation",
|
||||
"usedByEntrypoint": false,
|
||||
"capabilities": [],
|
||||
"threatVectors": [],
|
||||
"metadata": {
|
||||
"node.observation.components": "2",
|
||||
"node.observation.edges": "2",
|
||||
"node.observation.entrypoints": "0",
|
||||
"node.observation.native": "1",
|
||||
"node.observation.wasm": "1"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "node.observation",
|
||||
"locator": "phase22.ndjson",
|
||||
"value": "{\u0022type\u0022:\u0022component\u0022,\u0022componentType\u0022:\u0022native\u0022,\u0022path\u0022:\u0022/native/addon.node\u0022,\u0022reason\u0022:\u0022native-addon-file\u0022,\u0022confidence\u0022:0.82,\u0022resolverTrace\u0022:[\u0022file:/native/addon.node\u0022],\u0022arch\u0022:\u0022x86_64\u0022,\u0022platform\u0022:\u0022linux\u0022}\r\n{\u0022type\u0022:\u0022component\u0022,\u0022componentType\u0022:\u0022wasm\u0022,\u0022path\u0022:\u0022/pkg/pkg.wasm\u0022,\u0022reason\u0022:\u0022wasm-file\u0022,\u0022confidence\u0022:0.8,\u0022resolverTrace\u0022:[\u0022file:/pkg/pkg.wasm\u0022]}\r\n{\u0022type\u0022:\u0022edge\u0022,\u0022edgeType\u0022:\u0022wasm\u0022,\u0022from\u0022:\u0022/src/app.js\u0022,\u0022to\u0022:\u0022/src/pkg/pkg.wasm\u0022,\u0022reason\u0022:\u0022wasm-import\u0022,\u0022confidence\u0022:0.74,\u0022resolverTrace\u0022:[\u0022source:/src/app.js\u0022,\u0022call:WebAssembly.instantiate(\\u0027./pkg/pkg.wasm\\u0027)\u0022]}\r\n{\u0022type\u0022:\u0022edge\u0022,\u0022edgeType\u0022:\u0022capability\u0022,\u0022from\u0022:\u0022/src/app.js\u0022,\u0022to\u0022:\u0022child_process.execFile\u0022,\u0022reason\u0022:\u0022capability-child-process\u0022,\u0022confidence\u0022:0.7,\u0022resolverTrace\u0022:[\u0022source:/src/app.js\u0022,\u0022call:child_process.execFile\u0022]}",
|
||||
"sha256": "1329f1c41716d8430b5bdb6d02d1d5f2be1be80877ac15a7e72d3a079fffa4fb"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests;
|
||||
|
||||
public sealed class OfflineKitOptionsValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WhenDisabled_SucceedsEvenWithDefaults()
|
||||
{
|
||||
var validator = new OfflineKitOptionsValidator();
|
||||
var result = validator.Validate(null, new OfflineKitOptions());
|
||||
Assert.Equal(ValidateOptionsResult.Success, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_RequiresRekorSnapshotDirectory()
|
||||
{
|
||||
var validator = new OfflineKitOptionsValidator();
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TrustAnchors = new List<TrustAnchorConfig>()
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, options);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.NotNull(result.Failures);
|
||||
Assert.Contains(result.Failures!, message => message.Contains("RekorSnapshotDirectory", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_RequiresTrustRootDirectoryWhenAnchorsPresent()
|
||||
{
|
||||
var validator = new OfflineKitOptionsValidator();
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RekorOfflineMode = false,
|
||||
TrustAnchors = new List<TrustAnchorConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AnchorId = "default",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:abcdef" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, options);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.NotNull(result.Failures);
|
||||
Assert.Contains(result.Failures!, message => message.Contains("TrustRootDirectory", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_WithMinimalValidConfig_Succeeds()
|
||||
{
|
||||
var validator = new OfflineKitOptionsValidator();
|
||||
|
||||
var trustRootDirectory = CreateTempDirectory("offline-kit-trust-roots");
|
||||
var rekorSnapshotDirectory = CreateTempDirectory("offline-kit-rekor");
|
||||
|
||||
try
|
||||
{
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireDsse = true,
|
||||
RekorOfflineMode = true,
|
||||
TrustRootDirectory = trustRootDirectory,
|
||||
RekorSnapshotDirectory = rekorSnapshotDirectory,
|
||||
TrustAnchors = new List<TrustAnchorConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AnchorId = "default",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:abcdef" },
|
||||
MinSignatures = 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(trustRootDirectory);
|
||||
TryDeleteDirectory(rekorSnapshotDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenEnabled_DetectsDuplicateAnchorIds()
|
||||
{
|
||||
var validator = new OfflineKitOptionsValidator();
|
||||
|
||||
var trustRootDirectory = CreateTempDirectory("offline-kit-trust-roots");
|
||||
var rekorSnapshotDirectory = CreateTempDirectory("offline-kit-rekor");
|
||||
|
||||
try
|
||||
{
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RekorOfflineMode = true,
|
||||
TrustRootDirectory = trustRootDirectory,
|
||||
RekorSnapshotDirectory = rekorSnapshotDirectory,
|
||||
TrustAnchors = new List<TrustAnchorConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AnchorId = "duplicate",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:aaaa" },
|
||||
},
|
||||
new()
|
||||
{
|
||||
AnchorId = "DUPLICATE",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:bbbb" },
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, options);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.NotNull(result.Failures);
|
||||
Assert.Contains(result.Failures!, message => message.Contains("Duplicate", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(trustRootDirectory);
|
||||
TryDeleteDirectory(rekorSnapshotDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory(string prefix)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public class ReachabilityUnionPublisherTests
|
||||
|
||||
var entry = await cas.TryGetAsync(result.Sha256);
|
||||
Assert.NotNull(entry);
|
||||
Assert.True(entry!.Value.SizeBytes > 0);
|
||||
Assert.True(entry!.SizeBytes > 0);
|
||||
}
|
||||
|
||||
private sealed class TempDir : IDisposable
|
||||
|
||||
@@ -53,10 +53,19 @@ public class ReachabilityUnionWriterTests
|
||||
Assert.Contains("sym:dotnet:B", nodeLines[1]);
|
||||
|
||||
// Hashes recorded in meta match content
|
||||
var meta = await JsonDocument.ParseAsync(File.OpenRead(result.MetaPath));
|
||||
var files = meta.RootElement.GetProperty("files").EnumerateArray().ToList();
|
||||
Assert.Contains(files, f => f.GetProperty("path").GetString() == result.Nodes.Path && f.GetProperty("sha256").GetString() == result.Nodes.Sha256);
|
||||
Assert.Contains(files, f => f.GetProperty("path").GetString() == result.Edges.Path && f.GetProperty("sha256").GetString() == result.Edges.Sha256);
|
||||
List<(string? Path, string? Sha256)> files;
|
||||
await using (var metaStream = File.OpenRead(result.MetaPath))
|
||||
using (var meta = await JsonDocument.ParseAsync(metaStream))
|
||||
{
|
||||
files = meta.RootElement
|
||||
.GetProperty("files")
|
||||
.EnumerateArray()
|
||||
.Select(file => (Path: file.GetProperty("path").GetString(), Sha256: file.GetProperty("sha256").GetString()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
Assert.Contains(files, file => file.Path == result.Nodes.Path && file.Sha256 == result.Nodes.Sha256);
|
||||
Assert.Contains(files, file => file.Path == result.Edges.Path && file.Sha256 == result.Edges.Sha256);
|
||||
|
||||
// Determinism: re-run with shuffled inputs yields identical hashes
|
||||
var shuffled = new ReachabilityUnionGraph(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests;
|
||||
|
||||
public sealed class PurlPatternMatcherTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("*", "pkg:npm/foo@1.0.0", true)]
|
||||
[InlineData("*", "anything", true)]
|
||||
[InlineData("*", "", false)]
|
||||
[InlineData("*", null, false)]
|
||||
[InlineData("pkg:npm/*", "pkg:npm/foo@1.0.0", true)]
|
||||
[InlineData("pkg:npm/*", "pkg:maven/org.apache.logging.log4j@2.0.0", false)]
|
||||
[InlineData("pkg:maven/org.apache.*", "pkg:maven/org.apache.logging.log4j@2.0.0", true)]
|
||||
[InlineData("pkg:maven/org.apache.*", "pkg:maven/org.eclipse.jetty@11.0.0", false)]
|
||||
[InlineData("pkg:npm/@scope/pkg@1.0.0", "PKG:NPM/@SCOPE/PKG@1.0.0", true)]
|
||||
public void IsMatch_HandlesGlobPatterns(string pattern, string? purl, bool expected)
|
||||
{
|
||||
var matcher = new PurlPatternMatcher(pattern);
|
||||
Assert.Equal(expected, matcher.IsMatch(purl));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_RejectsEmptyPattern(string pattern)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new PurlPatternMatcher(pattern));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests;
|
||||
|
||||
public sealed class TrustAnchorRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveForPurl_ReturnsNullWhenDisabled()
|
||||
{
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = false,
|
||||
TrustAnchors = new List<TrustAnchorConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AnchorId = "default",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:abcdef" },
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var registry = new TrustAnchorRegistry(
|
||||
new StaticOptionsMonitor<OfflineKitOptions>(options),
|
||||
new StubKeyLoader(new Dictionary<string, byte[]>()),
|
||||
NullLogger<TrustAnchorRegistry>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
Assert.Null(registry.ResolveForPurl("pkg:npm/foo@1.0.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveForPurl_FirstMatchWins()
|
||||
{
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TrustAnchors = new List<TrustAnchorConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AnchorId = "catch-all",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:aaaa" },
|
||||
},
|
||||
new()
|
||||
{
|
||||
AnchorId = "npm",
|
||||
PurlPattern = "pkg:npm/*",
|
||||
AllowedKeyIds = new List<string> { "sha256:bbbb" },
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["aaaa"] = new byte[] { 0x01, 0x02 },
|
||||
["bbbb"] = new byte[] { 0x03, 0x04 },
|
||||
};
|
||||
|
||||
var registry = new TrustAnchorRegistry(
|
||||
new StaticOptionsMonitor<OfflineKitOptions>(options),
|
||||
new StubKeyLoader(keys),
|
||||
NullLogger<TrustAnchorRegistry>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var resolution = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
|
||||
Assert.NotNull(resolution);
|
||||
Assert.Equal("catch-all", resolution!.AnchorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveForPurl_SkipsExpiredAnchors()
|
||||
{
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TrustAnchors = new List<TrustAnchorConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AnchorId = "expired",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:aaaa" },
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
},
|
||||
new()
|
||||
{
|
||||
AnchorId = "active",
|
||||
PurlPattern = "*",
|
||||
AllowedKeyIds = new List<string> { "sha256:bbbb" },
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["aaaa"] = new byte[] { 0x01, 0x02 },
|
||||
["bbbb"] = new byte[] { 0x03, 0x04 },
|
||||
};
|
||||
|
||||
var registry = new TrustAnchorRegistry(
|
||||
new StaticOptionsMonitor<OfflineKitOptions>(options),
|
||||
new StubKeyLoader(keys),
|
||||
NullLogger<TrustAnchorRegistry>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var resolution = registry.ResolveForPurl("pkg:maven/org.example/app@1.0.0");
|
||||
Assert.NotNull(resolution);
|
||||
Assert.Equal("active", resolution!.AnchorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveForPurl_NormalizesKeyIdsAndAddsSha256Alias()
|
||||
{
|
||||
var options = new OfflineKitOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TrustAnchors = new List<TrustAnchorConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AnchorId = "npm",
|
||||
PurlPattern = "pkg:npm/*",
|
||||
AllowedKeyIds = new List<string> { "sha256:ABCDEF" },
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["abcdef"] = new byte[] { 0x01, 0x02, 0x03 },
|
||||
};
|
||||
|
||||
var registry = new TrustAnchorRegistry(
|
||||
new StaticOptionsMonitor<OfflineKitOptions>(options),
|
||||
new StubKeyLoader(keys),
|
||||
NullLogger<TrustAnchorRegistry>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var resolution = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
|
||||
Assert.NotNull(resolution);
|
||||
Assert.Equal(new[] { "abcdef" }, resolution!.AllowedKeyIds);
|
||||
Assert.True(resolution.PublicKeys.ContainsKey("abcdef"));
|
||||
Assert.True(resolution.PublicKeys.ContainsKey("sha256:abcdef"));
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T currentValue) => CurrentValue = currentValue;
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubKeyLoader : IPublicKeyLoader
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, byte[]> _keys;
|
||||
|
||||
public StubKeyLoader(IReadOnlyDictionary<string, byte[]> keys) => _keys = keys;
|
||||
|
||||
public byte[]? LoadKey(string keyId, string? keyDirectory)
|
||||
=> _keys.TryGetValue(keyId, out var bytes) ? bytes : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
internal sealed class ScannerApplicationFactory : WebApplicationFactory<Program>
|
||||
internal sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceStatus>
|
||||
{
|
||||
private readonly ScannerWebServicePostgresFixture postgresFixture;
|
||||
private readonly Dictionary<string, string?> configuration = new()
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
using StellaOps.Scanner.Worker.Determinism.Calculators;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.Determinism;
|
||||
|
||||
public sealed class BitwiseFidelityCalculatorTests
|
||||
{
|
||||
private readonly BitwiseFidelityCalculator _calculator = new();
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithEmptyReplays_ReturnsFullScore()
|
||||
{
|
||||
var baseline = new Dictionary<string, string>
|
||||
{
|
||||
["file1.json"] = "hash1",
|
||||
["file2.json"] = "hash2"
|
||||
};
|
||||
var replays = Array.Empty<IReadOnlyDictionary<string, string>>();
|
||||
|
||||
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(1.0, score);
|
||||
Assert.Equal(0, identicalCount);
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithIdenticalReplays_ReturnsFullScore()
|
||||
{
|
||||
var baseline = new Dictionary<string, string>
|
||||
{
|
||||
["sbom.json"] = "sha256:abc",
|
||||
["findings.ndjson"] = "sha256:def"
|
||||
};
|
||||
var replays = new List<IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["sbom.json"] = "sha256:abc",
|
||||
["findings.ndjson"] = "sha256:def"
|
||||
},
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["sbom.json"] = "sha256:abc",
|
||||
["findings.ndjson"] = "sha256:def"
|
||||
}
|
||||
};
|
||||
|
||||
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(1.0, score);
|
||||
Assert.Equal(2, identicalCount);
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithPartialMismatch_ReturnsPartialScore()
|
||||
{
|
||||
var baseline = new Dictionary<string, string>
|
||||
{
|
||||
["sbom.json"] = "sha256:abc",
|
||||
["findings.ndjson"] = "sha256:def"
|
||||
};
|
||||
var replays = new List<IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["sbom.json"] = "sha256:abc",
|
||||
["findings.ndjson"] = "sha256:def"
|
||||
},
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["sbom.json"] = "sha256:abc",
|
||||
["findings.ndjson"] = "sha256:DIFFERENT" // Mismatch
|
||||
},
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["sbom.json"] = "sha256:abc",
|
||||
["findings.ndjson"] = "sha256:def"
|
||||
}
|
||||
};
|
||||
|
||||
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(2.0 / 3, score, precision: 4);
|
||||
Assert.Equal(2, identicalCount);
|
||||
Assert.Single(mismatches);
|
||||
Assert.Equal(1, mismatches[0].RunIndex);
|
||||
Assert.Equal(FidelityMismatchType.BitwiseOnly, mismatches[0].Type);
|
||||
Assert.Contains("findings.ndjson", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithMissingArtifact_DetectsMismatch()
|
||||
{
|
||||
var baseline = new Dictionary<string, string>
|
||||
{
|
||||
["file1.json"] = "hash1",
|
||||
["file2.json"] = "hash2"
|
||||
};
|
||||
var replays = new List<IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["file1.json"] = "hash1"
|
||||
// file2.json missing
|
||||
}
|
||||
};
|
||||
|
||||
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(0.0, score);
|
||||
Assert.Equal(0, identicalCount);
|
||||
Assert.Single(mismatches);
|
||||
Assert.Contains("file2.json", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithExtraArtifact_DetectsMismatch()
|
||||
{
|
||||
var baseline = new Dictionary<string, string>
|
||||
{
|
||||
["file1.json"] = "hash1"
|
||||
};
|
||||
var replays = new List<IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["file1.json"] = "hash1",
|
||||
["extra.json"] = "extra_hash" // Extra artifact
|
||||
}
|
||||
};
|
||||
|
||||
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(0.0, score);
|
||||
Assert.Single(mismatches);
|
||||
Assert.Contains("extra.json", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_IsCaseInsensitiveForHashes()
|
||||
{
|
||||
var baseline = new Dictionary<string, string>
|
||||
{
|
||||
["file.json"] = "SHA256:ABCDEF"
|
||||
};
|
||||
var replays = new List<IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["file.json"] = "sha256:abcdef" // Different case
|
||||
}
|
||||
};
|
||||
|
||||
var (score, identicalCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(1.0, score);
|
||||
Assert.Equal(1, identicalCount);
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
using StellaOps.Scanner.Worker.Determinism.Calculators;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.Determinism;
|
||||
|
||||
public sealed class SemanticFidelityCalculatorTests
|
||||
{
|
||||
private readonly SemanticFidelityCalculator _calculator = new();
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithEmptyReplays_ReturnsFullScore()
|
||||
{
|
||||
var baseline = CreateBaseline();
|
||||
var replays = Array.Empty<NormalizedFindings>();
|
||||
|
||||
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(1.0, score);
|
||||
Assert.Equal(0, matchCount);
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithIdenticalFindings_ReturnsFullScore()
|
||||
{
|
||||
var baseline = CreateBaseline();
|
||||
var replays = new List<NormalizedFindings>
|
||||
{
|
||||
CreateBaseline(),
|
||||
CreateBaseline()
|
||||
};
|
||||
|
||||
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(1.0, score);
|
||||
Assert.Equal(2, matchCount);
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithDifferentPackages_DetectsMismatch()
|
||||
{
|
||||
var baseline = CreateBaseline();
|
||||
var replays = new List<NormalizedFindings>
|
||||
{
|
||||
new NormalizedFindings
|
||||
{
|
||||
Packages = new List<NormalizedPackage>
|
||||
{
|
||||
new("pkg:npm/lodash@4.17.21", "4.17.21"),
|
||||
new("pkg:npm/extra@1.0.0", "1.0.0") // Extra package
|
||||
},
|
||||
Cves = new HashSet<string> { "CVE-2021-23337" },
|
||||
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
|
||||
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
|
||||
}
|
||||
};
|
||||
|
||||
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(0.0, score);
|
||||
Assert.Equal(0, matchCount);
|
||||
Assert.Single(mismatches);
|
||||
Assert.Contains("packages", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithDifferentCves_DetectsMismatch()
|
||||
{
|
||||
var baseline = CreateBaseline();
|
||||
var replays = new List<NormalizedFindings>
|
||||
{
|
||||
new NormalizedFindings
|
||||
{
|
||||
Packages = new List<NormalizedPackage>
|
||||
{
|
||||
new("pkg:npm/lodash@4.17.21", "4.17.21")
|
||||
},
|
||||
Cves = new HashSet<string> { "CVE-2021-23337", "CVE-2022-12345" }, // Extra CVE
|
||||
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
|
||||
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
|
||||
}
|
||||
};
|
||||
|
||||
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(0.0, score);
|
||||
Assert.Contains("cves", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithDifferentSeverities_DetectsMismatch()
|
||||
{
|
||||
var baseline = CreateBaseline();
|
||||
var replays = new List<NormalizedFindings>
|
||||
{
|
||||
new NormalizedFindings
|
||||
{
|
||||
Packages = new List<NormalizedPackage>
|
||||
{
|
||||
new("pkg:npm/lodash@4.17.21", "4.17.21")
|
||||
},
|
||||
Cves = new HashSet<string> { "CVE-2021-23337" },
|
||||
SeverityCounts = new Dictionary<string, int> { ["CRITICAL"] = 1 }, // Different severity
|
||||
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
|
||||
}
|
||||
};
|
||||
|
||||
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(0.0, score);
|
||||
Assert.Contains("severities", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithDifferentVerdicts_DetectsMismatch()
|
||||
{
|
||||
var baseline = CreateBaseline();
|
||||
var replays = new List<NormalizedFindings>
|
||||
{
|
||||
new NormalizedFindings
|
||||
{
|
||||
Packages = new List<NormalizedPackage>
|
||||
{
|
||||
new("pkg:npm/lodash@4.17.21", "4.17.21")
|
||||
},
|
||||
Cves = new HashSet<string> { "CVE-2021-23337" },
|
||||
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
|
||||
Verdicts = new Dictionary<string, string> { ["overall"] = "pass" } // Different verdict
|
||||
}
|
||||
};
|
||||
|
||||
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(0.0, score);
|
||||
Assert.Contains("verdicts", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithPartialMatches_ReturnsCorrectScore()
|
||||
{
|
||||
var baseline = CreateBaseline();
|
||||
var replays = new List<NormalizedFindings>
|
||||
{
|
||||
CreateBaseline(), // Match
|
||||
new NormalizedFindings // Mismatch
|
||||
{
|
||||
Packages = new List<NormalizedPackage>(),
|
||||
Cves = new HashSet<string>(),
|
||||
SeverityCounts = new Dictionary<string, int>(),
|
||||
Verdicts = new Dictionary<string, string>()
|
||||
},
|
||||
CreateBaseline() // Match
|
||||
};
|
||||
|
||||
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
|
||||
|
||||
Assert.Equal(2.0 / 3, score, precision: 4);
|
||||
Assert.Equal(2, matchCount);
|
||||
Assert.Single(mismatches);
|
||||
}
|
||||
|
||||
private static NormalizedFindings CreateBaseline() => new()
|
||||
{
|
||||
Packages = new List<NormalizedPackage>
|
||||
{
|
||||
new("pkg:npm/lodash@4.17.21", "4.17.21")
|
||||
},
|
||||
Cves = new HashSet<string> { "CVE-2021-23337" },
|
||||
SeverityCounts = new Dictionary<string, int> { ["HIGH"] = 1 },
|
||||
Verdicts = new Dictionary<string, string> { ["overall"] = "fail" }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://stella-ops.org/schemas/scanner-offline-kit-config.schema.json",
|
||||
"title": "Scanner Offline Kit Configuration",
|
||||
"type": "object",
|
||||
"description": "Schema for the `scanner.offlineKit` configuration section used by Scanner WebService/Worker for offline kit verification.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable offline kit operations (opt-in)."
|
||||
},
|
||||
"requireDsse": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Fail import if DSSE verification fails."
|
||||
},
|
||||
"rekorOfflineMode": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Use only local Rekor snapshots; do not call online Rekor APIs."
|
||||
},
|
||||
"attestationVerifier": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "URL of internal attestation verifier service."
|
||||
},
|
||||
"trustRootDirectory": {
|
||||
"type": "string",
|
||||
"description": "Path to directory containing trust root public keys."
|
||||
},
|
||||
"rekorSnapshotDirectory": {
|
||||
"type": "string",
|
||||
"description": "Path to Rekor snapshot directory."
|
||||
},
|
||||
"trustAnchors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"anchorId",
|
||||
"purlPattern",
|
||||
"allowedKeyids"
|
||||
],
|
||||
"properties": {
|
||||
"anchorId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"purlPattern": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"examples": [
|
||||
"pkg:npm/*",
|
||||
"pkg:maven/org.apache.*",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"allowedKeyids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"minSignatures": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user