up
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
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 18:08:55 +02:00
parent 6e45066e37
commit f1a39c4ce3
234 changed files with 24038 additions and 6910 deletions

View File

@@ -0,0 +1,913 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Reflection;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Callgraph;
/// <summary>
/// Builds Java reachability graphs from class path analysis.
/// Extracts methods, call edges, synthetic roots, and emits unknowns.
/// </summary>
internal sealed class JavaCallgraphBuilder
{
private readonly Dictionary<string, JavaMethodNode> _methods = new();
private readonly List<JavaCallEdge> _edges = new();
private readonly List<JavaSyntheticRoot> _roots = new();
private readonly List<JavaUnknown> _unknowns = new();
private readonly Dictionary<string, string> _classToJarPath = new();
private readonly string _contextDigest;
private int _jarCount;
private int _classCount;
public JavaCallgraphBuilder(string contextDigest)
{
_contextDigest = contextDigest;
}
/// <summary>
/// Adds a class path analysis to the graph.
/// </summary>
public void AddClassPath(JavaClassPathAnalysis classPath, CancellationToken cancellationToken = default)
{
foreach (var segment in classPath.Segments)
{
cancellationToken.ThrowIfCancellationRequested();
_jarCount++;
// Derive PURL from segment identifier (simplified - would use proper mapping in production)
var purl = DerivePurlFromSegment(segment);
foreach (var kvp in segment.ClassLocations)
{
cancellationToken.ThrowIfCancellationRequested();
var className = kvp.Key;
var location = kvp.Value;
_classCount++;
_classToJarPath[className] = segment.Identifier;
try
{
using var stream = location.OpenClassStream(cancellationToken);
AddClassFile(stream, className, segment.Identifier, purl, cancellationToken);
}
catch (Exception)
{
// Record as unknown if class file cannot be parsed
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
segment.Identifier,
JavaUnknownType.UnresolvedClass,
className,
null);
_unknowns.Add(new JavaUnknown(
UnknownId: unknownId,
UnknownType: JavaUnknownType.UnresolvedClass,
SourceId: segment.Identifier,
ClassName: className,
MethodName: null,
Reason: "Class file could not be parsed",
JarPath: segment.Identifier));
}
}
}
}
private static string? DerivePurlFromSegment(JavaClassPathSegment segment)
{
// Simplified PURL derivation from JAR path
var fileName = Path.GetFileNameWithoutExtension(segment.Identifier);
if (string.IsNullOrEmpty(fileName))
{
return null;
}
return $"pkg:maven/{fileName}";
}
/// <summary>
/// Adds reflection analysis edges.
/// </summary>
public void AddReflectionAnalysis(JavaReflectionAnalysis reflectionAnalysis)
{
foreach (var edge in reflectionAnalysis.Edges)
{
// Use actual property names from JavaReflectionEdge record
var callerId = JavaGraphIdentifiers.ComputeMethodId(
JavaGraphIdentifiers.NormalizeClassName(edge.SourceClass),
edge.MethodName,
edge.MethodDescriptor);
var targetClassName = edge.TargetType ?? "unknown";
var isResolved = edge.TargetType is not null;
// For reflection, the callee is a class load, not a method call
var calleeId = isResolved
? JavaGraphIdentifiers.ComputeMethodId(JavaGraphIdentifiers.NormalizeClassName(targetClassName), "<clinit>", "()V")
: $"reflection:{targetClassName}";
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, calleeId, edge.InstructionOffset);
var confidence = edge.Confidence == JavaReflectionConfidence.High ? 0.9 : 0.5;
var edgeType = edge.Reason switch
{
JavaReflectionReason.ClassForName => JavaEdgeType.Reflection,
JavaReflectionReason.ClassLoaderLoadClass => JavaEdgeType.Reflection,
JavaReflectionReason.ServiceLoaderLoad => JavaEdgeType.ServiceLoader,
_ => JavaEdgeType.Reflection,
};
_edges.Add(new JavaCallEdge(
EdgeId: edgeId,
CallerId: callerId,
CalleeId: calleeId,
CalleePurl: null, // Reflection targets often unknown
CalleeMethodDigest: null,
EdgeType: edgeType,
BytecodeOffset: edge.InstructionOffset,
IsResolved: isResolved,
Confidence: confidence));
if (!isResolved)
{
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
edgeId,
JavaUnknownType.ReflectionTarget,
null,
null);
_unknowns.Add(new JavaUnknown(
UnknownId: unknownId,
UnknownType: JavaUnknownType.ReflectionTarget,
SourceId: edgeId,
ClassName: null,
MethodName: null,
Reason: "Reflection target class could not be determined",
JarPath: edge.SegmentIdentifier));
}
}
}
/// <summary>
/// Builds the final reachability graph.
/// </summary>
public JavaReachabilityGraph Build()
{
var methods = _methods.Values
.OrderBy(m => m.ClassName)
.ThenBy(m => m.MethodName)
.ThenBy(m => m.Descriptor)
.ToImmutableArray();
var edges = _edges
.OrderBy(e => e.CallerId)
.ThenBy(e => e.BytecodeOffset)
.ToImmutableArray();
var roots = _roots
.OrderBy(r => (int)r.Phase)
.ThenBy(r => r.Order)
.ThenBy(r => r.TargetId, StringComparer.Ordinal)
.ToImmutableArray();
var unknowns = _unknowns
.OrderBy(u => u.JarPath)
.ThenBy(u => u.SourceId)
.ToImmutableArray();
var contentHash = JavaGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
var metadata = new JavaGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratorVersion: JavaGraphIdentifiers.GetGeneratorVersion(),
ContextDigest: _contextDigest,
JarCount: _jarCount,
ClassCount: _classCount,
MethodCount: methods.Length,
EdgeCount: edges.Length,
UnknownCount: unknowns.Length,
SyntheticRootCount: roots.Length);
return new JavaReachabilityGraph(
_contextDigest,
methods,
edges,
roots,
unknowns,
metadata,
contentHash);
}
private void AddClassFile(Stream stream, string className, string jarPath, string? purl, CancellationToken cancellationToken)
{
var classFile = JavaClassFileParser.Parse(stream, cancellationToken);
var normalizedClassName = JavaGraphIdentifiers.NormalizeClassName(className);
// Add methods
foreach (var method in classFile.Methods)
{
cancellationToken.ThrowIfCancellationRequested();
AddMethod(normalizedClassName, method, jarPath, purl);
}
// Find synthetic roots
FindSyntheticRoots(normalizedClassName, classFile, jarPath);
// Extract call edges from bytecode
foreach (var method in classFile.Methods)
{
cancellationToken.ThrowIfCancellationRequested();
ExtractCallEdges(normalizedClassName, method, jarPath, classFile.ConstantPool);
}
}
private void AddMethod(string className, JavaClassFileParser.MethodInfo method, string jarPath, string? purl)
{
var methodId = JavaGraphIdentifiers.ComputeMethodId(className, method.Name, method.Descriptor);
var methodDigest = JavaGraphIdentifiers.ComputeMethodDigest(className, method.Name, method.Descriptor, method.AccessFlags);
var isStatic = (method.AccessFlags & 0x0008) != 0;
var isPublic = (method.AccessFlags & 0x0001) != 0;
var isSynthetic = (method.AccessFlags & 0x1000) != 0;
var isBridge = (method.AccessFlags & 0x0040) != 0;
var node = new JavaMethodNode(
MethodId: methodId,
ClassName: className,
MethodName: method.Name,
Descriptor: method.Descriptor,
Purl: purl,
JarPath: jarPath,
AccessFlags: method.AccessFlags,
MethodDigest: methodDigest,
IsStatic: isStatic,
IsPublic: isPublic,
IsSynthetic: isSynthetic,
IsBridge: isBridge);
_methods.TryAdd(methodId, node);
}
private void FindSyntheticRoots(string className, JavaClassFileParser.ClassFile classFile, string jarPath)
{
var rootOrder = 0;
foreach (var method in classFile.Methods)
{
var methodId = JavaGraphIdentifiers.ComputeMethodId(className, method.Name, method.Descriptor);
// main method
if (method.Name == "main" && method.Descriptor == "([Ljava/lang/String;)V" &&
(method.AccessFlags & 0x0009) == 0x0009) // public static
{
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.Main, rootOrder++, methodId);
_roots.Add(new JavaSyntheticRoot(
RootId: rootId,
TargetId: methodId,
RootType: JavaRootType.Main,
Source: "main",
JarPath: jarPath,
Phase: JavaRootPhase.Main,
Order: rootOrder - 1));
}
// Static initializer
if (method.Name == "<clinit>")
{
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.ClassLoad, rootOrder++, methodId);
_roots.Add(new JavaSyntheticRoot(
RootId: rootId,
TargetId: methodId,
RootType: JavaRootType.StaticInitializer,
Source: "static_init",
JarPath: jarPath,
Phase: JavaRootPhase.ClassLoad,
Order: rootOrder - 1));
}
// Servlet lifecycle methods
if (classFile.SuperClassName?.Contains("Servlet") == true ||
classFile.Interfaces.Any(i => i.Contains("Servlet")))
{
if (method.Name == "init" && method.Descriptor.StartsWith("(Ljavax/servlet/"))
{
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.AppInit, rootOrder++, methodId);
_roots.Add(new JavaSyntheticRoot(
RootId: rootId,
TargetId: methodId,
RootType: JavaRootType.ServletInit,
Source: "servlet_init",
JarPath: jarPath,
Phase: JavaRootPhase.AppInit,
Order: rootOrder - 1));
}
else if (method.Name is "service" or "doGet" or "doPost" or "doPut" or "doDelete")
{
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.Main, rootOrder++, methodId);
_roots.Add(new JavaSyntheticRoot(
RootId: rootId,
TargetId: methodId,
RootType: JavaRootType.ServletHandler,
Source: "servlet_handler",
JarPath: jarPath,
Phase: JavaRootPhase.Main,
Order: rootOrder - 1));
}
}
// JUnit test methods (check for @Test annotation in attributes)
if ((method.AccessFlags & 0x0001) != 0 && // public
method.Descriptor == "()V" &&
!method.Name.StartsWith("<") &&
method.HasTestAnnotation)
{
var rootId = JavaGraphIdentifiers.ComputeRootId(JavaRootPhase.Main, rootOrder++, methodId);
_roots.Add(new JavaSyntheticRoot(
RootId: rootId,
TargetId: methodId,
RootType: JavaRootType.TestMethod,
Source: "junit_test",
JarPath: jarPath,
Phase: JavaRootPhase.Main,
Order: rootOrder - 1));
}
}
}
private void ExtractCallEdges(
string className,
JavaClassFileParser.MethodInfo method,
string jarPath,
JavaClassFileParser.ConstantPool pool)
{
var callerId = JavaGraphIdentifiers.ComputeMethodId(className, method.Name, method.Descriptor);
if (method.Code is null)
{
return;
}
var code = method.Code;
var offset = 0;
while (offset < code.Length)
{
var instructionOffset = offset;
var opcode = code[offset++];
switch (opcode)
{
case 0xB8: // invokestatic
case 0xB6: // invokevirtual
case 0xB7: // invokespecial
case 0xB9: // invokeinterface
{
if (offset + 2 > code.Length)
{
break;
}
var methodIndex = (code[offset++] << 8) | code[offset++];
if (opcode == 0xB9)
{
offset += 2; // count and zero
}
var methodRef = pool.GetMethodReference(methodIndex);
if (methodRef.HasValue)
{
var targetClass = JavaGraphIdentifiers.NormalizeClassName(methodRef.Value.OwnerInternalName);
var targetMethodId = JavaGraphIdentifiers.ComputeMethodId(
targetClass,
methodRef.Value.Name,
methodRef.Value.Descriptor);
var edgeType = opcode switch
{
0xB8 => JavaEdgeType.InvokeStatic,
0xB6 => JavaEdgeType.InvokeVirtual,
0xB7 => methodRef.Value.Name == "<init>" ? JavaEdgeType.Constructor : JavaEdgeType.InvokeSpecial,
0xB9 => JavaEdgeType.InvokeInterface,
_ => JavaEdgeType.InvokeVirtual,
};
// Check if target is resolved (known in our method set)
var isResolved = _methods.ContainsKey(targetMethodId) ||
_classToJarPath.ContainsKey(targetClass.Replace('.', '/'));
var calleePurl = isResolved ? GetPurlForClass(targetClass) : null;
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, targetMethodId, instructionOffset);
_edges.Add(new JavaCallEdge(
EdgeId: edgeId,
CallerId: callerId,
CalleeId: targetMethodId,
CalleePurl: calleePurl,
CalleeMethodDigest: null, // Would compute if method is in our set
EdgeType: edgeType,
BytecodeOffset: instructionOffset,
IsResolved: isResolved,
Confidence: isResolved ? 1.0 : 0.7));
if (!isResolved)
{
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
edgeId,
JavaUnknownType.UnresolvedMethod,
targetClass,
methodRef.Value.Name);
_unknowns.Add(new JavaUnknown(
UnknownId: unknownId,
UnknownType: JavaUnknownType.UnresolvedMethod,
SourceId: edgeId,
ClassName: targetClass,
MethodName: methodRef.Value.Name,
Reason: "Method not found in analyzed classpath",
JarPath: jarPath));
}
}
break;
}
case 0xBA: // invokedynamic
{
if (offset + 4 > code.Length)
{
break;
}
var dynamicIndex = (code[offset++] << 8) | code[offset++];
offset += 2; // skip zeros
// invokedynamic targets are typically lambdas/method refs - emit as unknown
var targetId = $"dynamic:{dynamicIndex}";
var edgeId = JavaGraphIdentifiers.ComputeEdgeId(callerId, targetId, instructionOffset);
_edges.Add(new JavaCallEdge(
EdgeId: edgeId,
CallerId: callerId,
CalleeId: targetId,
CalleePurl: null,
CalleeMethodDigest: null,
EdgeType: JavaEdgeType.InvokeDynamic,
BytecodeOffset: instructionOffset,
IsResolved: false,
Confidence: 0.3));
var unknownId = JavaGraphIdentifiers.ComputeUnknownId(
edgeId,
JavaUnknownType.DynamicTarget,
null,
null);
_unknowns.Add(new JavaUnknown(
UnknownId: unknownId,
UnknownType: JavaUnknownType.DynamicTarget,
SourceId: edgeId,
ClassName: null,
MethodName: null,
Reason: "invokedynamic target requires bootstrap method resolution",
JarPath: jarPath));
break;
}
default:
// Skip other instructions - advance based on opcode
offset += GetInstructionSize(opcode) - 1;
break;
}
}
}
private string? GetPurlForClass(string className)
{
var internalName = className.Replace('.', '/');
if (_classToJarPath.TryGetValue(internalName, out var jarPath))
{
// In production, would map JAR to Maven coordinates
return $"pkg:maven/{Path.GetFileNameWithoutExtension(jarPath)}";
}
return null;
}
private static int GetInstructionSize(byte opcode)
{
// Simplified instruction size lookup - production would have full table
return opcode switch
{
// Zero operand instructions
>= 0x00 and <= 0x0F => 1, // nop, aconst_null, iconst_*, lconst_*, fconst_*, dconst_*
>= 0x1A and <= 0x35 => 1, // iload_*, lload_*, fload_*, dload_*, aload_*, *aload
>= 0x3B and <= 0x56 => 1, // istore_*, lstore_*, fstore_*, dstore_*, astore_*, *astore
>= 0x57 and <= 0x83 => 1, // pop, dup, swap, arithmetic, conversions
>= 0x94 and <= 0x98 => 1, // lcmp, fcmp*, dcmp*
>= 0xAC and <= 0xB1 => 1, // *return, return
0xBE => 1, // arraylength
0xBF => 1, // athrow
0xC2 => 1, // monitorenter
0xC3 => 1, // monitorexit
// Single byte operand
0x10 => 2, // bipush
>= 0x15 and <= 0x19 => 2, // iload, lload, fload, dload, aload
>= 0x36 and <= 0x3A => 2, // istore, lstore, fstore, dstore, astore
0xA9 => 2, // ret
0xBC => 2, // newarray
// Two byte operand
0x11 => 3, // sipush
0x12 => 2, // ldc
0x13 => 3, // ldc_w
0x14 => 3, // ldc2_w
0x84 => 3, // iinc
>= 0x99 and <= 0xA8 => 3, // if*, goto, jsr
>= 0xB2 and <= 0xB5 => 3, // get/put static/field
>= 0xB6 and <= 0xB8 => 3, // invoke virtual/special/static
0xB9 => 5, // invokeinterface
0xBA => 5, // invokedynamic
0xBB => 3, // new
0xBD => 3, // anewarray
0xC0 => 3, // checkcast
0xC1 => 3, // instanceof
0xC5 => 4, // multianewarray
0xC6 => 3, // ifnull
0xC7 => 3, // ifnonnull
0xC8 => 5, // goto_w
0xC9 => 5, // jsr_w
// Variable length (tableswitch, lookupswitch) - simplified
0xAA => 16, // tableswitch (minimum)
0xAB => 8, // lookupswitch (minimum)
// wide prefix
0xC4 => 4, // wide (varies, using minimum)
_ => 1, // default
};
}
}
/// <summary>
/// Minimal Java class file parser for callgraph extraction.
/// </summary>
internal static class JavaClassFileParser
{
public static ClassFile Parse(Stream stream, CancellationToken cancellationToken)
{
using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: true);
var magic = ReadUInt32BE(reader);
if (magic != 0xCAFEBABE)
{
throw new InvalidDataException("Invalid Java class file magic.");
}
_ = ReadUInt16BE(reader); // minor version
_ = ReadUInt16BE(reader); // major version
var constantPoolCount = ReadUInt16BE(reader);
var pool = new ConstantPool(constantPoolCount);
for (var i = 1; i < constantPoolCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var tag = reader.ReadByte();
var entry = ReadConstantPoolEntry(reader, tag);
pool.Set(i, entry);
// Long and Double take two slots
if (tag == 5 || tag == 6)
{
i++;
}
}
_ = ReadUInt16BE(reader); // access flags
var thisClassIndex = ReadUInt16BE(reader);
var superClassIndex = ReadUInt16BE(reader);
var interfaceCount = ReadUInt16BE(reader);
var interfaces = new string[interfaceCount];
for (var i = 0; i < interfaceCount; i++)
{
var idx = ReadUInt16BE(reader);
interfaces[i] = pool.GetClassName(idx) ?? "";
}
var fieldCount = ReadUInt16BE(reader);
for (var i = 0; i < fieldCount; i++)
{
SkipMember(reader);
}
var methodCount = ReadUInt16BE(reader);
var methods = new List<MethodInfo>(methodCount);
for (var i = 0; i < methodCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var method = ReadMethod(reader, pool);
methods.Add(method);
}
// Skip class attributes
var attrCount = ReadUInt16BE(reader);
for (var i = 0; i < attrCount; i++)
{
SkipAttribute(reader);
}
var thisClassName = pool.GetClassName(thisClassIndex);
var superClassName = superClassIndex > 0 ? pool.GetClassName(superClassIndex) : null;
return new ClassFile(thisClassName ?? "", superClassName, interfaces.ToImmutableArray(), methods.ToImmutableArray(), pool);
}
private static MethodInfo ReadMethod(BinaryReader reader, ConstantPool pool)
{
var accessFlags = ReadUInt16BE(reader);
var nameIndex = ReadUInt16BE(reader);
var descriptorIndex = ReadUInt16BE(reader);
var name = pool.GetUtf8(nameIndex) ?? "";
var descriptor = pool.GetUtf8(descriptorIndex) ?? "";
byte[]? code = null;
var hasTestAnnotation = false;
var attrCount = ReadUInt16BE(reader);
for (var i = 0; i < attrCount; i++)
{
var attrNameIndex = ReadUInt16BE(reader);
var attrLength = ReadUInt32BE(reader);
var attrName = pool.GetUtf8(attrNameIndex) ?? "";
if (attrName == "Code")
{
_ = ReadUInt16BE(reader); // max_stack
_ = ReadUInt16BE(reader); // max_locals
var codeLength = ReadUInt32BE(reader);
code = reader.ReadBytes((int)codeLength);
var exceptionTableLength = ReadUInt16BE(reader);
for (var e = 0; e < exceptionTableLength; e++)
{
reader.ReadBytes(8);
}
var codeAttrCount = ReadUInt16BE(reader);
for (var c = 0; c < codeAttrCount; c++)
{
SkipAttribute(reader);
}
}
else if (attrName == "RuntimeVisibleAnnotations" || attrName == "RuntimeInvisibleAnnotations")
{
var startPos = reader.BaseStream.Position;
var numAnnotations = ReadUInt16BE(reader);
for (var a = 0; a < numAnnotations; a++)
{
var typeIndex = ReadUInt16BE(reader);
var annotationType = pool.GetUtf8(typeIndex) ?? "";
if (annotationType.Contains("Test") || annotationType.Contains("org/junit"))
{
hasTestAnnotation = true;
}
var numPairs = ReadUInt16BE(reader);
for (var p = 0; p < numPairs; p++)
{
_ = ReadUInt16BE(reader); // element_name_index
SkipAnnotationValue(reader);
}
}
// Seek to end of attribute if we didn't read it all
reader.BaseStream.Position = startPos + attrLength - 2;
}
else
{
reader.ReadBytes((int)attrLength);
}
}
return new MethodInfo(name, descriptor, accessFlags, code, hasTestAnnotation);
}
private static void SkipMember(BinaryReader reader)
{
reader.ReadBytes(6); // access_flags, name_index, descriptor_index
var attrCount = ReadUInt16BE(reader);
for (var i = 0; i < attrCount; i++)
{
SkipAttribute(reader);
}
}
private static void SkipAttribute(BinaryReader reader)
{
_ = ReadUInt16BE(reader); // name_index
var length = ReadUInt32BE(reader);
reader.ReadBytes((int)length);
}
private static void SkipAnnotationValue(BinaryReader reader)
{
var tag = (char)reader.ReadByte();
switch (tag)
{
case 'B':
case 'C':
case 'D':
case 'F':
case 'I':
case 'J':
case 'S':
case 'Z':
case 's':
case 'c':
ReadUInt16BE(reader);
break;
case 'e':
ReadUInt16BE(reader);
ReadUInt16BE(reader);
break;
case '@':
ReadUInt16BE(reader);
var numPairs = ReadUInt16BE(reader);
for (var i = 0; i < numPairs; i++)
{
ReadUInt16BE(reader);
SkipAnnotationValue(reader);
}
break;
case '[':
var numValues = ReadUInt16BE(reader);
for (var i = 0; i < numValues; i++)
{
SkipAnnotationValue(reader);
}
break;
}
}
private static ConstantPoolEntry ReadConstantPoolEntry(BinaryReader reader, byte tag)
{
return tag switch
{
1 => new ConstantPoolEntry.Utf8Entry(ReadUtf8(reader)),
3 => new ConstantPoolEntry.IntegerEntry(ReadUInt32BE(reader)),
4 => new ConstantPoolEntry.FloatEntry(reader.ReadBytes(4)),
5 => new ConstantPoolEntry.LongEntry(reader.ReadBytes(8)),
6 => new ConstantPoolEntry.DoubleEntry(reader.ReadBytes(8)),
7 => new ConstantPoolEntry.ClassEntry(ReadUInt16BE(reader)),
8 => new ConstantPoolEntry.StringEntry(ReadUInt16BE(reader)),
9 => new ConstantPoolEntry.FieldrefEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
10 => new ConstantPoolEntry.MethodrefEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
11 => new ConstantPoolEntry.InterfaceMethodrefEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
12 => new ConstantPoolEntry.NameAndTypeEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
15 => new ConstantPoolEntry.MethodHandleEntry(reader.ReadByte(), ReadUInt16BE(reader)),
16 => new ConstantPoolEntry.MethodTypeEntry(ReadUInt16BE(reader)),
17 => new ConstantPoolEntry.DynamicEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
18 => new ConstantPoolEntry.InvokeDynamicEntry(ReadUInt16BE(reader), ReadUInt16BE(reader)),
19 => new ConstantPoolEntry.ModuleEntry(ReadUInt16BE(reader)),
20 => new ConstantPoolEntry.PackageEntry(ReadUInt16BE(reader)),
_ => throw new InvalidDataException($"Unknown constant pool tag: {tag}"),
};
}
private static ushort ReadUInt16BE(BinaryReader reader)
{
var b1 = reader.ReadByte();
var b2 = reader.ReadByte();
return (ushort)((b1 << 8) | b2);
}
private static uint ReadUInt32BE(BinaryReader reader)
{
var b1 = reader.ReadByte();
var b2 = reader.ReadByte();
var b3 = reader.ReadByte();
var b4 = reader.ReadByte();
return (uint)((b1 << 24) | (b2 << 16) | (b3 << 8) | b4);
}
private static string ReadUtf8(BinaryReader reader)
{
var length = ReadUInt16BE(reader);
var bytes = reader.ReadBytes(length);
return System.Text.Encoding.UTF8.GetString(bytes);
}
public sealed record ClassFile(
string ThisClassName,
string? SuperClassName,
ImmutableArray<string> Interfaces,
ImmutableArray<MethodInfo> Methods,
ConstantPool ConstantPool);
public sealed record MethodInfo(
string Name,
string Descriptor,
int AccessFlags,
byte[]? Code,
bool HasTestAnnotation);
public sealed class ConstantPool
{
private readonly ConstantPoolEntry?[] _entries;
public ConstantPool(int count)
{
_entries = new ConstantPoolEntry?[count];
}
public void Set(int index, ConstantPoolEntry entry)
{
_entries[index] = entry;
}
public string? GetUtf8(int index)
{
if (index <= 0 || index >= _entries.Length)
{
return null;
}
return _entries[index] is ConstantPoolEntry.Utf8Entry utf8 ? utf8.Value : null;
}
public string? GetClassName(int index)
{
if (_entries[index] is ConstantPoolEntry.ClassEntry classEntry)
{
return GetUtf8(classEntry.NameIndex);
}
return null;
}
public MethodReference? GetMethodReference(int index)
{
if (_entries[index] is not ConstantPoolEntry.MethodrefEntry and not ConstantPoolEntry.InterfaceMethodrefEntry)
{
return null;
}
var (classIndex, nameAndTypeIndex) = _entries[index] switch
{
ConstantPoolEntry.MethodrefEntry m => (m.ClassIndex, m.NameAndTypeIndex),
ConstantPoolEntry.InterfaceMethodrefEntry m => (m.ClassIndex, m.NameAndTypeIndex),
_ => (0, 0),
};
var owner = GetClassName(classIndex);
if (owner is null || _entries[nameAndTypeIndex] is not ConstantPoolEntry.NameAndTypeEntry nat)
{
return null;
}
var name = GetUtf8(nat.NameIndex) ?? "";
var descriptor = GetUtf8(nat.DescriptorIndex) ?? "";
return new MethodReference(owner, name, descriptor);
}
}
public readonly record struct MethodReference(string OwnerInternalName, string Name, string Descriptor);
public abstract record ConstantPoolEntry
{
public sealed record Utf8Entry(string Value) : ConstantPoolEntry;
public sealed record IntegerEntry(uint Value) : ConstantPoolEntry;
public sealed record FloatEntry(byte[] Bytes) : ConstantPoolEntry;
public sealed record LongEntry(byte[] Bytes) : ConstantPoolEntry;
public sealed record DoubleEntry(byte[] Bytes) : ConstantPoolEntry;
public sealed record ClassEntry(int NameIndex) : ConstantPoolEntry;
public sealed record StringEntry(int StringIndex) : ConstantPoolEntry;
public sealed record FieldrefEntry(int ClassIndex, int NameAndTypeIndex) : ConstantPoolEntry;
public sealed record MethodrefEntry(int ClassIndex, int NameAndTypeIndex) : ConstantPoolEntry;
public sealed record InterfaceMethodrefEntry(int ClassIndex, int NameAndTypeIndex) : ConstantPoolEntry;
public sealed record NameAndTypeEntry(int NameIndex, int DescriptorIndex) : ConstantPoolEntry;
public sealed record MethodHandleEntry(byte ReferenceKind, int ReferenceIndex) : ConstantPoolEntry;
public sealed record MethodTypeEntry(int DescriptorIndex) : ConstantPoolEntry;
public sealed record DynamicEntry(int BootstrapMethodAttrIndex, int NameAndTypeIndex) : ConstantPoolEntry;
public sealed record InvokeDynamicEntry(int BootstrapMethodAttrIndex, int NameAndTypeIndex) : ConstantPoolEntry;
public sealed record ModuleEntry(int NameIndex) : ConstantPoolEntry;
public sealed record PackageEntry(int NameIndex) : ConstantPoolEntry;
}
}

