Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET. - Added `all-visibility-levels.json` to validate method visibility levels in .NET. - Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application. - Included `go-gin-api.json` for a Go Gin API application structure. - Added `java-spring-boot.json` for the Spring PetClinic application in Java. - Introduced `legacy-no-schema.json` for legacy application structure without schema. - Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
@@ -173,6 +173,14 @@ internal sealed class DotNetCallgraphBuilder
|
||||
var isVirtual = (methodDef.Attributes & MethodAttributes.Virtual) != 0;
|
||||
var isGeneric = methodDef.GetGenericParameters().Count > 0;
|
||||
|
||||
// Extract visibility from MethodAttributes
|
||||
var visibility = ExtractVisibility(methodDef.Attributes);
|
||||
|
||||
// Determine if this method is an entrypoint candidate
|
||||
var isTypePublic = (typeDef.Attributes & TypeAttributes.Public) != 0 ||
|
||||
(typeDef.Attributes & TypeAttributes.NestedPublic) != 0;
|
||||
var isEntrypointCandidate = isPublic && isTypePublic && !methodName.StartsWith("<");
|
||||
|
||||
var node = new DotNetMethodNode(
|
||||
MethodId: methodId,
|
||||
AssemblyName: assemblyName,
|
||||
@@ -186,7 +194,9 @@ internal sealed class DotNetCallgraphBuilder
|
||||
IsStatic: isStatic,
|
||||
IsPublic: isPublic,
|
||||
IsVirtual: isVirtual,
|
||||
IsGeneric: isGeneric);
|
||||
IsGeneric: isGeneric,
|
||||
Visibility: visibility,
|
||||
IsEntrypointCandidate: isEntrypointCandidate);
|
||||
|
||||
_methods.TryAdd(methodId, node);
|
||||
|
||||
@@ -254,6 +264,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
!methodName.StartsWith("get_") && !methodName.StartsWith("set_") &&
|
||||
methodName != ".ctor")
|
||||
{
|
||||
var (routeTemplate, httpMethod) = ExtractRouteInfo(metadata, methodDef.GetCustomAttributes());
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
RootId: rootId,
|
||||
@@ -262,14 +273,29 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "ControllerAction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
RouteTemplate: routeTemplate,
|
||||
HttpMethod: httpMethod,
|
||||
Framework: DotNetEntrypointFramework.AspNetCore));
|
||||
}
|
||||
|
||||
// Test methods (xUnit, NUnit, MSTest)
|
||||
var testFramework = DotNetEntrypointFramework.Unknown;
|
||||
if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.FactAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.TheoryAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "NUnit.Framework.TestAttribute") ||
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"))
|
||||
HasAttribute(metadata, methodDef.GetCustomAttributes(), "Xunit.TheoryAttribute"))
|
||||
{
|
||||
testFramework = DotNetEntrypointFramework.XUnit;
|
||||
}
|
||||
else if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "NUnit.Framework.TestAttribute"))
|
||||
{
|
||||
testFramework = DotNetEntrypointFramework.NUnit;
|
||||
}
|
||||
else if (HasAttribute(metadata, methodDef.GetCustomAttributes(), "Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"))
|
||||
{
|
||||
testFramework = DotNetEntrypointFramework.MSTest;
|
||||
}
|
||||
|
||||
if (testFramework != DotNetEntrypointFramework.Unknown)
|
||||
{
|
||||
var rootId = DotNetGraphIdentifiers.ComputeRootId(DotNetRootPhase.Runtime, rootOrder++, methodId);
|
||||
_roots.Add(new DotNetSyntheticRoot(
|
||||
@@ -279,7 +305,8 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "TestMethod",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
Framework: testFramework));
|
||||
}
|
||||
|
||||
// Azure Functions
|
||||
@@ -294,7 +321,8 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "AzureFunction",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
Framework: DotNetEntrypointFramework.AzureFunctions));
|
||||
}
|
||||
|
||||
// AWS Lambda
|
||||
@@ -308,10 +336,120 @@ internal sealed class DotNetCallgraphBuilder
|
||||
Source: "LambdaHandler",
|
||||
AssemblyPath: assemblyPath,
|
||||
Phase: DotNetRootPhase.Runtime,
|
||||
Order: rootOrder - 1));
|
||||
Order: rootOrder - 1,
|
||||
Framework: DotNetEntrypointFramework.AwsLambda));
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? RouteTemplate, string? HttpMethod) ExtractRouteInfo(
|
||||
MetadataReader metadata,
|
||||
CustomAttributeHandleCollection attributes)
|
||||
{
|
||||
string? routeTemplate = null;
|
||||
string? httpMethod = null;
|
||||
|
||||
foreach (var attrHandle in attributes)
|
||||
{
|
||||
var attr = metadata.GetCustomAttribute(attrHandle);
|
||||
var ctorHandle = attr.Constructor;
|
||||
|
||||
string? typeName = null;
|
||||
switch (ctorHandle.Kind)
|
||||
{
|
||||
case HandleKind.MemberReference:
|
||||
var memberRef = metadata.GetMemberReference((MemberReferenceHandle)ctorHandle);
|
||||
if (memberRef.Parent.Kind == HandleKind.TypeReference)
|
||||
{
|
||||
var typeRef = metadata.GetTypeReference((TypeReferenceHandle)memberRef.Parent);
|
||||
typeName = GetTypeRefName(metadata, typeRef);
|
||||
}
|
||||
break;
|
||||
case HandleKind.MethodDefinition:
|
||||
var methodDef = metadata.GetMethodDefinition((MethodDefinitionHandle)ctorHandle);
|
||||
var declaringType = metadata.GetTypeDefinition(methodDef.GetDeclaringType());
|
||||
typeName = GetFullTypeName(metadata, declaringType);
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeName is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract route template from [Route] attribute
|
||||
if (typeName.Contains("RouteAttribute"))
|
||||
{
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
|
||||
// Extract HTTP method and optional route from Http*Attribute
|
||||
if (typeName.Contains("HttpGetAttribute"))
|
||||
{
|
||||
httpMethod = "GET";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpPostAttribute"))
|
||||
{
|
||||
httpMethod = "POST";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpPutAttribute"))
|
||||
{
|
||||
httpMethod = "PUT";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpDeleteAttribute"))
|
||||
{
|
||||
httpMethod = "DELETE";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
else if (typeName.Contains("HttpPatchAttribute"))
|
||||
{
|
||||
httpMethod = "PATCH";
|
||||
routeTemplate ??= TryExtractStringArgument(metadata, attr);
|
||||
}
|
||||
}
|
||||
|
||||
return (routeTemplate, httpMethod);
|
||||
}
|
||||
|
||||
private static string? TryExtractStringArgument(MetadataReader metadata, CustomAttribute attr)
|
||||
{
|
||||
// Simplified extraction - read first string argument from attribute blob
|
||||
// Full implementation would properly parse the custom attribute blob
|
||||
try
|
||||
{
|
||||
var value = attr.DecodeValue(new SimpleAttributeProvider());
|
||||
if (value.FixedArguments.Length > 0 &&
|
||||
value.FixedArguments[0].Value is string strValue &&
|
||||
!string.IsNullOrEmpty(strValue))
|
||||
{
|
||||
return strValue;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Attribute decoding failed - not critical
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple attribute type provider for decoding custom attributes.
|
||||
/// </summary>
|
||||
private sealed class SimpleAttributeProvider : ICustomAttributeTypeProvider<object?>
|
||||
{
|
||||
public object? GetPrimitiveType(PrimitiveTypeCode typeCode) => null;
|
||||
public object? GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => null;
|
||||
public object? GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) => null;
|
||||
public object? GetSZArrayType(object? elementType) => null;
|
||||
public object? GetSystemType() => typeof(Type);
|
||||
public object? GetTypeFromSerializedName(string name) => Type.GetType(name);
|
||||
public PrimitiveTypeCode GetUnderlyingEnumType(object? type) => PrimitiveTypeCode.Int32;
|
||||
public bool IsSystemType(object? type) => type is Type;
|
||||
}
|
||||
|
||||
private void ExtractCallEdgesFromType(
|
||||
MetadataReader metadata,
|
||||
TypeDefinition typeDef,
|
||||
@@ -390,15 +528,15 @@ internal sealed class DotNetCallgraphBuilder
|
||||
var token = BitConverter.ToInt32(ilBytes, offset);
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode switch
|
||||
var (edgeType, edgeReason) = opcode switch
|
||||
{
|
||||
0x28 => DotNetEdgeType.Call,
|
||||
0x6F => DotNetEdgeType.CallVirt,
|
||||
0x73 => DotNetEdgeType.NewObj,
|
||||
_ => DotNetEdgeType.Call,
|
||||
0x28 => (DotNetEdgeType.Call, DotNetEdgeReason.DirectCall),
|
||||
0x6F => (DotNetEdgeType.CallVirt, DotNetEdgeReason.VirtualCall),
|
||||
0x73 => (DotNetEdgeType.NewObj, DotNetEdgeReason.NewObj),
|
||||
_ => (DotNetEdgeType.Call, DotNetEdgeReason.DirectCall),
|
||||
};
|
||||
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, edgeReason, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0xFE06: // ldftn (0xFE 0x06)
|
||||
@@ -413,7 +551,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
offset += 4;
|
||||
|
||||
var edgeType = opcode == 0xFE06 ? DotNetEdgeType.LdFtn : DotNetEdgeType.LdVirtFtn;
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
AddCallEdge(metadata, callerId, token, ilOffset, edgeType, DotNetEdgeReason.DelegateCreate, assemblyName, assemblyPath);
|
||||
break;
|
||||
}
|
||||
case 0x29: // calli
|
||||
@@ -436,6 +574,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
CalleePurl: null,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: DotNetEdgeType.CallI,
|
||||
EdgeReason: DotNetEdgeReason.IndirectCall,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: false,
|
||||
Confidence: 0.2));
|
||||
@@ -470,6 +609,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
int token,
|
||||
int ilOffset,
|
||||
DotNetEdgeType edgeType,
|
||||
DotNetEdgeReason edgeReason,
|
||||
string assemblyName,
|
||||
string assemblyPath)
|
||||
{
|
||||
@@ -517,8 +657,8 @@ internal sealed class DotNetCallgraphBuilder
|
||||
case HandleKind.MethodSpecification:
|
||||
{
|
||||
var methodSpec = metadata.GetMethodSpecification((MethodSpecificationHandle)handle);
|
||||
// Recursively resolve the generic method
|
||||
AddCallEdge(metadata, callerId, MetadataTokens.GetToken(methodSpec.Method), ilOffset, edgeType, assemblyName, assemblyPath);
|
||||
// Recursively resolve the generic method - use GenericInstantiation reason
|
||||
AddCallEdge(metadata, callerId, MetadataTokens.GetToken(methodSpec.Method), ilOffset, edgeType, DotNetEdgeReason.GenericInstantiation, assemblyName, assemblyPath);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
@@ -549,6 +689,7 @@ internal sealed class DotNetCallgraphBuilder
|
||||
CalleePurl: calleePurl,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: edgeType,
|
||||
EdgeReason: edgeReason,
|
||||
ILOffset: ilOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.7));
|
||||
@@ -788,4 +929,19 @@ internal sealed class DotNetCallgraphBuilder
|
||||
_ => 1, // default for unrecognized
|
||||
};
|
||||
}
|
||||
|
||||
private static DotNetVisibility ExtractVisibility(MethodAttributes attributes)
|
||||
{
|
||||
var accessMask = attributes & MethodAttributes.MemberAccessMask;
|
||||
return accessMask switch
|
||||
{
|
||||
MethodAttributes.Public => DotNetVisibility.Public,
|
||||
MethodAttributes.Private => DotNetVisibility.Private,
|
||||
MethodAttributes.Family => DotNetVisibility.Protected,
|
||||
MethodAttributes.Assembly => DotNetVisibility.Internal,
|
||||
MethodAttributes.FamORAssem => DotNetVisibility.ProtectedInternal,
|
||||
MethodAttributes.FamANDAssem => DotNetVisibility.PrivateProtected,
|
||||
_ => DotNetVisibility.Private
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ public sealed record DotNetReachabilityGraph(
|
||||
/// <param name="IsPublic">Whether the method is public.</param>
|
||||
/// <param name="IsVirtual">Whether the method is virtual.</param>
|
||||
/// <param name="IsGeneric">Whether the method has generic parameters.</param>
|
||||
/// <param name="Visibility">Access visibility (public, private, protected, internal, etc.).</param>
|
||||
/// <param name="IsEntrypointCandidate">Whether this method could be an entrypoint (public, controller action, etc.).</param>
|
||||
public sealed record DotNetMethodNode(
|
||||
string MethodId,
|
||||
string AssemblyName,
|
||||
@@ -45,7 +47,33 @@ public sealed record DotNetMethodNode(
|
||||
bool IsStatic,
|
||||
bool IsPublic,
|
||||
bool IsVirtual,
|
||||
bool IsGeneric);
|
||||
bool IsGeneric,
|
||||
DotNetVisibility Visibility,
|
||||
bool IsEntrypointCandidate);
|
||||
|
||||
/// <summary>
|
||||
/// Access visibility levels for .NET methods.
|
||||
/// </summary>
|
||||
public enum DotNetVisibility
|
||||
{
|
||||
/// <summary>Accessible from anywhere.</summary>
|
||||
Public,
|
||||
|
||||
/// <summary>Accessible only within the same type.</summary>
|
||||
Private,
|
||||
|
||||
/// <summary>Accessible within the same type or derived types.</summary>
|
||||
Protected,
|
||||
|
||||
/// <summary>Accessible within the same assembly.</summary>
|
||||
Internal,
|
||||
|
||||
/// <summary>Accessible within the same assembly or derived types.</summary>
|
||||
ProtectedInternal,
|
||||
|
||||
/// <summary>Accessible only within derived types in the same assembly.</summary>
|
||||
PrivateProtected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A call edge in the .NET call graph.
|
||||
@@ -56,6 +84,7 @@ public sealed record DotNetMethodNode(
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeMethodDigest">Method digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (call instruction type).</param>
|
||||
/// <param name="EdgeReason">Semantic reason for the edge (DirectCall, VirtualCall, etc.).</param>
|
||||
/// <param name="ILOffset">IL offset where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
@@ -66,6 +95,7 @@ public sealed record DotNetCallEdge(
|
||||
string? CalleePurl,
|
||||
string? CalleeMethodDigest,
|
||||
DotNetEdgeType EdgeType,
|
||||
DotNetEdgeReason EdgeReason,
|
||||
int ILOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
@@ -103,6 +133,52 @@ public enum DotNetEdgeType
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic reason for why a .NET edge exists.
|
||||
/// Maps to the schema's EdgeReason enum for explainability.
|
||||
/// </summary>
|
||||
public enum DotNetEdgeReason
|
||||
{
|
||||
/// <summary>Direct method call (call opcode).</summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>Virtual/interface dispatch (callvirt opcode).</summary>
|
||||
VirtualCall,
|
||||
|
||||
/// <summary>Reflection-based invocation (Type.GetMethod, etc.).</summary>
|
||||
ReflectionString,
|
||||
|
||||
/// <summary>Dependency injection binding.</summary>
|
||||
DiBinding,
|
||||
|
||||
/// <summary>Dynamic import or late binding.</summary>
|
||||
DynamicImport,
|
||||
|
||||
/// <summary>Constructor/object instantiation (newobj opcode).</summary>
|
||||
NewObj,
|
||||
|
||||
/// <summary>Delegate/function pointer creation (ldftn, ldvirtftn).</summary>
|
||||
DelegateCreate,
|
||||
|
||||
/// <summary>Async/await continuation.</summary>
|
||||
AsyncContinuation,
|
||||
|
||||
/// <summary>Event handler subscription.</summary>
|
||||
EventHandler,
|
||||
|
||||
/// <summary>Generic type instantiation.</summary>
|
||||
GenericInstantiation,
|
||||
|
||||
/// <summary>Native interop (P/Invoke).</summary>
|
||||
NativeInterop,
|
||||
|
||||
/// <summary>Indirect call through function pointer (calli).</summary>
|
||||
IndirectCall,
|
||||
|
||||
/// <summary>Reason could not be determined.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the .NET call graph.
|
||||
/// </summary>
|
||||
@@ -114,6 +190,9 @@ public enum DotNetEdgeType
|
||||
/// <param name="Phase">Execution phase.</param>
|
||||
/// <param name="Order">Order within the phase.</param>
|
||||
/// <param name="IsResolved">Whether the target was successfully resolved.</param>
|
||||
/// <param name="RouteTemplate">HTTP route template if applicable (e.g., "/api/orders/{id}").</param>
|
||||
/// <param name="HttpMethod">HTTP method if applicable (GET, POST, etc.).</param>
|
||||
/// <param name="Framework">Framework exposing this entrypoint.</param>
|
||||
public sealed record DotNetSyntheticRoot(
|
||||
string RootId,
|
||||
string TargetId,
|
||||
@@ -122,7 +201,43 @@ public sealed record DotNetSyntheticRoot(
|
||||
string AssemblyPath,
|
||||
DotNetRootPhase Phase,
|
||||
int Order,
|
||||
bool IsResolved = true);
|
||||
bool IsResolved = true,
|
||||
string? RouteTemplate = null,
|
||||
string? HttpMethod = null,
|
||||
DotNetEntrypointFramework Framework = DotNetEntrypointFramework.Unknown);
|
||||
|
||||
/// <summary>
|
||||
/// Frameworks that expose .NET entrypoints.
|
||||
/// </summary>
|
||||
public enum DotNetEntrypointFramework
|
||||
{
|
||||
/// <summary>Unknown framework.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>ASP.NET Core MVC/WebAPI.</summary>
|
||||
AspNetCore,
|
||||
|
||||
/// <summary>ASP.NET Core Minimal APIs.</summary>
|
||||
MinimalApi,
|
||||
|
||||
/// <summary>gRPC for .NET.</summary>
|
||||
Grpc,
|
||||
|
||||
/// <summary>Azure Functions.</summary>
|
||||
AzureFunctions,
|
||||
|
||||
/// <summary>AWS Lambda.</summary>
|
||||
AwsLambda,
|
||||
|
||||
/// <summary>xUnit test framework.</summary>
|
||||
XUnit,
|
||||
|
||||
/// <summary>NUnit test framework.</summary>
|
||||
NUnit,
|
||||
|
||||
/// <summary>MSTest framework.</summary>
|
||||
MSTest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase for .NET synthetic roots.
|
||||
|
||||
@@ -108,12 +108,12 @@ internal sealed class JavaCallgraphBuilder
|
||||
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, calleeId, edge.InstructionOffset);
|
||||
var confidence = edge.Confidence == JavaReflectionConfidence.High ? 0.9 : 0.5;
|
||||
|
||||
var edgeType = edge.Reason switch
|
||||
var (edgeType, edgeReason) = edge.Reason switch
|
||||
{
|
||||
JavaReflectionReason.ClassForName => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ClassLoaderLoadClass => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ServiceLoaderLoad => JavaEdgeType.ServiceLoader,
|
||||
_ => JavaEdgeType.Reflection,
|
||||
JavaReflectionReason.ClassForName => (JavaEdgeType.Reflection, JavaEdgeReason.ReflectionString),
|
||||
JavaReflectionReason.ClassLoaderLoadClass => (JavaEdgeType.Reflection, JavaEdgeReason.ReflectionString),
|
||||
JavaReflectionReason.ServiceLoaderLoad => (JavaEdgeType.ServiceLoader, JavaEdgeReason.ServiceLoader),
|
||||
_ => (JavaEdgeType.Reflection, JavaEdgeReason.ReflectionString),
|
||||
};
|
||||
|
||||
_edges.Add(new JavaCallEdge(
|
||||
@@ -123,6 +123,7 @@ internal sealed class JavaCallgraphBuilder
|
||||
CalleePurl: null, // Reflection targets often unknown
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: edgeType,
|
||||
EdgeReason: edgeReason,
|
||||
BytecodeOffset: edge.InstructionOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: confidence));
|
||||
@@ -229,6 +230,16 @@ internal sealed class JavaCallgraphBuilder
|
||||
var isSynthetic = (method.AccessFlags & 0x1000) != 0;
|
||||
var isBridge = (method.AccessFlags & 0x0040) != 0;
|
||||
|
||||
// Extract visibility from access flags
|
||||
var visibility = ExtractVisibility(method.AccessFlags);
|
||||
|
||||
// Determine if this method is an entrypoint candidate
|
||||
// Public non-synthetic methods that aren't constructors or accessors
|
||||
var isEntrypointCandidate = isPublic &&
|
||||
!isSynthetic &&
|
||||
!method.Name.StartsWith("<") &&
|
||||
!method.Name.StartsWith("lambda$");
|
||||
|
||||
var node = new JavaMethodNode(
|
||||
MethodId: methodId,
|
||||
ClassName: className,
|
||||
@@ -241,11 +252,34 @@ internal sealed class JavaCallgraphBuilder
|
||||
IsStatic: isStatic,
|
||||
IsPublic: isPublic,
|
||||
IsSynthetic: isSynthetic,
|
||||
IsBridge: isBridge);
|
||||
IsBridge: isBridge,
|
||||
Visibility: visibility,
|
||||
IsEntrypointCandidate: isEntrypointCandidate);
|
||||
|
||||
_methods.TryAdd(methodId, node);
|
||||
}
|
||||
|
||||
private static JavaVisibility ExtractVisibility(int accessFlags)
|
||||
{
|
||||
// ACC_PUBLIC = 0x0001, ACC_PRIVATE = 0x0002, ACC_PROTECTED = 0x0004
|
||||
if ((accessFlags & 0x0001) != 0)
|
||||
{
|
||||
return JavaVisibility.Public;
|
||||
}
|
||||
else if ((accessFlags & 0x0002) != 0)
|
||||
{
|
||||
return JavaVisibility.Private;
|
||||
}
|
||||
else if ((accessFlags & 0x0004) != 0)
|
||||
{
|
||||
return JavaVisibility.Protected;
|
||||
}
|
||||
else
|
||||
{
|
||||
return JavaVisibility.Package; // Package-private (default)
|
||||
}
|
||||
}
|
||||
|
||||
private void FindSyntheticRoots(string className, JavaClassFileParser.ClassFile classFile, string jarPath)
|
||||
{
|
||||
var rootOrder = 0;
|
||||
@@ -380,13 +414,14 @@ internal sealed class JavaCallgraphBuilder
|
||||
methodRef.Value.Name,
|
||||
methodRef.Value.Descriptor);
|
||||
|
||||
var edgeType = opcode switch
|
||||
var (edgeType, edgeReason) = opcode switch
|
||||
{
|
||||
0xB8 => JavaEdgeType.InvokeStatic,
|
||||
0xB6 => JavaEdgeType.InvokeVirtual,
|
||||
0xB7 => methodRef.Value.Name == "<init>" ? JavaEdgeType.Constructor : JavaEdgeType.InvokeSpecial,
|
||||
0xB9 => JavaEdgeType.InvokeInterface,
|
||||
_ => JavaEdgeType.InvokeVirtual,
|
||||
0xB8 => (JavaEdgeType.InvokeStatic, JavaEdgeReason.DirectCall),
|
||||
0xB6 => (JavaEdgeType.InvokeVirtual, JavaEdgeReason.VirtualCall),
|
||||
0xB7 when methodRef.Value.Name == "<init>" => (JavaEdgeType.Constructor, JavaEdgeReason.NewObj),
|
||||
0xB7 => (JavaEdgeType.InvokeSpecial, JavaEdgeReason.SuperCall),
|
||||
0xB9 => (JavaEdgeType.InvokeInterface, JavaEdgeReason.InterfaceCall),
|
||||
_ => (JavaEdgeType.InvokeVirtual, JavaEdgeReason.VirtualCall),
|
||||
};
|
||||
|
||||
// Check if target is resolved (known in our method set)
|
||||
@@ -403,6 +438,7 @@ internal sealed class JavaCallgraphBuilder
|
||||
CalleePurl: calleePurl,
|
||||
CalleeMethodDigest: null, // Would compute if method is in our set
|
||||
EdgeType: edgeType,
|
||||
EdgeReason: edgeReason,
|
||||
BytecodeOffset: instructionOffset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.7));
|
||||
@@ -448,6 +484,7 @@ internal sealed class JavaCallgraphBuilder
|
||||
CalleePurl: null,
|
||||
CalleeMethodDigest: null,
|
||||
EdgeType: JavaEdgeType.InvokeDynamic,
|
||||
EdgeReason: JavaEdgeReason.DynamicImport,
|
||||
BytecodeOffset: instructionOffset,
|
||||
IsResolved: false,
|
||||
Confidence: 0.3));
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed record JavaReachabilityGraph(
|
||||
/// <param name="IsPublic">Whether the method is public.</param>
|
||||
/// <param name="IsSynthetic">Whether the method is synthetic (compiler-generated).</param>
|
||||
/// <param name="IsBridge">Whether the method is a bridge method.</param>
|
||||
/// <param name="Visibility">Access visibility (public, private, protected, package).</param>
|
||||
/// <param name="IsEntrypointCandidate">Whether this method could be an entrypoint (public, controller action, etc.).</param>
|
||||
public sealed record JavaMethodNode(
|
||||
string MethodId,
|
||||
string ClassName,
|
||||
@@ -43,7 +45,27 @@ public sealed record JavaMethodNode(
|
||||
bool IsStatic,
|
||||
bool IsPublic,
|
||||
bool IsSynthetic,
|
||||
bool IsBridge);
|
||||
bool IsBridge,
|
||||
JavaVisibility Visibility,
|
||||
bool IsEntrypointCandidate);
|
||||
|
||||
/// <summary>
|
||||
/// Access visibility levels for Java methods.
|
||||
/// </summary>
|
||||
public enum JavaVisibility
|
||||
{
|
||||
/// <summary>Accessible from anywhere.</summary>
|
||||
Public,
|
||||
|
||||
/// <summary>Accessible only within the same class.</summary>
|
||||
Private,
|
||||
|
||||
/// <summary>Accessible within the same package or subclasses.</summary>
|
||||
Protected,
|
||||
|
||||
/// <summary>Package-private (default access).</summary>
|
||||
Package
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A call edge in the Java call graph.
|
||||
@@ -54,6 +76,7 @@ public sealed record JavaMethodNode(
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeMethodDigest">Method digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (invoke type).</param>
|
||||
/// <param name="EdgeReason">Semantic reason for the edge (DirectCall, VirtualCall, etc.).</param>
|
||||
/// <param name="BytecodeOffset">Bytecode offset where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
@@ -64,6 +87,7 @@ public sealed record JavaCallEdge(
|
||||
string? CalleePurl,
|
||||
string? CalleeMethodDigest,
|
||||
JavaEdgeType EdgeType,
|
||||
JavaEdgeReason EdgeReason,
|
||||
int BytecodeOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
@@ -98,6 +122,46 @@ public enum JavaEdgeType
|
||||
Constructor,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic reason for why a Java edge exists.
|
||||
/// Maps to the schema's EdgeReason enum for explainability.
|
||||
/// </summary>
|
||||
public enum JavaEdgeReason
|
||||
{
|
||||
/// <summary>Direct static method call (invokestatic).</summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>Virtual method dispatch (invokevirtual, invokeinterface).</summary>
|
||||
VirtualCall,
|
||||
|
||||
/// <summary>Reflection-based invocation (Class.forName, Method.invoke).</summary>
|
||||
ReflectionString,
|
||||
|
||||
/// <summary>Dependency injection binding (Spring, Guice).</summary>
|
||||
DiBinding,
|
||||
|
||||
/// <summary>Dynamic lambda or method reference (invokedynamic).</summary>
|
||||
DynamicImport,
|
||||
|
||||
/// <summary>Constructor/object instantiation (invokespecial <init>).</summary>
|
||||
NewObj,
|
||||
|
||||
/// <summary>Super or private method call (invokespecial non-init).</summary>
|
||||
SuperCall,
|
||||
|
||||
/// <summary>ServiceLoader-based service discovery.</summary>
|
||||
ServiceLoader,
|
||||
|
||||
/// <summary>Interface method dispatch.</summary>
|
||||
InterfaceCall,
|
||||
|
||||
/// <summary>Native interop (JNI).</summary>
|
||||
NativeInterop,
|
||||
|
||||
/// <summary>Reason could not be determined.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthetic root in the Java call graph.
|
||||
/// </summary>
|
||||
|
||||
@@ -258,6 +258,9 @@ internal sealed class NativeCallgraphBuilder
|
||||
var isResolved = targetSym.Value != 0 || targetSym.SectionIndex != 0;
|
||||
var calleePurl = isResolved ? GeneratePurl(elf.Path, targetSym.Name) : null;
|
||||
|
||||
// Determine edge reason based on whether target is external
|
||||
var edgeReason = isResolved ? NativeEdgeReason.DirectCall : NativeEdgeReason.NativeInterop;
|
||||
|
||||
_edges.Add(new NativeCallEdge(
|
||||
EdgeId: edgeId,
|
||||
CallerId: callerId,
|
||||
@@ -265,6 +268,7 @@ internal sealed class NativeCallgraphBuilder
|
||||
CalleePurl: calleePurl,
|
||||
CalleeSymbolDigest: calleeDigest,
|
||||
EdgeType: NativeEdgeType.Relocation,
|
||||
EdgeReason: edgeReason,
|
||||
CallSiteOffset: reloc.Offset,
|
||||
IsResolved: isResolved,
|
||||
Confidence: isResolved ? 1.0 : 0.5));
|
||||
@@ -321,6 +325,7 @@ internal sealed class NativeCallgraphBuilder
|
||||
CalleePurl: GeneratePurl(elf.Path, targetSym.Name),
|
||||
CalleeSymbolDigest: targetDigest,
|
||||
EdgeType: NativeEdgeType.InitArray,
|
||||
EdgeReason: NativeEdgeReason.InitCallback,
|
||||
CallSiteOffset: (ulong)idx,
|
||||
IsResolved: true,
|
||||
Confidence: 1.0));
|
||||
|
||||
@@ -49,6 +49,7 @@ public sealed record NativeFunctionNode(
|
||||
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
|
||||
/// <param name="CalleeSymbolDigest">Symbol digest of the callee.</param>
|
||||
/// <param name="EdgeType">Type of edge (direct, plt, got, reloc).</param>
|
||||
/// <param name="EdgeReason">Semantic reason for the edge (DirectCall, NativeInterop, etc.).</param>
|
||||
/// <param name="CallSiteOffset">Offset within caller where call occurs.</param>
|
||||
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
|
||||
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
|
||||
@@ -59,10 +60,30 @@ public sealed record NativeCallEdge(
|
||||
string? CalleePurl,
|
||||
string? CalleeSymbolDigest,
|
||||
NativeEdgeType EdgeType,
|
||||
NativeEdgeReason EdgeReason,
|
||||
ulong CallSiteOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Semantic reason for why a native edge exists.
|
||||
/// Maps to the schema's EdgeReason enum for explainability.
|
||||
/// </summary>
|
||||
public enum NativeEdgeReason
|
||||
{
|
||||
/// <summary>Direct function call within the same binary.</summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>Call through PLT/GOT to external library (native interop).</summary>
|
||||
NativeInterop,
|
||||
|
||||
/// <summary>Initialization or finalization callback.</summary>
|
||||
InitCallback,
|
||||
|
||||
/// <summary>Indirect call through function pointer (unknown target).</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of call edge.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for offline kit operations.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:OfflineKit";
|
||||
|
||||
/// <summary>
|
||||
/// Enables offline kit operations for this host.
|
||||
/// Default: false (opt-in)
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, import fails if DSSE/Rekor verification fails.
|
||||
/// When false, verification failures are logged as warnings but import proceeds.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool RequireDsse { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, Rekor verification uses only local snapshots.
|
||||
/// No online Rekor API calls are attempted.
|
||||
/// Default: true (for air-gap safety)
|
||||
/// </summary>
|
||||
public bool RekorOfflineMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// URL of the internal attestation verifier service.
|
||||
/// Optional; if not set, verification is performed locally.
|
||||
/// </summary>
|
||||
public string? AttestationVerifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchors for signature verification.
|
||||
/// Matched by PURL pattern; first match wins.
|
||||
/// </summary>
|
||||
public List<TrustAnchorConfig> TrustAnchors { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Path to directory containing trust root public keys.
|
||||
/// Keys are loaded by keyid reference from <see cref="TrustAnchors"/>.
|
||||
/// </summary>
|
||||
public string? TrustRootDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to offline Rekor snapshot directory.
|
||||
/// Contains checkpoint.sig and entries/*.jsonl
|
||||
/// </summary>
|
||||
public string? RekorSnapshotDirectory { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
public sealed class OfflineKitOptionsValidator : IValidateOptions<OfflineKitOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, OfflineKitOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("OfflineKit options must be provided.");
|
||||
}
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AttestationVerifier))
|
||||
{
|
||||
if (!Uri.TryCreate(options.AttestationVerifier, UriKind.Absolute, out _))
|
||||
{
|
||||
errors.Add("AttestationVerifier must be an absolute URI when provided.");
|
||||
}
|
||||
}
|
||||
|
||||
options.TrustAnchors ??= new List<TrustAnchorConfig>();
|
||||
|
||||
if (options.RequireDsse && options.TrustAnchors.Count == 0)
|
||||
{
|
||||
errors.Add("RequireDsse is true but no TrustAnchors are configured.");
|
||||
}
|
||||
|
||||
if (options.TrustAnchors.Count > 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.TrustRootDirectory))
|
||||
{
|
||||
errors.Add("TrustRootDirectory must be configured when TrustAnchors are present.");
|
||||
}
|
||||
else if (!Directory.Exists(options.TrustRootDirectory))
|
||||
{
|
||||
errors.Add($"TrustRootDirectory does not exist: {options.TrustRootDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.RekorOfflineMode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.RekorSnapshotDirectory))
|
||||
{
|
||||
errors.Add("RekorSnapshotDirectory must be configured when RekorOfflineMode is enabled.");
|
||||
}
|
||||
else if (!Directory.Exists(options.RekorSnapshotDirectory))
|
||||
{
|
||||
errors.Add($"RekorSnapshotDirectory does not exist: {options.RekorSnapshotDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var anchor in options.TrustAnchors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(anchor.AnchorId))
|
||||
{
|
||||
errors.Add("TrustAnchor has empty AnchorId.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(anchor.PurlPattern))
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' has empty PurlPattern.");
|
||||
}
|
||||
|
||||
anchor.AllowedKeyIds ??= new List<string>();
|
||||
if (anchor.AllowedKeyIds.Count == 0)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' has no AllowedKeyIds.");
|
||||
}
|
||||
|
||||
if (anchor.MinSignatures < 1)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' MinSignatures must be >= 1.");
|
||||
}
|
||||
else if (anchor.AllowedKeyIds.Count > 0 && anchor.MinSignatures > anchor.AllowedKeyIds.Count)
|
||||
{
|
||||
errors.Add(
|
||||
$"TrustAnchor '{anchor.AnchorId}' MinSignatures ({anchor.MinSignatures}) exceeds AllowedKeyIds count ({anchor.AllowedKeyIds.Count}).");
|
||||
}
|
||||
|
||||
foreach (var keyId in anchor.AllowedKeyIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' contains an empty AllowedKeyId entry.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = TrustAnchorRegistry.NormalizeKeyId(keyId);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' contains an empty AllowedKeyId entry.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
|| normalized.Contains(Path.DirectorySeparatorChar)
|
||||
|| normalized.Contains(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' contains invalid AllowedKeyId '{keyId}'.");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = new PurlPatternMatcher(anchor.PurlPattern);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"TrustAnchor '{anchor.AnchorId}' has invalid PurlPattern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var duplicateIds = options.TrustAnchors
|
||||
.Where(anchor => !string.IsNullOrWhiteSpace(anchor.AnchorId))
|
||||
.GroupBy(anchor => anchor.AnchorId.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(grouping => grouping.Count() > 1)
|
||||
.Select(grouping => grouping.Key)
|
||||
.ToList();
|
||||
|
||||
if (duplicateIds.Count > 0)
|
||||
{
|
||||
errors.Add($"Duplicate TrustAnchor AnchorIds: {string.Join(", ", duplicateIds)}");
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidateOptionsResult.Fail(errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor configuration for ecosystem-specific signing authorities.
|
||||
/// </summary>
|
||||
public sealed class TrustAnchorConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this trust anchor.
|
||||
/// Used in audit logs and error messages.
|
||||
/// </summary>
|
||||
public string AnchorId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// PURL pattern to match against.
|
||||
/// Supports glob patterns: "pkg:npm/*", "pkg:maven/org.apache.*", "*".
|
||||
/// Patterns are matched in order; first match wins.
|
||||
/// </summary>
|
||||
public string PurlPattern { get; set; } = "*";
|
||||
|
||||
/// <summary>
|
||||
/// List of allowed key fingerprints (SHA-256 of public key).
|
||||
/// Format: "sha256:hexstring" or just "hexstring".
|
||||
/// </summary>
|
||||
public List<string> AllowedKeyIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional description for documentation/UI purposes.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this anchor expires. Null = no expiry.
|
||||
/// After expiry, anchor is skipped with a warning.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum required signatures from this anchor.
|
||||
/// Default: 1 (at least one key must sign)
|
||||
/// </summary>
|
||||
public int MinSignatures { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Drift;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates FN-Drift rate with stratification.
|
||||
/// </summary>
|
||||
public sealed class FnDriftCalculator
|
||||
{
|
||||
private readonly IClassificationHistoryRepository _repository;
|
||||
private readonly ILogger<FnDriftCalculator> _logger;
|
||||
|
||||
public FnDriftCalculator(
|
||||
IClassificationHistoryRepository repository,
|
||||
ILogger<FnDriftCalculator> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes FN-Drift for a tenant over a rolling window.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant to calculate for</param>
|
||||
/// <param name="windowDays">Rolling window in days (default: 30)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>FN-Drift summary with stratification</returns>
|
||||
public async Task<FnDrift30dSummary> CalculateAsync(
|
||||
Guid tenantId,
|
||||
int windowDays = 30,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-windowDays);
|
||||
var changes = await _repository.GetChangesAsync(tenantId, since, cancellationToken);
|
||||
|
||||
var fnTransitions = changes.Where(c => c.IsFnTransition).ToList();
|
||||
var totalEvaluated = changes.Count;
|
||||
|
||||
var summary = new FnDrift30dSummary
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TotalFnTransitions = fnTransitions.Count,
|
||||
TotalEvaluated = totalEvaluated,
|
||||
FnDriftPercent = totalEvaluated > 0
|
||||
? Math.Round((decimal)fnTransitions.Count / totalEvaluated * 100, 4)
|
||||
: 0,
|
||||
FeedCaused = fnTransitions.Count(c => c.Cause == DriftCause.FeedDelta),
|
||||
RuleCaused = fnTransitions.Count(c => c.Cause == DriftCause.RuleDelta),
|
||||
LatticeCaused = fnTransitions.Count(c => c.Cause == DriftCause.LatticeDelta),
|
||||
ReachabilityCaused = fnTransitions.Count(c => c.Cause == DriftCause.ReachabilityDelta),
|
||||
EngineCaused = fnTransitions.Count(c => c.Cause == DriftCause.Engine)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"FN-Drift for tenant {TenantId}: {Percent}% ({FnCount}/{Total}), " +
|
||||
"Feed={Feed}, Rule={Rule}, Lattice={Lattice}, Reach={Reach}, Engine={Engine}",
|
||||
tenantId, summary.FnDriftPercent, summary.TotalFnTransitions, summary.TotalEvaluated,
|
||||
summary.FeedCaused, summary.RuleCaused, summary.LatticeCaused,
|
||||
summary.ReachabilityCaused, summary.EngineCaused);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the drift cause for a classification change.
|
||||
/// </summary>
|
||||
public DriftCause DetermineCause(
|
||||
string? previousFeedVersion,
|
||||
string? currentFeedVersion,
|
||||
string? previousRuleHash,
|
||||
string? currentRuleHash,
|
||||
string? previousLatticeHash,
|
||||
string? currentLatticeHash,
|
||||
bool? previousReachable,
|
||||
bool? currentReachable)
|
||||
{
|
||||
// Priority order: feed > rule > lattice > reachability > engine > other
|
||||
|
||||
// Check feed delta
|
||||
if (!string.Equals(previousFeedVersion, currentFeedVersion, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: feed_delta (prev={PrevFeed}, curr={CurrFeed})",
|
||||
previousFeedVersion, currentFeedVersion);
|
||||
return DriftCause.FeedDelta;
|
||||
}
|
||||
|
||||
// Check rule delta
|
||||
if (!string.Equals(previousRuleHash, currentRuleHash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: rule_delta (prev={PrevRule}, curr={CurrRule})",
|
||||
previousRuleHash, currentRuleHash);
|
||||
return DriftCause.RuleDelta;
|
||||
}
|
||||
|
||||
// Check lattice delta
|
||||
if (!string.Equals(previousLatticeHash, currentLatticeHash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: lattice_delta (prev={PrevLattice}, curr={CurrLattice})",
|
||||
previousLatticeHash, currentLatticeHash);
|
||||
return DriftCause.LatticeDelta;
|
||||
}
|
||||
|
||||
// Check reachability delta
|
||||
if (previousReachable != currentReachable)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Drift cause: reachability_delta (prev={PrevReach}, curr={CurrReach})",
|
||||
previousReachable, currentReachable);
|
||||
return DriftCause.ReachabilityDelta;
|
||||
}
|
||||
|
||||
// If nothing external changed, it's an engine change or unknown
|
||||
_logger.LogDebug("Drift cause: other (no external cause identified)");
|
||||
return DriftCause.Other;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ClassificationChange record for a status transition.
|
||||
/// </summary>
|
||||
public ClassificationChange CreateChange(
|
||||
string artifactDigest,
|
||||
string vulnId,
|
||||
string packagePurl,
|
||||
Guid tenantId,
|
||||
Guid manifestId,
|
||||
Guid executionId,
|
||||
ClassificationStatus previousStatus,
|
||||
ClassificationStatus newStatus,
|
||||
DriftCause cause,
|
||||
IReadOnlyDictionary<string, string>? causeDetail = null)
|
||||
{
|
||||
return new ClassificationChange
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
VulnId = vulnId,
|
||||
PackagePurl = packagePurl,
|
||||
TenantId = tenantId,
|
||||
ManifestId = manifestId,
|
||||
ExecutionId = executionId,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = newStatus,
|
||||
Cause = cause,
|
||||
CauseDetail = causeDetail,
|
||||
ChangedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the FN-Drift rate exceeds the threshold.
|
||||
/// </summary>
|
||||
/// <param name="summary">The drift summary to check</param>
|
||||
/// <param name="thresholdPercent">Maximum acceptable FN-Drift rate (default: 5%)</param>
|
||||
/// <returns>True if drift rate exceeds threshold</returns>
|
||||
public bool ExceedsThreshold(FnDrift30dSummary summary, decimal thresholdPercent = 5.0m)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
var exceeds = summary.FnDriftPercent > thresholdPercent;
|
||||
|
||||
if (exceeds)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"FN-Drift for tenant {TenantId} exceeds threshold: {Percent}% > {Threshold}%",
|
||||
summary.TenantId, summary.FnDriftPercent, thresholdPercent);
|
||||
}
|
||||
|
||||
return exceeds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
public sealed class FileSystemPublicKeyLoader : IPublicKeyLoader
|
||||
{
|
||||
private static readonly string[] CandidateExtensions =
|
||||
{
|
||||
string.Empty,
|
||||
".pub",
|
||||
".pem",
|
||||
".der"
|
||||
};
|
||||
|
||||
public byte[]? LoadKey(string keyId, string? keyDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(keyDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keyId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
|| keyId.Contains(Path.DirectorySeparatorChar)
|
||||
|| keyId.Contains(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var extension in CandidateExtensions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(keyDirectory, keyId + extension);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
return TryParsePemPublicKey(bytes) ?? bytes;
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryParsePemPublicKey(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string Begin = "-----BEGIN PUBLIC KEY-----";
|
||||
const string End = "-----END PUBLIC KEY-----";
|
||||
|
||||
var beginIndex = text.IndexOf(Begin, StringComparison.Ordinal);
|
||||
if (beginIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endIndex = text.IndexOf(End, StringComparison.Ordinal);
|
||||
if (endIndex <= beginIndex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var base64 = text
|
||||
.Substring(beginIndex + Begin.Length, endIndex - (beginIndex + Begin.Length))
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal)
|
||||
.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
public interface IPublicKeyLoader
|
||||
{
|
||||
byte[]? LoadKey(string keyId, string? keyDirectory);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
public interface ITrustAnchorRegistry
|
||||
{
|
||||
TrustAnchorResolution? ResolveForPurl(string purl);
|
||||
|
||||
IReadOnlyList<TrustAnchorConfig> GetAllAnchors();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Package URLs against glob patterns.
|
||||
/// Supports:
|
||||
/// - Exact match: "pkg:npm/@scope/package@1.0.0"
|
||||
/// - Prefix wildcard: "pkg:npm/*"
|
||||
/// - Infix wildcard: "pkg:maven/org.apache.*"
|
||||
/// - Universal: "*"
|
||||
/// </summary>
|
||||
public sealed class PurlPatternMatcher
|
||||
{
|
||||
private readonly string _pattern;
|
||||
private readonly Regex _regex;
|
||||
|
||||
public PurlPatternMatcher(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
throw new ArgumentException("Pattern cannot be empty.", nameof(pattern));
|
||||
}
|
||||
|
||||
_pattern = pattern.Trim();
|
||||
_regex = CompilePattern(_pattern);
|
||||
}
|
||||
|
||||
public bool IsMatch(string? purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _regex.IsMatch(purl);
|
||||
}
|
||||
|
||||
public string Pattern => _pattern;
|
||||
|
||||
private static Regex CompilePattern(string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return new Regex("^.*$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
var escaped = Regex.Escape(pattern);
|
||||
escaped = escaped.Replace(@"\*", ".*", StringComparison.Ordinal);
|
||||
return new Regex($"^{escaped}$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.Core.TrustAnchors;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for trust anchors with PURL-based resolution.
|
||||
/// Thread-safe and supports runtime reload.
|
||||
/// </summary>
|
||||
public sealed class TrustAnchorRegistry : ITrustAnchorRegistry
|
||||
{
|
||||
private readonly IOptionsMonitor<OfflineKitOptions> _options;
|
||||
private readonly IPublicKeyLoader _keyLoader;
|
||||
private readonly ILogger<TrustAnchorRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private IReadOnlyList<CompiledTrustAnchor>? _compiledAnchors;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public TrustAnchorRegistry(
|
||||
IOptionsMonitor<OfflineKitOptions> options,
|
||||
IPublicKeyLoader keyLoader,
|
||||
ILogger<TrustAnchorRegistry> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_keyLoader = keyLoader ?? throw new ArgumentNullException(nameof(keyLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
|
||||
_options.OnChange(_ => InvalidateCache());
|
||||
}
|
||||
|
||||
public TrustAnchorResolution? ResolveForPurl(string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_options.CurrentValue.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var anchors = GetCompiledAnchors();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
if (!anchor.Matcher.IsMatch(purl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (anchor.Config.ExpiresAt is { } expiresAt && expiresAt < now)
|
||||
{
|
||||
_logger.LogWarning("Trust anchor {AnchorId} has expired, skipping.", anchor.Config.AnchorId);
|
||||
continue;
|
||||
}
|
||||
|
||||
return new TrustAnchorResolution(
|
||||
AnchorId: anchor.Config.AnchorId,
|
||||
AllowedKeyIds: anchor.AllowedKeyIds,
|
||||
MinSignatures: anchor.Config.MinSignatures,
|
||||
PublicKeys: anchor.LoadedKeys);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<TrustAnchorConfig> GetAllAnchors()
|
||||
=> _options.CurrentValue.TrustAnchors.AsReadOnly();
|
||||
|
||||
private IReadOnlyList<CompiledTrustAnchor> GetCompiledAnchors()
|
||||
{
|
||||
if (_compiledAnchors is not null)
|
||||
{
|
||||
return _compiledAnchors;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_compiledAnchors is not null)
|
||||
{
|
||||
return _compiledAnchors;
|
||||
}
|
||||
|
||||
var config = _options.CurrentValue;
|
||||
config.TrustAnchors ??= new List<TrustAnchorConfig>();
|
||||
|
||||
var compiled = new List<CompiledTrustAnchor>(config.TrustAnchors.Count);
|
||||
foreach (var anchor in config.TrustAnchors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var matcher = new PurlPatternMatcher(anchor.PurlPattern);
|
||||
var (allowedKeyIds, keys) = LoadKeysForAnchor(anchor, config.TrustRootDirectory);
|
||||
compiled.Add(new CompiledTrustAnchor(anchor, matcher, allowedKeyIds, keys));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to compile trust anchor {AnchorId}.", anchor.AnchorId);
|
||||
}
|
||||
}
|
||||
|
||||
_compiledAnchors = compiled.AsReadOnly();
|
||||
return _compiledAnchors;
|
||||
}
|
||||
}
|
||||
|
||||
private (IReadOnlyList<string> AllowedKeyIds, IReadOnlyDictionary<string, byte[]> LoadedKeys) LoadKeysForAnchor(
|
||||
TrustAnchorConfig anchor,
|
||||
string? keyDirectory)
|
||||
{
|
||||
var normalizedKeyIds = new List<string>(anchor.AllowedKeyIds.Count);
|
||||
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var configuredKeyId in anchor.AllowedKeyIds)
|
||||
{
|
||||
var normalizedKeyId = NormalizeKeyId(configuredKeyId);
|
||||
if (string.IsNullOrWhiteSpace(normalizedKeyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedKeyIds.Add(normalizedKeyId);
|
||||
|
||||
var keyBytes = _keyLoader.LoadKey(normalizedKeyId, keyDirectory);
|
||||
if (keyBytes is null)
|
||||
{
|
||||
_logger.LogWarning("Key {KeyId} not found for anchor {AnchorId}.", configuredKeyId, anchor.AnchorId);
|
||||
continue;
|
||||
}
|
||||
|
||||
keys[normalizedKeyId] = keyBytes;
|
||||
keys[$"sha256:{normalizedKeyId}"] = keyBytes;
|
||||
}
|
||||
|
||||
return (normalizedKeyIds.AsReadOnly(), keys);
|
||||
}
|
||||
|
||||
internal static string NormalizeKeyId(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = keyId.Trim();
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = trimmed[7..];
|
||||
}
|
||||
|
||||
trimmed = trimmed.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return LooksLikeHex(trimmed)
|
||||
? trimmed.ToLowerInvariant()
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
private static bool LooksLikeHex(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var isHex = (character >= '0' && character <= '9')
|
||||
|| (character >= 'a' && character <= 'f')
|
||||
|| (character >= 'A' && character <= 'F');
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void InvalidateCache()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_compiledAnchors = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CompiledTrustAnchor(
|
||||
TrustAnchorConfig Config,
|
||||
PurlPatternMatcher Matcher,
|
||||
IReadOnlyList<string> AllowedKeyIds,
|
||||
IReadOnlyDictionary<string, byte[]> LoadedKeys);
|
||||
}
|
||||
|
||||
public sealed record TrustAnchorResolution(
|
||||
string AnchorId,
|
||||
IReadOnlyList<string> AllowedKeyIds,
|
||||
int MinSignatures,
|
||||
IReadOnlyDictionary<string, byte[]> PublicKeys);
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace StellaOps.Scanner.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a classification status change for FN-Drift tracking.
|
||||
/// </summary>
|
||||
public sealed record ClassificationChange
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
// Artifact identification
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
|
||||
// Scan context
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid ManifestId { get; init; }
|
||||
public required Guid ExecutionId { get; init; }
|
||||
|
||||
// Status transition
|
||||
public required ClassificationStatus PreviousStatus { get; init; }
|
||||
public required ClassificationStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this was a false-negative transition (unaffected/unknown -> affected)
|
||||
/// </summary>
|
||||
public bool IsFnTransition =>
|
||||
PreviousStatus is ClassificationStatus.Unaffected or ClassificationStatus.Unknown
|
||||
&& NewStatus == ClassificationStatus.Affected;
|
||||
|
||||
// Drift cause
|
||||
public required DriftCause Cause { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? CauseDetail { get; init; }
|
||||
|
||||
// Timestamp
|
||||
public DateTimeOffset ChangedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification status values.
|
||||
/// </summary>
|
||||
public enum ClassificationStatus
|
||||
{
|
||||
/// <summary>First scan, no previous status</summary>
|
||||
New,
|
||||
|
||||
/// <summary>Confirmed not affected</summary>
|
||||
Unaffected,
|
||||
|
||||
/// <summary>Status unknown/uncertain</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Confirmed affected</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Previously affected, now fixed</summary>
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stratification causes for FN-Drift analysis.
|
||||
/// </summary>
|
||||
public enum DriftCause
|
||||
{
|
||||
/// <summary>Vulnerability feed updated (NVD, GHSA, OVAL)</summary>
|
||||
FeedDelta,
|
||||
|
||||
/// <summary>Policy rules changed</summary>
|
||||
RuleDelta,
|
||||
|
||||
/// <summary>VEX lattice state changed</summary>
|
||||
LatticeDelta,
|
||||
|
||||
/// <summary>Reachability analysis changed</summary>
|
||||
ReachabilityDelta,
|
||||
|
||||
/// <summary>Scanner engine change (should be ~0)</summary>
|
||||
Engine,
|
||||
|
||||
/// <summary>Other/unknown cause</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FN-Drift statistics for a time period.
|
||||
/// </summary>
|
||||
public sealed record FnDriftStats
|
||||
{
|
||||
public required DateOnly DayBucket { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required DriftCause Cause { get; init; }
|
||||
|
||||
public required int TotalReclassified { get; init; }
|
||||
public required int FnCount { get; init; }
|
||||
public required decimal FnDriftPercent { get; init; }
|
||||
|
||||
// Stratification counts
|
||||
public required int FeedDeltaCount { get; init; }
|
||||
public required int RuleDeltaCount { get; init; }
|
||||
public required int LatticeDeltaCount { get; init; }
|
||||
public required int ReachabilityDeltaCount { get; init; }
|
||||
public required int EngineCount { get; init; }
|
||||
public required int OtherCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 30-day rolling FN-Drift summary.
|
||||
/// </summary>
|
||||
public sealed record FnDrift30dSummary
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required int TotalFnTransitions { get; init; }
|
||||
public required int TotalEvaluated { get; init; }
|
||||
public required decimal FnDriftPercent { get; init; }
|
||||
|
||||
// Stratification breakdown
|
||||
public required int FeedCaused { get; init; }
|
||||
public required int RuleCaused { get; init; }
|
||||
public required int LatticeCaused { get; init; }
|
||||
public required int ReachabilityCaused { get; init; }
|
||||
public required int EngineCaused { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Classification history for FN-Drift tracking
|
||||
-- Per advisory section 13.2
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- Artifact identification
|
||||
artifact_digest TEXT NOT NULL,
|
||||
vuln_id TEXT NOT NULL,
|
||||
package_purl TEXT NOT NULL,
|
||||
|
||||
-- Scan context
|
||||
tenant_id UUID NOT NULL,
|
||||
manifest_id UUID NOT NULL,
|
||||
execution_id UUID NOT NULL,
|
||||
|
||||
-- Status transition
|
||||
previous_status TEXT NOT NULL, -- 'new', 'unaffected', 'unknown', 'affected', 'fixed'
|
||||
new_status TEXT NOT NULL,
|
||||
is_fn_transition BOOLEAN NOT NULL GENERATED ALWAYS AS (
|
||||
previous_status IN ('unaffected', 'unknown') AND new_status = 'affected'
|
||||
) STORED,
|
||||
|
||||
-- Drift cause classification
|
||||
cause TEXT NOT NULL, -- 'feed_delta', 'rule_delta', 'lattice_delta', 'reachability_delta', 'engine', 'other'
|
||||
cause_detail JSONB, -- Additional context (e.g., feed version, rule hash)
|
||||
|
||||
-- Timestamps
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_previous_status CHECK (previous_status IN ('new', 'unaffected', 'unknown', 'affected', 'fixed')),
|
||||
CONSTRAINT valid_new_status CHECK (new_status IN ('unaffected', 'unknown', 'affected', 'fixed')),
|
||||
CONSTRAINT valid_cause CHECK (cause IN ('feed_delta', 'rule_delta', 'lattice_delta', 'reachability_delta', 'engine', 'other'))
|
||||
);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_artifact ON classification_history(artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_tenant ON classification_history(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_changed_at ON classification_history(changed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_fn_transition ON classification_history(is_fn_transition) WHERE is_fn_transition = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_cause ON classification_history(cause);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_vuln ON classification_history(vuln_id);
|
||||
|
||||
COMMENT ON TABLE classification_history IS 'Tracks vulnerability classification changes for FN-Drift analysis';
|
||||
COMMENT ON COLUMN classification_history.is_fn_transition IS 'True if this was a false-negative transition (unaffected/unknown -> affected)';
|
||||
COMMENT ON COLUMN classification_history.cause IS 'Stratification cause: feed_delta, rule_delta, lattice_delta, reachability_delta, engine, other';
|
||||
|
||||
-- Materialized view for FN-Drift statistics
|
||||
-- Aggregates classification_history for dashboard queries
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS fn_drift_stats AS
|
||||
SELECT
|
||||
date_trunc('day', changed_at)::date AS day_bucket,
|
||||
tenant_id,
|
||||
cause,
|
||||
|
||||
-- Total reclassifications
|
||||
COUNT(*) AS total_reclassified,
|
||||
|
||||
-- FN transitions (unaffected/unknown -> affected)
|
||||
COUNT(*) FILTER (WHERE is_fn_transition) AS fn_count,
|
||||
|
||||
-- FN-Drift rate
|
||||
ROUND(
|
||||
(COUNT(*) FILTER (WHERE is_fn_transition)::numeric /
|
||||
NULLIF(COUNT(*), 0)) * 100, 4
|
||||
) AS fn_drift_percent,
|
||||
|
||||
-- Stratification counts
|
||||
COUNT(*) FILTER (WHERE cause = 'feed_delta') AS feed_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'rule_delta') AS rule_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'lattice_delta') AS lattice_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'reachability_delta') AS reachability_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'engine') AS engine_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'other') AS other_count
|
||||
|
||||
FROM classification_history
|
||||
GROUP BY date_trunc('day', changed_at)::date, tenant_id, cause;
|
||||
|
||||
-- Index for efficient queries
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_fn_drift_stats_pk ON fn_drift_stats(day_bucket, tenant_id, cause);
|
||||
CREATE INDEX IF NOT EXISTS idx_fn_drift_stats_tenant ON fn_drift_stats(tenant_id);
|
||||
|
||||
-- View for 30-day rolling FN-Drift (per advisory definition)
|
||||
CREATE OR REPLACE VIEW fn_drift_30d AS
|
||||
SELECT
|
||||
tenant_id,
|
||||
SUM(fn_count)::int AS total_fn_transitions,
|
||||
SUM(total_reclassified)::int AS total_evaluated,
|
||||
ROUND(
|
||||
(SUM(fn_count)::numeric / NULLIF(SUM(total_reclassified), 0)) * 100, 4
|
||||
) AS fn_drift_percent,
|
||||
|
||||
-- Stratification breakdown
|
||||
SUM(feed_delta_count)::int AS feed_caused,
|
||||
SUM(rule_delta_count)::int AS rule_caused,
|
||||
SUM(lattice_delta_count)::int AS lattice_caused,
|
||||
SUM(reachability_delta_count)::int AS reachability_caused,
|
||||
SUM(engine_count)::int AS engine_caused
|
||||
|
||||
FROM fn_drift_stats
|
||||
WHERE day_bucket >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY tenant_id;
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW fn_drift_stats IS 'Daily FN-Drift statistics, refresh periodically';
|
||||
COMMENT ON VIEW fn_drift_30d IS 'Rolling 30-day FN-Drift rate per tenant';
|
||||
@@ -4,4 +4,5 @@ internal static class MigrationIds
|
||||
{
|
||||
public const string CreateTables = "001_create_tables.sql";
|
||||
public const string ProofSpineTables = "002_proof_spine_tables.sql";
|
||||
public const string ClassificationHistory = "003_classification_history.sql";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of classification history repository.
|
||||
/// </summary>
|
||||
public sealed class ClassificationHistoryRepository : RepositoryBase<ScannerDataSource>, IClassificationHistoryRepository
|
||||
{
|
||||
private const string Tenant = "";
|
||||
private string Table => $"{SchemaName}.classification_history";
|
||||
private string DriftStatsView => $"{SchemaName}.fn_drift_stats";
|
||||
private string Drift30dView => $"{SchemaName}.fn_drift_30d";
|
||||
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public ClassificationHistoryRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<ClassificationHistoryRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {Table}
|
||||
(artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, cause, cause_detail, changed_at)
|
||||
VALUES
|
||||
(@artifact_digest, @vuln_id, @package_purl, @tenant_id, @manifest_id, @execution_id,
|
||||
@previous_status, @new_status, @cause, @cause_detail::jsonb, @changed_at)
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddChangeParameters(cmd, change),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
|
||||
var changeList = changes.ToList();
|
||||
if (changeList.Count == 0) return;
|
||||
|
||||
// Use batch insert for better performance
|
||||
foreach (var change in changeList)
|
||||
{
|
||||
await InsertAsync(change, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE tenant_id = @tenant_id AND changed_at >= @since
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "since", since);
|
||||
},
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE artifact_digest = @artifact_digest
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "artifact_digest", artifactDigest),
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(
|
||||
string vulnId,
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnId);
|
||||
|
||||
var sql = tenantId.HasValue
|
||||
? $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE vuln_id = @vuln_id AND tenant_id = @tenant_id
|
||||
ORDER BY changed_at DESC
|
||||
"""
|
||||
: $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE vuln_id = @vuln_id
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vuln_id", vulnId);
|
||||
if (tenantId.HasValue)
|
||||
AddParameter(cmd, "tenant_id", tenantId.Value);
|
||||
},
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(
|
||||
Guid tenantId,
|
||||
DateOnly fromDate,
|
||||
DateOnly toDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
|
||||
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
|
||||
engine_count, other_count
|
||||
FROM {DriftStatsView}
|
||||
WHERE tenant_id = @tenant_id AND day_bucket >= @from_date AND day_bucket <= @to_date
|
||||
ORDER BY day_bucket DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from_date", fromDate);
|
||||
AddParameter(cmd, "to_date", toDate);
|
||||
},
|
||||
MapDriftStats,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT tenant_id, total_fn_transitions, total_evaluated, fn_drift_percent,
|
||||
feed_caused, rule_caused, lattice_caused, reachability_caused, engine_caused
|
||||
FROM {Drift30dView}
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapDrift30dSummary,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"REFRESH MATERIALIZED VIEW CONCURRENTLY {DriftStatsView}";
|
||||
|
||||
await ExecuteAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
static _ => { },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private void AddChangeParameters(NpgsqlCommand cmd, ClassificationChange change)
|
||||
{
|
||||
AddParameter(cmd, "artifact_digest", change.ArtifactDigest);
|
||||
AddParameter(cmd, "vuln_id", change.VulnId);
|
||||
AddParameter(cmd, "package_purl", change.PackagePurl);
|
||||
AddParameter(cmd, "tenant_id", change.TenantId);
|
||||
AddParameter(cmd, "manifest_id", change.ManifestId);
|
||||
AddParameter(cmd, "execution_id", change.ExecutionId);
|
||||
AddParameter(cmd, "previous_status", MapStatusToString(change.PreviousStatus));
|
||||
AddParameter(cmd, "new_status", MapStatusToString(change.NewStatus));
|
||||
AddParameter(cmd, "cause", MapCauseToString(change.Cause));
|
||||
AddParameter(cmd, "cause_detail", change.CauseDetail != null
|
||||
? JsonSerializer.Serialize(change.CauseDetail, JsonOptions)
|
||||
: null);
|
||||
AddParameter(cmd, "changed_at", change.ChangedAt);
|
||||
}
|
||||
|
||||
private static ClassificationChange MapChange(NpgsqlDataReader reader)
|
||||
{
|
||||
var causeDetailJson = reader.IsDBNull(11) ? null : reader.GetString(11);
|
||||
var causeDetail = causeDetailJson != null
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(causeDetailJson, JsonOptions)
|
||||
: null;
|
||||
|
||||
return new ClassificationChange
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
ArtifactDigest = reader.GetString(1),
|
||||
VulnId = reader.GetString(2),
|
||||
PackagePurl = reader.GetString(3),
|
||||
TenantId = reader.GetGuid(4),
|
||||
ManifestId = reader.GetGuid(5),
|
||||
ExecutionId = reader.GetGuid(6),
|
||||
PreviousStatus = MapStringToStatus(reader.GetString(7)),
|
||||
NewStatus = MapStringToStatus(reader.GetString(8)),
|
||||
// is_fn_transition is at index 9, but we compute it from PreviousStatus/NewStatus
|
||||
Cause = MapStringToCause(reader.GetString(10)),
|
||||
CauseDetail = causeDetail,
|
||||
ChangedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}
|
||||
|
||||
private static FnDriftStats MapDriftStats(NpgsqlDataReader reader)
|
||||
{
|
||||
return new FnDriftStats
|
||||
{
|
||||
DayBucket = DateOnly.FromDateTime(reader.GetDateTime(0)),
|
||||
TenantId = reader.GetGuid(1),
|
||||
Cause = MapStringToCause(reader.GetString(2)),
|
||||
TotalReclassified = reader.GetInt32(3),
|
||||
FnCount = reader.GetInt32(4),
|
||||
FnDriftPercent = reader.GetDecimal(5),
|
||||
FeedDeltaCount = reader.GetInt32(6),
|
||||
RuleDeltaCount = reader.GetInt32(7),
|
||||
LatticeDeltaCount = reader.GetInt32(8),
|
||||
ReachabilityDeltaCount = reader.GetInt32(9),
|
||||
EngineCount = reader.GetInt32(10),
|
||||
OtherCount = reader.GetInt32(11)
|
||||
};
|
||||
}
|
||||
|
||||
private static FnDrift30dSummary MapDrift30dSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new FnDrift30dSummary
|
||||
{
|
||||
TenantId = reader.GetGuid(0),
|
||||
TotalFnTransitions = reader.GetInt32(1),
|
||||
TotalEvaluated = reader.GetInt32(2),
|
||||
FnDriftPercent = reader.IsDBNull(3) ? 0 : reader.GetDecimal(3),
|
||||
FeedCaused = reader.GetInt32(4),
|
||||
RuleCaused = reader.GetInt32(5),
|
||||
LatticeCaused = reader.GetInt32(6),
|
||||
ReachabilityCaused = reader.GetInt32(7),
|
||||
EngineCaused = reader.GetInt32(8)
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapStatusToString(ClassificationStatus status) => status switch
|
||||
{
|
||||
ClassificationStatus.New => "new",
|
||||
ClassificationStatus.Unaffected => "unaffected",
|
||||
ClassificationStatus.Unknown => "unknown",
|
||||
ClassificationStatus.Affected => "affected",
|
||||
ClassificationStatus.Fixed => "fixed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static ClassificationStatus MapStringToStatus(string status) => status switch
|
||||
{
|
||||
"new" => ClassificationStatus.New,
|
||||
"unaffected" => ClassificationStatus.Unaffected,
|
||||
"unknown" => ClassificationStatus.Unknown,
|
||||
"affected" => ClassificationStatus.Affected,
|
||||
"fixed" => ClassificationStatus.Fixed,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static string MapCauseToString(DriftCause cause) => cause switch
|
||||
{
|
||||
DriftCause.FeedDelta => "feed_delta",
|
||||
DriftCause.RuleDelta => "rule_delta",
|
||||
DriftCause.LatticeDelta => "lattice_delta",
|
||||
DriftCause.ReachabilityDelta => "reachability_delta",
|
||||
DriftCause.Engine => "engine",
|
||||
DriftCause.Other => "other",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cause))
|
||||
};
|
||||
|
||||
private static DriftCause MapStringToCause(string cause) => cause switch
|
||||
{
|
||||
"feed_delta" => DriftCause.FeedDelta,
|
||||
"rule_delta" => DriftCause.RuleDelta,
|
||||
"lattice_delta" => DriftCause.LatticeDelta,
|
||||
"reachability_delta" => DriftCause.ReachabilityDelta,
|
||||
"engine" => DriftCause.Engine,
|
||||
"other" => DriftCause.Other,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cause))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for classification history operations.
|
||||
/// </summary>
|
||||
public interface IClassificationHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a classification status change.
|
||||
/// </summary>
|
||||
Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records multiple classification changes in a batch.
|
||||
/// </summary>
|
||||
Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a tenant since a given date.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a specific artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a specific vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(
|
||||
string vulnId,
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets FN-Drift statistics from the materialized view.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(
|
||||
Guid tenantId,
|
||||
DateOnly fromDate,
|
||||
DateOnly toDate,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets 30-day rolling FN-Drift summary for a tenant.
|
||||
/// </summary>
|
||||
Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the FN-Drift statistics materialized view.
|
||||
/// </summary>
|
||||
Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user