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

- 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:
master
2025-12-16 10:44:24 +02:00
parent 4391f35d8a
commit 5a480a3c2a
223 changed files with 19367 additions and 727 deletions

View File

@@ -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

View File

@@ -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. |

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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>;

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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
};
}
}

View File

@@ -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.

View File

@@ -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));

View File

@@ -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 &lt;init&gt;).</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>

View File

@@ -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));

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Scanner.Core.TrustAnchors;
public interface IPublicKeyLoader
{
byte[]? LoadKey(string keyId, string? keyDirectory);
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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';

View File

@@ -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";
}

View File

@@ -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))
};
}

View File

@@ -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);
}

View File

@@ -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"
}
]
}
]

View File

@@ -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
{
}
}
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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()

View File

@@ -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);
}
}

View File

@@ -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" }
};
}

View File

@@ -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
}
}
}
}
}
}