View File

@@ -0,0 +1,329 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Callgraph;
/// <summary>
/// Java reachability graph containing methods, call edges, and metadata.
/// </summary>
public sealed record JavaReachabilityGraph(
string ContextDigest,
ImmutableArray<JavaMethodNode> Methods,
ImmutableArray<JavaCallEdge> Edges,
ImmutableArray<JavaSyntheticRoot> SyntheticRoots,
ImmutableArray<JavaUnknown> Unknowns,
JavaGraphMetadata Metadata,
string ContentHash);
/// <summary>
/// A method node in the Java call graph.
/// </summary>
/// <param name="MethodId">Deterministic method identifier (sha256 of class+name+descriptor).</param>
/// <param name="ClassName">Fully qualified class name (e.g., java.lang.String).</param>
/// <param name="MethodName">Method name.</param>
/// <param name="Descriptor">JVM method descriptor (e.g., (Ljava/lang/String;)V).</param>
/// <param name="Purl">Package URL if resolvable (e.g., pkg:maven/org.example/lib).</param>
/// <param name="JarPath">Path to the containing JAR file.</param>
/// <param name="AccessFlags">Method access flags (public, static, etc.).</param>
/// <param name="MethodDigest">SHA-256 of (class + name + descriptor + accessFlags).</param>
/// <param name="IsStatic">Whether the method is static.</param>
/// <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>
public sealed record JavaMethodNode(
string MethodId,
string ClassName,
string MethodName,
string Descriptor,
string? Purl,
string JarPath,
int AccessFlags,
string MethodDigest,
bool IsStatic,
bool IsPublic,
bool IsSynthetic,
bool IsBridge);
/// <summary>
/// A call edge in the Java call graph.
/// </summary>
/// <param name="EdgeId">Deterministic edge identifier.</param>
/// <param name="CallerId">MethodId of the calling method.</param>
/// <param name="CalleeId">MethodId of the called method (or Unknown placeholder).</param>
/// <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="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>
public sealed record JavaCallEdge(
string EdgeId,
string CallerId,
string CalleeId,
string? CalleePurl,
string? CalleeMethodDigest,
JavaEdgeType EdgeType,
int BytecodeOffset,
bool IsResolved,
double Confidence);
/// <summary>
/// Type of Java call edge.
/// </summary>
public enum JavaEdgeType
{
/// <summary>invokestatic - static method call.</summary>
InvokeStatic,
/// <summary>invokevirtual - virtual method call.</summary>
InvokeVirtual,
/// <summary>invokeinterface - interface method call.</summary>
InvokeInterface,
/// <summary>invokespecial - constructor, super, private method call.</summary>
InvokeSpecial,
/// <summary>invokedynamic - lambda/method reference.</summary>
InvokeDynamic,
/// <summary>Class.forName reflection call.</summary>
Reflection,
/// <summary>ServiceLoader.load call.</summary>
ServiceLoader,
/// <summary>Constructor invocation.</summary>
Constructor,
}
/// <summary>
/// A synthetic root in the Java call graph.
/// </summary>
/// <param name="RootId">Deterministic root identifier.</param>
/// <param name="TargetId">MethodId of the target method.</param>
/// <param name="RootType">Type of synthetic root.</param>
/// <param name="Source">Source of the root (e.g., main, static_init, servlet).</param>
/// <param name="JarPath">Path to the containing JAR.</param>
/// <param name="Phase">Execution phase.</param>
/// <param name="Order">Order within the phase.</param>
/// <param name="IsResolved">Whether the target was successfully resolved.</param>
public sealed record JavaSyntheticRoot(
string RootId,
string TargetId,
JavaRootType RootType,
string Source,
string JarPath,
JavaRootPhase Phase,
int Order,
bool IsResolved = true);
/// <summary>
/// Execution phase for Java synthetic roots.
/// </summary>
public enum JavaRootPhase
{
/// <summary>Class loading phase - static initializers.</summary>
ClassLoad = 0,
/// <summary>Application initialization - servlet init, Spring context.</summary>
AppInit = 1,
/// <summary>Main execution - main method, request handlers.</summary>
Main = 2,
/// <summary>Shutdown - destroy methods, shutdown hooks.</summary>
Shutdown = 3,
}
/// <summary>
/// Type of Java synthetic root.
/// </summary>
public enum JavaRootType
{
/// <summary>main(String[] args) method.</summary>
Main,
/// <summary>Static initializer block (&lt;clinit&gt;).</summary>
StaticInitializer,
/// <summary>Instance initializer (&lt;init&gt;).</summary>
Constructor,
/// <summary>Servlet init method.</summary>
ServletInit,
/// <summary>Servlet service/doGet/doPost methods.</summary>
ServletHandler,
/// <summary>Spring @PostConstruct.</summary>
PostConstruct,
/// <summary>Spring @PreDestroy.</summary>
PreDestroy,
/// <summary>JUnit @Test method.</summary>
TestMethod,
/// <summary>Runtime shutdown hook.</summary>
ShutdownHook,
/// <summary>Thread run method.</summary>
ThreadRun,
}
/// <summary>
/// An unknown/unresolved reference in the Java call graph.
/// </summary>
public sealed record JavaUnknown(
string UnknownId,
JavaUnknownType UnknownType,
string SourceId,
string? ClassName,
string? MethodName,
string Reason,
string JarPath);
/// <summary>
/// Type of unknown reference in Java.
/// </summary>
public enum JavaUnknownType
{
/// <summary>Class could not be resolved.</summary>
UnresolvedClass,
/// <summary>Method could not be resolved.</summary>
UnresolvedMethod,
/// <summary>Dynamic invoke target is unknown.</summary>
DynamicTarget,
/// <summary>Reflection target is unknown.</summary>
ReflectionTarget,
/// <summary>Service provider class is unknown.</summary>
ServiceProvider,
}
/// <summary>
/// Metadata for the Java reachability graph.
/// </summary>
public sealed record JavaGraphMetadata(
DateTimeOffset GeneratedAt,
string GeneratorVersion,
string ContextDigest,
int JarCount,
int ClassCount,
int MethodCount,
int EdgeCount,
int UnknownCount,
int SyntheticRootCount);
/// <summary>
/// Helper methods for creating deterministic Java graph identifiers.
/// </summary>
internal static class JavaGraphIdentifiers
{
private const string GeneratorVersion = "1.0.0";
/// <summary>
/// Computes a deterministic method ID from class, name, and descriptor.
/// </summary>
public static string ComputeMethodId(string className, string methodName, string descriptor)
{
var input = $"{className}:{methodName}:{descriptor}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"jmethod:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
/// <summary>
/// Computes a deterministic method digest.
/// </summary>
public static string ComputeMethodDigest(string className, string methodName, string descriptor, int accessFlags)
{
var input = $"{className}:{methodName}:{descriptor}:{accessFlags}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Computes a deterministic edge ID.
/// </summary>
public static string ComputeEdgeId(string callerId, string calleeId, int bytecodeOffset)
{
var input = $"{callerId}:{calleeId}:{bytecodeOffset}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"jedge:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
/// <summary>
/// Computes a deterministic root ID.
/// </summary>
public static string ComputeRootId(JavaRootPhase phase, int order, string targetId)
{
var phaseName = phase.ToString().ToLowerInvariant();
return $"jroot:{phaseName}:{order}:{targetId}";
}
/// <summary>
/// Computes a deterministic unknown ID.
/// </summary>
public static string ComputeUnknownId(string sourceId, JavaUnknownType unknownType, string? className, string? methodName)
{
var input = $"{sourceId}:{unknownType}:{className ?? ""}:{methodName ?? ""}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"junk:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
/// <summary>
/// Computes content hash for the entire graph.
/// </summary>
public static string ComputeGraphHash(
ImmutableArray<JavaMethodNode> methods,
ImmutableArray<JavaCallEdge> edges,
ImmutableArray<JavaSyntheticRoot> roots)
{
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var m in methods.OrderBy(m => m.MethodId))
{
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodId));
sha.AppendData(Encoding.UTF8.GetBytes(m.MethodDigest));
}
foreach (var e in edges.OrderBy(e => e.EdgeId))
{
sha.AppendData(Encoding.UTF8.GetBytes(e.EdgeId));
}
foreach (var r in roots.OrderBy(r => r.RootId))
{
sha.AppendData(Encoding.UTF8.GetBytes(r.RootId));
}
return Convert.ToHexString(sha.GetCurrentHash()).ToLowerInvariant();
}
/// <summary>
/// Gets the current generator version.
/// </summary>
public static string GetGeneratorVersion() => GeneratorVersion;
/// <summary>
/// Normalizes a JVM internal class name to fully qualified format.
/// </summary>
public static string NormalizeClassName(string internalName)
{
return internalName.Replace('/', '.');
}
/// <summary>
/// Parses a method descriptor to extract readable signature.
/// </summary>
public static string ParseDescriptor(string descriptor)
{
// Simplified parsing - full implementation would properly decode JVM descriptors
return descriptor;
}
}

View File

@@ -9,10 +9,48 @@ using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
/// <summary>
/// Collects and parses Java lock files (Gradle lockfiles, Maven POMs) to produce dependency entries.
/// </summary>
/// <remarks>
/// <para><strong>Lock Precedence Rules (Sprint 0403 / Interlock 2):</strong></para>
/// <list type="number">
/// <item>
/// <description>
/// <strong>Gradle lockfiles</strong> are highest priority (most reliable, resolved coordinates).
/// When multiple lockfiles exist across a multi-module project, they are processed in
/// lexicographic order by relative path (ensures deterministic iteration).
/// </description>
/// </item>
/// <item>
/// <description>
/// <strong>De-duplication rule:</strong> For the same GAV (group:artifact:version),
/// the <em>first</em> lockfile encountered (by lexicographic path order) wins.
/// This means root-level lockfiles (e.g., <c>gradle.lockfile</c>) are processed before
/// submodule lockfiles (e.g., <c>app/gradle.lockfile</c>) and thus take precedence.
/// <para>Rationale: Root lockfiles typically represent the resolved dependency graph for
/// the entire project; module-level lockfiles may contain duplicates or overrides.</para>
/// </description>
/// </item>
/// <item>
/// <description>
/// <strong>Gradle build files</strong> (when no lockfiles exist) are parsed with version
/// catalog resolution. Same lexicographic + first-wins rule applies.
/// </description>
/// </item>
/// <item>
/// <description>
/// <strong>Maven POMs</strong> are lowest priority and are additive (TryAdd semantics).
/// </description>
/// </item>
/// </list>
/// <para>
/// Each entry carries <c>lockLocator</c> (relative path to the source file) and
/// <c>lockModulePath</c> (module directory context, e.g., <c>.</c> for root, <c>app</c> for submodule).
/// </para>
/// </remarks>
internal static class JavaLockFileCollector
{
private static readonly string[] GradleLockPatterns = ["gradle.lockfile"];
public static async Task<JavaLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
@@ -20,25 +58,17 @@ internal static class JavaLockFileCollector
var entries = new Dictionary<string, JavaLockEntry>(StringComparer.OrdinalIgnoreCase);
var root = context.RootPath;
// Discover all build files
// Discover all build files (returns paths sorted by RelativePath for determinism)
var buildFiles = JavaBuildFileDiscovery.Discover(root);
// Priority 1: Gradle lockfiles (most reliable)
foreach (var pattern in GradleLockPatterns)
// Priority 1: Gradle lockfiles from discovery (most reliable)
// Processed in lexicographic order by relative path; first-wins for duplicate GAVs.
if (buildFiles.HasGradleLockFiles)
{
var lockPath = Path.Combine(root, pattern);
if (File.Exists(lockPath))
foreach (var lockFile in buildFiles.GradleLockFiles)
{
await ParseGradleLockFileAsync(context, lockPath, entries, cancellationToken).ConfigureAwait(false);
}
}
var dependencyLocksDir = Path.Combine(root, "gradle", "dependency-locks");
if (Directory.Exists(dependencyLocksDir))
{
foreach (var file in Directory.EnumerateFiles(dependencyLocksDir, "*.lockfile", SearchOption.AllDirectories))
{
await ParseGradleLockFileAsync(context, file, entries, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
await ParseGradleLockFileAsync(context, lockFile.AbsolutePath, lockFile.ProjectDirectory, entries, cancellationToken).ConfigureAwait(false);
}
}
@@ -69,12 +99,16 @@ internal static class JavaLockFileCollector
private static async Task ParseGradleLockFileAsync(
LanguageAnalyzerContext context,
string path,
string modulePath,
IDictionary<string, JavaLockEntry> entries,
CancellationToken cancellationToken)
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
var locator = NormalizeLocator(context, path);
var normalizedModulePath = NormalizeModulePath(modulePath);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
@@ -113,7 +147,8 @@ internal static class JavaLockFileCollector
artifactId.Trim(),
version.Trim(),
Path.GetFileName(path),
NormalizeLocator(context, path),
locator,
normalizedModulePath,
configuration,
null,
null,
@@ -124,10 +159,22 @@ internal static class JavaLockFileCollector
null,
null);
entries[entry.Key] = entry;
// First-wins for duplicate GAVs (entries are processed in lexicographic order)
entries.TryAdd(entry.Key, entry);
}
}
private static string NormalizeModulePath(string? modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath))
{
return ".";
}
var normalized = modulePath.Replace('\\', '/').Trim('/');
return string.IsNullOrEmpty(normalized) ? "." : normalized;
}
private static async Task ParseGradleBuildFilesAsync(
LanguageAnalyzerContext context,
JavaBuildFiles buildFiles,
@@ -190,6 +237,9 @@ internal static class JavaLockFileCollector
GradleVersionCatalog? versionCatalog,
IDictionary<string, JavaLockEntry> entries)
{
var locator = NormalizeLocator(context, buildFile.SourcePath);
var modulePath = NormalizeModulePath(Path.GetDirectoryName(context.GetRelativePath(buildFile.SourcePath)));
foreach (var dep in buildFile.Dependencies)
{
if (string.IsNullOrWhiteSpace(dep.GroupId) || string.IsNullOrWhiteSpace(dep.ArtifactId))
@@ -224,7 +274,8 @@ internal static class JavaLockFileCollector
dep.ArtifactId,
version,
Path.GetFileName(buildFile.SourcePath),
NormalizeLocator(context, buildFile.SourcePath),
locator,
modulePath,
scope,
null,
null,
@@ -257,6 +308,9 @@ internal static class JavaLockFileCollector
var effectivePomBuilder = new MavenEffectivePomBuilder(context.RootPath);
var effectivePom = await effectivePomBuilder.BuildAsync(pom, cancellationToken).ConfigureAwait(false);
var locator = NormalizeLocator(context, path);
var modulePath = NormalizeModulePath(Path.GetDirectoryName(context.GetRelativePath(path)));
foreach (var dep in effectivePom.ResolvedDependencies)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -281,7 +335,8 @@ internal static class JavaLockFileCollector
dep.ArtifactId,
dep.Version,
"pom.xml",
NormalizeLocator(context, path),
locator,
modulePath,
scope,
null,
null,
@@ -311,6 +366,9 @@ internal static class JavaLockFileCollector
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false);
var locator = NormalizeLocator(context, path);
var modulePath = NormalizeModulePath(Path.GetDirectoryName(context.GetRelativePath(path)));
var dependencies = document
.Descendants()
.Where(static element => element.Name.LocalName.Equals("dependency", StringComparison.OrdinalIgnoreCase));
@@ -343,7 +401,8 @@ internal static class JavaLockFileCollector
artifactId,
version,
"pom.xml",
NormalizeLocator(context, path),
locator,
modulePath,
scope,
repository,
null,
@@ -400,6 +459,7 @@ internal sealed record JavaLockEntry(
string Version,
string Source,
string Locator,
string? LockModulePath,
string? Configuration,
string? Repository,
string? ResolvedUrl,

View File

@@ -3,6 +3,7 @@ using System.IO;
using System.IO.Compression;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Xml;
using System.Xml.Linq;
@@ -61,6 +62,9 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
}
}
// Task 403-004: Emit runtime image components (explicit-key, no PURL to avoid false vuln matches)
EmitRuntimeImageComponents(workspace, Id, writer, context, cancellationToken);
// E5: Detect version conflicts
var conflictAnalysis = BuildConflictAnalysis(lockData);
@@ -849,6 +853,7 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
AddMetadata(metadata, "lockConfiguration", entry.Configuration);
AddMetadata(metadata, "lockRepository", entry.Repository);
AddMetadata(metadata, "lockResolved", entry.ResolvedUrl);
AddMetadata(metadata, "lockModulePath", entry.LockModulePath);
// E4: Add scope and risk level metadata
AddMetadata(metadata, "declaredScope", entry.Scope);
@@ -1678,6 +1683,121 @@ internal sealed record JniHintSummary(
EvidenceSha256: sha256);
}
/// <summary>
/// Emits runtime image components discovered by JavaWorkspaceNormalizer.
/// </summary>
/// <remarks>
/// <para><strong>Runtime Component Identity Decision (Sprint 0403 / Action 2):</strong></para>
/// <para>
/// Java runtime images are emitted using <em>explicit-key</em> (not PURL) to avoid false
/// vulnerability matches. There is no standardized PURL scheme for JDK/JRE installations
/// that reliably maps to CVE advisories. Using explicit-key ensures runtime context is
/// captured without introducing misleading vuln alerts.
/// </para>
/// <para>
/// The component key is formed from: analyzerId + "java-runtime" + version + vendor + relativePath.
/// </para>
/// <para>
/// Deduplication: identical runtime images (same version+vendor+relativePath) are emitted only once.
/// </para>
/// </remarks>
private static void EmitRuntimeImageComponents(
JavaWorkspace workspace,
string analyzerId,
LanguageComponentWriter writer,
LanguageAnalyzerContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(workspace);
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(context);
if (workspace.RuntimeImages.Length == 0)
{
return;
}
// Deduplicate by (version, vendor, relativePath) - deterministic ordering
var seenRuntimes = new HashSet<string>(StringComparer.Ordinal);
foreach (var runtime in workspace.RuntimeImages.OrderBy(r => r.RelativePath, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
// Create dedup key from identifying properties
var dedupKey = $"{runtime.JavaVersion}|{runtime.Vendor}|{runtime.RelativePath}";
if (!seenRuntimes.Add(dedupKey))
{
continue;
}
var normalizedPath = runtime.RelativePath.Replace('\\', '/');
var releaseLocator = string.IsNullOrEmpty(normalizedPath) || normalizedPath == "."
? "release"
: $"{normalizedPath}/release";
// Compute evidence SHA256 from release file
string? releaseSha256 = null;
var releaseFilePath = Path.Combine(runtime.AbsolutePath, "release");
if (File.Exists(releaseFilePath))
{
try
{
var releaseBytes = File.ReadAllBytes(releaseFilePath);
releaseSha256 = Convert.ToHexString(SHA256.HashData(releaseBytes)).ToLowerInvariant();
}
catch (IOException)
{
// Cannot read release file; proceed without SHA256
}
}
// Build component metadata
var metadata = new List<KeyValuePair<string, string?>>(8);
AddMetadata(metadata, "java.version", runtime.JavaVersion);
AddMetadata(metadata, "java.vendor", runtime.Vendor);
AddMetadata(metadata, "runtimeImagePath", normalizedPath, allowEmpty: true);
AddMetadata(metadata, "componentType", "java-runtime");
// Build evidence referencing the release file
var evidence = new[]
{
new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"release",
releaseLocator,
null,
releaseSha256),
};
// Build explicit component key (no PURL to avoid false vuln matches)
var componentKeyData = $"{runtime.JavaVersion}:{runtime.Vendor}:{normalizedPath}";
var componentKey = LanguageExplicitKey.Create(
analyzerId,
"java-runtime",
componentKeyData,
releaseSha256 ?? string.Empty,
releaseLocator);
// Emit component name: e.g., "java-runtime-21.0.1" or "java-runtime-21.0.1 (Eclipse Adoptium)"
var componentName = string.IsNullOrWhiteSpace(runtime.Vendor)
? $"java-runtime-{runtime.JavaVersion}"
: $"java-runtime-{runtime.JavaVersion} ({runtime.Vendor})";
writer.AddFromExplicitKey(
analyzerId: analyzerId,
componentKey: componentKey,
purl: null,
name: componentName,
version: runtime.JavaVersion,
type: "java-runtime",
metadata: SortMetadata(metadata),
evidence: evidence,
usedByEntrypoint: false);
}
}
private static IReadOnlyList<KeyValuePair<string, string?>> CreateDeclaredMetadata(
JavaLockEntry entry,
VersionConflictAnalysis conflictAnalysis)