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

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