From 04f8378af6a5e45bad41a4fa8c09b7a84ee9b3d3 Mon Sep 17 00:00:00 2001 From: Sergej Dechand Date: Fri, 12 Dec 2025 23:38:28 +0100 Subject: [PATCH 1/6] Add runtime capability monitoring agent Introduces jcapslock-agent module for runtime capability detection and policy enforcement: - CapslockAgent: Java agent entry point, instruments JDK classes at startup - CapabilityTransformer: ASM-based bytecode transformer for capability methods - PolicyChecker: Stack inspection and policy enforcement with YAML config support Features: - Log-only mode: monitors capability usage with full stack traces - Policy mode: blocks capabilities for specific packages via SecurityException - Uses capability mappings from core module (java-interesting.cm) Also: - Add dependency-reduced-pom.xml to .gitignore - Disable AssignmentToForLoopParameter inspection (false positive on i++) --- .gitignore | 1 + .idea/inspectionProfiles/Project_Default.xml | 1 + agent/README.md | 74 ++++++++++++ agent/pom.xml | 82 +++++++++++++ .../agent/CapabilityTransformer.java | 111 +++++++++++++++++ .../serj/jcapslock/agent/CapslockAgent.java | 112 ++++++++++++++++++ .../serj/jcapslock/agent/PolicyChecker.java | 71 +++++++++++ .../agent/PolicyEnforcementTest.java | 61 ++++++++++ 8 files changed, 513 insertions(+) create mode 100644 agent/README.md create mode 100644 agent/pom.xml create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java create mode 100644 agent/src/test/java/com/github/serj/jcapslock/agent/PolicyEnforcementTest.java diff --git a/.gitignore b/.gitignore index 0985d8c..5f27e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ current_output.txt *.tmp *.bak *.backup +dependency-reduced-pom.xml ### Maven invoker generated files ### examples/**/invoker.properties diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 63770be..81a108f 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,7 @@ diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..ba54602 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,74 @@ +# JCapsLock Agent + +A Java agent for runtime capability monitoring and policy enforcement. +Instruments JDK classes to detect and optionally block capability usage at runtime. + +## Usage + +### Log-only mode (monitor capabilities) + +```bash +java -javaagent:jcapslock-agent.jar -jar myapp.jar +``` + +Output shows which capabilities are used and the call stack: + +``` +[CAPSLOCK] CAPABILITY_FILES: + com.example.MyApp.readConfig() + com.example.Main.main() +``` + +### Policy enforcement mode (block capabilities) + +```bash +java -javaagent:jcapslock-agent.jar=policy.yaml -jar myapp.jar +``` + +When a blocked capability is used, throws `SecurityException`: + +``` +SecurityException: [CAPSLOCK] CAPABILITY_FILES blocked for com.example.untrusted.MaliciousLib +``` + +## Policy File Format + +```yaml +policies: + - package: com.example.untrusted + blocked: + - CAPABILITY_FILES + - CAPABILITY_NETWORK + - CAPABILITY_EXEC + - package: com.other.lib + blocked: + - CAPABILITY_EXEC +``` + +Packages are matched by prefix - blocking `com.example` also blocks `com.example.sub`. + +## How It Works + +1. **Instrumentation**: Uses ASM to instrument JDK methods that represent +capabilities (e.g., `FileInputStream`, `Socket`, `Runtime.exec`) + +2. **Stack inspection**: When an instrumented method is called, walks the call +stack to find application code (skipping JDK frames) + +3. **Policy check**: If a policy file is provided, checks if any caller package +is blocked for the capability + +4. **Logging**: Always logs capability usage with full stack trace to stderr + +## Building + +```bash +mvn clean package -pl agent -am +``` + +The shaded JAR is at `agent/target/jcapslock-agent-1.0-SNAPSHOT.jar`. + +## Capabilities + +Capabilities are loaded from `java-interesting.cm` in the core module. +See the core module for the full list of tracked JDK methods. \ No newline at end of file diff --git a/agent/pom.xml b/agent/pom.xml new file mode 100644 index 0000000..b7fd54d --- /dev/null +++ b/agent/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + com.github.serj + jcapslock-parent + 1.0-SNAPSHOT + + + jcapslock-agent + jar + + JCapsLock Agent + Java agent for runtime capability monitoring + + + + org.ow2.asm + asm + + + org.ow2.asm + asm-commons + ${asm.version} + + + com.github.serj + jcapslock-core + ${project.version} + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.github.serj.jcapslock.agent.CapslockAgent + true + true + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + org.objectweb.asm + com.github.serj.jcapslock.agent.asm + + + + + + + + + diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java new file mode 100644 index 0000000..58c0516 --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java @@ -0,0 +1,111 @@ +package com.github.serj.jcapslock.agent; + +import capslock.proto.CapabilityOuterClass.Capability; +import com.github.serj.jcapslock.capability.CapabilityMapper; +import org.objectweb.asm.*; +import org.objectweb.asm.commons.AdviceAdapter; + +import java.lang.instrument.ClassFileTransformer; +import java.security.ProtectionDomain; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * ASM-based class transformer that instruments JDK capability methods + * to log when they are called, including the full call stack. + * + * Capability mappings are loaded from java-interesting.cm via CapabilityMapper. + */ +public class CapabilityTransformer implements ClassFileTransformer { + + // Map of internal class name -> (method name -> capability name) + private static final Map> CAPABILITY_METHODS = buildCapabilityMethods(); + + private static Map> buildCapabilityMethods() { + Map> result = new HashMap<>(); + + CapabilityMapper.getAllMappings().forEach((fullMethod, capabilities) -> { + int lastDot = fullMethod.lastIndexOf('.'); + if (lastDot <= 0) return; + + String internalClassName = fullMethod.substring(0, lastDot).replace('.', '/'); + String methodName = fullMethod.substring(lastDot + 1); + String capabilityName = capabilities.iterator().next().name(); + + result.computeIfAbsent(internalClassName, k -> new HashMap<>()) + .put(methodName, capabilityName); + }); + + return result; + } + + /** + * Get all internal class names that have capability mappings. + */ + public static Set getInstrumentedClassNames() { + return Collections.unmodifiableSet(CAPABILITY_METHODS.keySet()); + } + + @Override + public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined, + ProtectionDomain protectionDomain, byte[] classfileBuffer) { + if (className == null || !CAPABILITY_METHODS.containsKey(className)) { + return null; + } + + try { + ClassReader cr = new ClassReader(classfileBuffer); + ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES); + ClassVisitor cv = new CapabilityClassVisitor(cw, className); + cr.accept(cv, ClassReader.EXPAND_FRAMES); + return cw.toByteArray(); + } catch (Exception e) { + System.err.println(PolicyChecker.LOG_PREFIX + "Failed to transform " + className + ": " + e.getMessage()); + return null; + } + } + + private static class CapabilityClassVisitor extends ClassVisitor { + private final String className; + + CapabilityClassVisitor(ClassVisitor cv, String className) { + super(Opcodes.ASM9, cv); + this.className = className; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); + Map methods = CAPABILITY_METHODS.get(className); + if (methods != null && methods.containsKey(name)) { + String capability = methods.get(name); + return new CapabilityMethodVisitor(mv, access, name, descriptor, capability); + } + return mv; + } + } + + private static class CapabilityMethodVisitor extends AdviceAdapter { + private final String capability; + + CapabilityMethodVisitor(MethodVisitor mv, int access, String name, String descriptor, + String capability) { + super(Opcodes.ASM9, mv, access, name, descriptor); + this.capability = capability; + } + + @Override + protected void onMethodEnter() { + // Call PolicyChecker.check(capability) + mv.visitLdcInsn(capability); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, + "com/github/serj/jcapslock/agent/PolicyChecker", + "check", + "(Ljava/lang/String;)V", + false); + } + } +} diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java new file mode 100644 index 0000000..d3d2d0e --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java @@ -0,0 +1,112 @@ +package com.github.serj.jcapslock.agent; + +import org.objectweb.asm.Type; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.lang.instrument.Instrumentation; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.jar.JarFile; + +/** + * Java agent entry point for runtime capability monitoring. + * + * Usage: + * java -javaagent:jcapslock-agent.jar -jar myapp.jar # log only + * java -javaagent:jcapslock-agent.jar=.capslock/policy.yaml -jar myapp.jar # enforce policy + */ +public class CapslockAgent { + + public static void premain(String args, Instrumentation inst) { + System.err.println(PolicyChecker.LOG_PREFIX + "Runtime capability monitoring enabled"); + + // Load policy if provided + if (args != null && !args.isEmpty()) { + loadPolicy(args); + } + + // Add agent JAR to bootstrap classpath so logger is visible from JDK classes + try { + String agentJarPath = CapslockAgent.class.getProtectionDomain() + .getCodeSource().getLocation().toURI().getPath(); + inst.appendToBootstrapClassLoaderSearch(new JarFile(new File(agentJarPath))); + } catch (Exception e) { + System.err.println(PolicyChecker.LOG_PREFIX + "Warning: Could not add to bootstrap classpath: " + e.getMessage()); + } + + inst.addTransformer(new CapabilityTransformer(), true); + + // Retransform already-loaded JDK classes from capability mappings + for (String internalClassName : CapabilityTransformer.getInstrumentedClassNames()) { + String javaClassName = Type.getObjectType(internalClassName).getClassName(); + try { + Class clazz = Class.forName(javaClassName); + if (inst.isModifiableClass(clazz)) { + inst.retransformClasses(clazz); + } + } catch (ClassNotFoundException e) { + // Class not yet loaded, transformer will catch it later + } catch (Exception e) { + System.err.println(PolicyChecker.LOG_PREFIX + "Warning: Could not retransform " + javaClassName + ": " + e.getMessage()); + } + } + } + + /** + * Load policy from YAML file and set system properties. + * Format: capslock.block.CAPABILITY_NAME=pkg1,pkg2,... + */ + private static void loadPolicy(String policyPath) { + File policyFile = new File(policyPath); + if (!policyFile.exists()) { + System.err.println(PolicyChecker.LOG_PREFIX + "Policy file not found: " + policyPath); + return; + } + + System.err.println(PolicyChecker.LOG_PREFIX + "Loading policy from: " + policyPath); + + // Simple YAML parsing (no external deps) + Map> capabilityToPackages = new HashMap<>(); + + try (BufferedReader reader = new BufferedReader(new FileReader(policyFile))) { + String line; + String currentPackage = null; + boolean inBlocked = false; + + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + + // Handle "- package:" or "package:" + if (trimmed.contains("package:")) { + int idx = trimmed.indexOf("package:"); + currentPackage = trimmed.substring(idx + 8).trim().replace("\"", "").replace("'", ""); + inBlocked = false; + } else if (trimmed.equals("blocked:")) { + inBlocked = true; + } else if (inBlocked && trimmed.startsWith("- ")) { + String capability = trimmed.substring(2).trim(); + if (currentPackage != null) { + capabilityToPackages + .computeIfAbsent(capability, k -> new HashSet<>()) + .add(currentPackage); + } + } + } + } catch (Exception e) { + System.err.println(PolicyChecker.LOG_PREFIX + "Failed to load policy: " + e.getMessage()); + return; + } + + // Set system properties for each capability + for (Map.Entry> entry : capabilityToPackages.entrySet()) { + String propName = "capslock.block." + entry.getKey(); + String propValue = String.join(",", entry.getValue()); + System.setProperty(propName, propValue); + System.err.println(PolicyChecker.LOG_PREFIX + "Policy: " + entry.getKey() + " blocked for: " + propValue); + } + } +} diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java b/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java new file mode 100644 index 0000000..eba7550 --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java @@ -0,0 +1,71 @@ +package com.github.serj.jcapslock.agent; + +import com.github.serj.jcapslock.util.JavaUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Policy checker called from instrumented JDK methods. + * Must be on bootstrap classpath. + */ +public class PolicyChecker { + + public static final String PROP_PREFIX = "capslock.block."; + public static final String LOG_PREFIX = "[CAPSLOCK] "; + + /** + * Check if capability is allowed for the current call stack. + * Throws SecurityException if blocked. + */ + public static void check(String capability) { + String blocked = System.getProperty(PROP_PREFIX + capability); + String[] blockedPackages = (blocked != null) ? blocked.split(",") : null; + + List appFrames = collectAppFrames(); + if (appFrames.isEmpty()) { + return; + } + + logFrames(capability, appFrames); + + if (blockedPackages != null) { + String violator = findViolator(appFrames, blockedPackages); + if (violator != null) { + throw new SecurityException(LOG_PREFIX + capability + " blocked for " + violator); + } + } + } + + private static List collectAppFrames() { + StackTraceElement[] stack = Thread.currentThread().getStackTrace(); + List appFrames = new ArrayList<>(); + for (int i = 2; i < stack.length; i++) { + if (!JavaUtils.isJdkPackage(stack[i].getClassName())) { + appFrames.add(stack[i]); + } + } + return appFrames; + } + + private static void logFrames(String capability, List frames) { + StringBuilder sb = new StringBuilder(); + sb.append(LOG_PREFIX).append(capability).append(":\n"); + for (StackTraceElement frame : frames) { + sb.append(" ").append(frame.getClassName()).append(".").append(frame.getMethodName()).append("()\n"); + } + System.err.print(sb); + } + + private static String findViolator(List frames, String[] blockedPackages) { + for (StackTraceElement frame : frames) { + String className = frame.getClassName(); + for (String pkg : blockedPackages) { + if (className.startsWith(pkg)) { + return className; + } + } + } + return null; + } +} \ No newline at end of file diff --git a/agent/src/test/java/com/github/serj/jcapslock/agent/PolicyEnforcementTest.java b/agent/src/test/java/com/github/serj/jcapslock/agent/PolicyEnforcementTest.java new file mode 100644 index 0000000..ac47ff4 --- /dev/null +++ b/agent/src/test/java/com/github/serj/jcapslock/agent/PolicyEnforcementTest.java @@ -0,0 +1,61 @@ +package com.github.serj.jcapslock.agent; + +import capslock.proto.CapabilityOuterClass.Capability; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for PolicyChecker enforcement. + */ +class PolicyEnforcementTest { + + private static final String CAPABILITY = Capability.CAPABILITY_EXEC.name(); + private static final String PROP_KEY = PolicyChecker.PROP_PREFIX + CAPABILITY; + private static final String OUR_PACKAGE = PolicyEnforcementTest.class.getPackageName(); + + @BeforeEach + void setUp() { + System.clearProperty(PROP_KEY); + } + + @AfterEach + void tearDown() { + System.clearProperty(PROP_KEY); + } + + @Test + void checkAllowsWhenNoPolicy() { + assertDoesNotThrow(() -> PolicyChecker.check(CAPABILITY)); + } + + @Test + void checkAllowsUnblockedPackage() { + System.setProperty(PROP_KEY, "com.other.blocked"); + assertDoesNotThrow(() -> PolicyChecker.check(CAPABILITY)); + } + + @Test + void checkBlocksMatchingPackage() { + System.setProperty(PROP_KEY, OUR_PACKAGE); + + SecurityException ex = assertThrows(SecurityException.class, + () -> PolicyChecker.check(CAPABILITY)); + + assertTrue(ex.getMessage().contains(CAPABILITY)); + assertTrue(ex.getMessage().contains("blocked")); + } + + @Test + void checkBlocksSubpackage() { + // Block parent - should block us too + System.setProperty(PROP_KEY, "com.github.serj"); + + SecurityException ex = assertThrows(SecurityException.class, + () -> PolicyChecker.check(CAPABILITY)); + + assertTrue(ex.getMessage().contains("blocked")); + } +} \ No newline at end of file From 9c725744db6e2e910d583ad09bc8bef7ef7428c3 Mon Sep 17 00:00:00 2001 From: Sergej Dechand Date: Sat, 13 Dec 2025 10:59:32 +0100 Subject: [PATCH 2/6] Centralize dependency versions and add policy enforcement tests Dependency management: - Add protobuf.version (4.33.2) and mockito.version properties - Add asm-commons to parent dependencyManagement - Remove duplicate maven.plugin.version from maven-plugin module - Remove unused jetbrains:annotations dependency Agent fixes: - Fix classloader null check in CapabilityMapper resource loading - Remove unused import in CapabilityTransformer - Add extraArtifacts config to maven-invoker for jcapslock-agent Policy enforcement: - Add policy.yaml for quick-test example - Add BlockedExec test class and PolicyEnforcementTest - Add test-policy.sh E2E test script --- README.md | 1 + agent/README.md | 4 +- agent/pom.xml | 1 - .../agent/CapabilityTransformer.java | 1 - core/pom.xml | 8 +- .../capability/CapabilityMapper.java | 10 +- .../serj/jcapslock/model/DependencyInfo.java | 4 +- examples/quick-test/.capslock/policy.yaml | 7 ++ examples/quick-test/.capslock/snapshot.json | 58 +++++++++++ examples/quick-test/pom.xml | 61 +++++++++++- .../java/com/test/blocked/BlockedExec.java | 23 +++++ .../java/com/test/PolicyEnforcementTest.java | 99 +++++++++++++++++++ examples/quick-test/test-policy.sh | 30 ++++++ maven-plugin/pom.xml | 7 +- pom.xml | 26 +++-- 15 files changed, 311 insertions(+), 29 deletions(-) create mode 100644 examples/quick-test/.capslock/policy.yaml create mode 100644 examples/quick-test/src/main/java/com/test/blocked/BlockedExec.java create mode 100644 examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java create mode 100755 examples/quick-test/test-policy.sh diff --git a/README.md b/README.md index 606fd8d..8cd9ac1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ mvn capslock:check - **Maven integration** - Runs as part of your build, not a separate CLI - **Dependency awareness** - Distinguishes between direct, transitive, and optional dependencies - **Capability locking** - Snapshot your baseline and get alerted when dependencies gain new capabilities +- **Runtime agent** (Experimental) - Monitor and block capabilities at runtime with policy enforcement. See [agent/README.md](agent/README.md) ## How It Differs from Go Capslock diff --git a/agent/README.md b/agent/README.md index ba54602..7838442 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1,6 +1,8 @@ # JCapsLock Agent -A Java agent for runtime capability monitoring and policy enforcement. +> **Experimental**: This feature is under active development. APIs and behavior may change. + +A Java agent for runtime capability monitoring and policy enforcement. Instruments JDK classes to detect and optionally block capability usage at runtime. ## Usage diff --git a/agent/pom.xml b/agent/pom.xml index b7fd54d..607cb51 100644 --- a/agent/pom.xml +++ b/agent/pom.xml @@ -24,7 +24,6 @@ org.ow2.asm asm-commons - ${asm.version} com.github.serj diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java index 58c0516..ca17c72 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java @@ -1,6 +1,5 @@ package com.github.serj.jcapslock.agent; -import capslock.proto.CapabilityOuterClass.Capability; import com.github.serj.jcapslock.capability.CapabilityMapper; import org.objectweb.asm.*; import org.objectweb.asm.commons.AdviceAdapter; diff --git a/core/pom.xml b/core/pom.xml index 2e9f6d5..d17a9f1 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -81,12 +81,6 @@ mockito-core test - - org.jetbrains - annotations - 25.0.0 - compile - @@ -110,7 +104,7 @@ run - 4.32.0 + ${protobuf.version} ${project.parent.basedir}/capslock-proto/proto diff --git a/core/src/main/java/com/github/serj/jcapslock/capability/CapabilityMapper.java b/core/src/main/java/com/github/serj/jcapslock/capability/CapabilityMapper.java index b640b33..293f51a 100644 --- a/core/src/main/java/com/github/serj/jcapslock/capability/CapabilityMapper.java +++ b/core/src/main/java/com/github/serj/jcapslock/capability/CapabilityMapper.java @@ -73,7 +73,10 @@ private static void loadSafeFunctions() { * Load safe function mappings from a classpath resource. */ public static void loadSafeFromResource(String resourcePath) throws IOException { - InputStream is = CapabilityMapper.class.getClassLoader().getResourceAsStream(resourcePath); + ClassLoader cl = CapabilityMapper.class.getClassLoader(); + InputStream is = (cl != null) + ? cl.getResourceAsStream(resourcePath) + : ClassLoader.getSystemResourceAsStream(resourcePath); if (is == null) { throw new IOException("Resource not found: " + resourcePath); } @@ -133,7 +136,10 @@ public static void loadFromFile(Path file) throws IOException { * Load capability mappings from a classpath resource. */ public static void loadFromResource(String resourcePath) throws IOException { - InputStream is = CapabilityMapper.class.getClassLoader().getResourceAsStream(resourcePath); + ClassLoader cl = CapabilityMapper.class.getClassLoader(); + InputStream is = (cl != null) + ? cl.getResourceAsStream(resourcePath) + : ClassLoader.getSystemResourceAsStream(resourcePath); if (is == null) { throw new IOException("Resource not found: " + resourcePath); } diff --git a/core/src/main/java/com/github/serj/jcapslock/model/DependencyInfo.java b/core/src/main/java/com/github/serj/jcapslock/model/DependencyInfo.java index 009abf5..cb0a7e3 100644 --- a/core/src/main/java/com/github/serj/jcapslock/model/DependencyInfo.java +++ b/core/src/main/java/com/github/serj/jcapslock/model/DependencyInfo.java @@ -1,7 +1,5 @@ package com.github.serj.jcapslock.model; -import org.jetbrains.annotations.NotNull; - import java.io.File; import java.util.Objects; @@ -41,7 +39,7 @@ public int hashCode() { } @Override - public @NotNull String toString() { + public String toString() { return getCoordinate(); } } \ No newline at end of file diff --git a/examples/quick-test/.capslock/policy.yaml b/examples/quick-test/.capslock/policy.yaml new file mode 100644 index 0000000..2d5f408 --- /dev/null +++ b/examples/quick-test/.capslock/policy.yaml @@ -0,0 +1,7 @@ +# Capability policy for quick-test +# Packages listed here are blocked from using specified capabilities + +policies: + - package: "com.test.blocked" + blocked: + - CAPABILITY_EXEC diff --git a/examples/quick-test/.capslock/snapshot.json b/examples/quick-test/.capslock/snapshot.json index 10c4ea2..247b2e9 100644 --- a/examples/quick-test/.capslock/snapshot.json +++ b/examples/quick-test/.capslock/snapshot.json @@ -47,6 +47,35 @@ "name": "java.lang.Runtime.getRuntime", "package": "java.lang" }] + }, { + "package_name": "com.test.blocked", + "capability": "CAPABILITY_READ_SYSTEM_STATE", + "dep_path": "com.test.blocked.BlockedExec.main com.test.blocked.BlockedExec.runCommand java.lang.Runtime.getRuntime", + "package_dir": "com.test.blocked", + "capability_type": "CAPABILITY_TYPE_DIRECT", + "path": [{ + "name": "com.test.blocked.BlockedExec.main", + "package": "com.test.blocked" + }, { + "name": "com.test.blocked.BlockedExec.runCommand", + "package": "com.test.blocked" + }, { + "name": "java.lang.Runtime.getRuntime", + "package": "java.lang" + }] + }, { + "package_name": "com.test.blocked", + "capability": "CAPABILITY_READ_SYSTEM_STATE", + "dep_path": "com.test.blocked.BlockedExec.runCommand java.lang.Runtime.getRuntime", + "package_dir": "com.test.blocked", + "capability_type": "CAPABILITY_TYPE_DIRECT", + "path": [{ + "name": "com.test.blocked.BlockedExec.runCommand", + "package": "com.test.blocked" + }, { + "name": "java.lang.Runtime.getRuntime", + "package": "java.lang" + }] }, { "package_name": "com.test", "capability": "CAPABILITY_REFLECT", @@ -105,6 +134,35 @@ "name": "java.lang.Runtime.exec", "package": "java.lang" }] + }, { + "package_name": "com.test.blocked", + "capability": "CAPABILITY_EXEC", + "dep_path": "com.test.blocked.BlockedExec.main com.test.blocked.BlockedExec.runCommand java.lang.Runtime.exec", + "package_dir": "com.test.blocked", + "capability_type": "CAPABILITY_TYPE_DIRECT", + "path": [{ + "name": "com.test.blocked.BlockedExec.main", + "package": "com.test.blocked" + }, { + "name": "com.test.blocked.BlockedExec.runCommand", + "package": "com.test.blocked" + }, { + "name": "java.lang.Runtime.exec", + "package": "java.lang" + }] + }, { + "package_name": "com.test.blocked", + "capability": "CAPABILITY_EXEC", + "dep_path": "com.test.blocked.BlockedExec.runCommand java.lang.Runtime.exec", + "package_dir": "com.test.blocked", + "capability_type": "CAPABILITY_TYPE_DIRECT", + "path": [{ + "name": "com.test.blocked.BlockedExec.runCommand", + "package": "com.test.blocked" + }, { + "name": "java.lang.Runtime.exec", + "package": "java.lang" + }] }], "module_info": [{ "path": "com.github.serj:quick-test", diff --git a/examples/quick-test/pom.xml b/examples/quick-test/pom.xml index 2a26f0b..86073f8 100644 --- a/examples/quick-test/pom.xml +++ b/examples/quick-test/pom.xml @@ -18,9 +18,50 @@ 17 UTF-8 - + + + + org.junit.jupiter + junit-jupiter + test + + + com.github.serj + jcapslock-agent + ${project.version} + test + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.9.0 + + + set-agent-path + initialize + + properties + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + ${com.github.serj:jcapslock-agent:jar} + ${project.basedir}/.capslock/policy.yaml + ${project.build.outputDirectory} + + + com.github.serj @@ -38,6 +79,24 @@ true + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + policy-e2e-test + integration-test + + exec + + + ./test-policy.sh + + + + \ No newline at end of file diff --git a/examples/quick-test/src/main/java/com/test/blocked/BlockedExec.java b/examples/quick-test/src/main/java/com/test/blocked/BlockedExec.java new file mode 100644 index 0000000..9c7001d --- /dev/null +++ b/examples/quick-test/src/main/java/com/test/blocked/BlockedExec.java @@ -0,0 +1,23 @@ +package com.test.blocked; + +import java.io.IOException; + +/** + * This subpackage is blocked from using CAPABILITY_EXEC per policy. + */ +public class BlockedExec { + + public void runCommand(String cmd) { + try { + Runtime.getRuntime().exec(cmd); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void main(String[] args) { + System.out.println("BlockedExec trying to run exec (should fail with policy)"); + new BlockedExec().runCommand("echo hello"); + System.out.println("If you see this, policy was not enforced!"); + } +} \ No newline at end of file diff --git a/examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java b/examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java new file mode 100644 index 0000000..69e334f --- /dev/null +++ b/examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java @@ -0,0 +1,99 @@ +package com.test; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for runtime policy enforcement. + * These tests spawn separate JVMs with the agent attached to verify behavior. + */ +class PolicyEnforcementTest { + + private static String agentJar; + private static String policyFile; + private static String testClasses; + private static String javaHome; + + @BeforeAll + static void setup() { + agentJar = System.getProperty("agent.jar"); + policyFile = System.getProperty("policy.file"); + testClasses = System.getProperty("test.classes"); + javaHome = System.getProperty("java.home"); + + assertNotNull(agentJar, "agent.jar system property must be set"); + assertNotNull(policyFile, "policy.file system property must be set"); + assertNotNull(testClasses, "test.classes system property must be set"); + } + + @Test + void blockedPackageThrowsSecurityException() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + javaHome + "/bin/java", + "-javaagent:" + agentJar + "=" + policyFile, + "-cp", testClasses, + "com.test.blocked.BlockedExec" + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output = new BufferedReader(new InputStreamReader(process.getInputStream())) + .lines().collect(Collectors.joining("\n")); + + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + assertTrue(finished, "Process should complete within timeout"); + + int exitCode = process.exitValue(); + assertNotEquals(0, exitCode, "BlockedExec should fail with non-zero exit code"); + assertTrue(output.contains("SecurityException"), + "Output should contain SecurityException. Got: " + output); + } + + @Test + void allowedPackageCanExecute() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + javaHome + "/bin/java", + "-javaagent:" + agentJar + "=" + policyFile, + "-cp", testClasses, + "com.test.SimpleApp" + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output = new BufferedReader(new InputStreamReader(process.getInputStream())) + .lines().collect(Collectors.joining("\n")); + + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + assertTrue(finished, "Process should complete within timeout"); + + int exitCode = process.exitValue(); + assertEquals(0, exitCode, "SimpleApp should succeed. Output: " + output); + } + + @Test + void capabilityLoggingOccurs() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + javaHome + "/bin/java", + "-javaagent:" + agentJar + "=" + policyFile, + "-cp", testClasses, + "com.test.SimpleApp" + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output = new BufferedReader(new InputStreamReader(process.getInputStream())) + .lines().collect(Collectors.joining("\n")); + + process.waitFor(30, TimeUnit.SECONDS); + + assertTrue(output.contains("[CAPSLOCK]"), + "Capability usage should be logged. Got: " + output); + } +} \ No newline at end of file diff --git a/examples/quick-test/test-policy.sh b/examples/quick-test/test-policy.sh new file mode 100755 index 0000000..ec9b5d1 --- /dev/null +++ b/examples/quick-test/test-policy.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +AGENT_JAR="../../agent/target/jcapslock-agent-1.0-SNAPSHOT.jar" +POLICY=".capslock/policy.yaml" +CLASSES="target/classes" + +# Test 1: BlockedExec should FAIL with SecurityException +echo "Test 1: BlockedExec should be blocked..." +OUTPUT=$(java -javaagent:$AGENT_JAR=$POLICY -cp $CLASSES com.test.blocked.BlockedExec 2>&1 || true) +if ! echo "$OUTPUT" | grep -q "SecurityException"; then + echo "FAIL: BlockedExec should have thrown SecurityException" + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi +echo "PASS: BlockedExec was blocked with SecurityException" + +# Test 2: SimpleApp should PASS and show [CAPSLOCK] logging +echo "Test 2: SimpleApp should be allowed and log capabilities..." +OUTPUT=$(java -javaagent:$AGENT_JAR=$POLICY -cp $CLASSES com.test.SimpleApp 2>&1) +if ! echo "$OUTPUT" | grep -q "\[CAPSLOCK\]"; then + echo "FAIL: Expected [CAPSLOCK] logging output for capability monitoring" + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi +echo "PASS: SimpleApp ran successfully with capability logging" + +echo "All e2e tests passed!" \ No newline at end of file diff --git a/maven-plugin/pom.xml b/maven-plugin/pom.xml index 5d437f6..c314124 100644 --- a/maven-plugin/pom.xml +++ b/maven-plugin/pom.xml @@ -16,10 +16,6 @@ Maven Capslock Plugin Maven plugin for capability analysis - - 3.9.10 - - @@ -106,6 +102,9 @@ */pom.xml ${project.build.directory}/local-repo + + com.github.serj:jcapslock-agent:${project.version} + clean compile diff --git a/pom.xml b/pom.xml index 798c68c..ff24f03 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ core maven-plugin + agent examples @@ -23,10 +24,12 @@ 17 UTF-8 false - 9.8 + 9.9 3.9.10 - 2.18.4 - 5.11.4 + 2.20.1 + 5.14.1 + 5.21.0 + 4.33.2 7d35a1df0ad7f2ae177e269a58eba5a51d6dfd6f @@ -55,6 +58,11 @@ asm-analysis ${asm.version} + + org.ow2.asm + asm-commons + ${asm.version} + @@ -68,12 +76,12 @@ com.google.protobuf protobuf-java - 4.32.0 + ${protobuf.version} com.google.protobuf protobuf-java-util - 4.32.0 + ${protobuf.version} @@ -125,7 +133,7 @@ org.mockito mockito-core - 5.15.2 + ${mockito.version} test @@ -147,7 +155,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + 3.5.4 @@ -161,7 +169,7 @@ run - 4.32.0 + ${protobuf.version} capslock-proto/proto @@ -173,7 +181,7 @@ org.codehaus.mojo build-helper-maven-plugin - 3.6.0 + 3.6.1 generate-sources From 0e00aeb7a5ebae649c15e526b7460beadb13969e Mon Sep 17 00:00:00 2001 From: Sergej Dechand Date: Sat, 13 Dec 2025 12:24:38 +0100 Subject: [PATCH 3/6] Add Log utility adapted from Jazzer --- .../agent/CapabilityTransformer.java | 2 +- .../serj/jcapslock/agent/CapslockAgent.java | 14 +-- .../com/github/serj/jcapslock/agent/Log.java | 112 ++++++++++++++++++ .../serj/jcapslock/agent/PolicyChecker.java | 9 +- 4 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/Log.java diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java index ca17c72..86ed3ec 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java @@ -61,7 +61,7 @@ public byte[] transform(ClassLoader loader, String className, Class classBein cr.accept(cv, ClassReader.EXPAND_FRAMES); return cw.toByteArray(); } catch (Exception e) { - System.err.println(PolicyChecker.LOG_PREFIX + "Failed to transform " + className + ": " + e.getMessage()); + Log.error("Failed to transform " + className + ": " + e.getMessage()); return null; } } diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java index d3d2d0e..edd0198 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java @@ -22,7 +22,7 @@ public class CapslockAgent { public static void premain(String args, Instrumentation inst) { - System.err.println(PolicyChecker.LOG_PREFIX + "Runtime capability monitoring enabled"); + Log.info("Runtime capability monitoring enabled"); // Load policy if provided if (args != null && !args.isEmpty()) { @@ -35,7 +35,7 @@ public static void premain(String args, Instrumentation inst) { .getCodeSource().getLocation().toURI().getPath(); inst.appendToBootstrapClassLoaderSearch(new JarFile(new File(agentJarPath))); } catch (Exception e) { - System.err.println(PolicyChecker.LOG_PREFIX + "Warning: Could not add to bootstrap classpath: " + e.getMessage()); + Log.warn("Could not add to bootstrap classpath: " + e.getMessage()); } inst.addTransformer(new CapabilityTransformer(), true); @@ -51,7 +51,7 @@ public static void premain(String args, Instrumentation inst) { } catch (ClassNotFoundException e) { // Class not yet loaded, transformer will catch it later } catch (Exception e) { - System.err.println(PolicyChecker.LOG_PREFIX + "Warning: Could not retransform " + javaClassName + ": " + e.getMessage()); + Log.warn("Could not retransform " + javaClassName + ": " + e.getMessage()); } } } @@ -63,11 +63,11 @@ public static void premain(String args, Instrumentation inst) { private static void loadPolicy(String policyPath) { File policyFile = new File(policyPath); if (!policyFile.exists()) { - System.err.println(PolicyChecker.LOG_PREFIX + "Policy file not found: " + policyPath); + Log.error("Policy file not found: " + policyPath); return; } - System.err.println(PolicyChecker.LOG_PREFIX + "Loading policy from: " + policyPath); + Log.info("Loading policy from: " + policyPath); // Simple YAML parsing (no external deps) Map> capabilityToPackages = new HashMap<>(); @@ -97,7 +97,7 @@ private static void loadPolicy(String policyPath) { } } } catch (Exception e) { - System.err.println(PolicyChecker.LOG_PREFIX + "Failed to load policy: " + e.getMessage()); + Log.error("Failed to load policy: " + e.getMessage()); return; } @@ -106,7 +106,7 @@ private static void loadPolicy(String policyPath) { String propName = "capslock.block." + entry.getKey(); String propValue = String.join(",", entry.getValue()); System.setProperty(propName, propValue); - System.err.println(PolicyChecker.LOG_PREFIX + "Policy: " + entry.getKey() + " blocked for: " + propValue); + Log.info("Policy: " + entry.getKey() + " blocked for: " + propValue); } } } diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/Log.java b/agent/src/main/java/com/github/serj/jcapslock/agent/Log.java new file mode 100644 index 0000000..3fa5015 --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/Log.java @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This file was adapted from Jazzer (https://github.com/CodeIntelligenceTesting/jazzer) + * to keep the agent lean by avoiding external logging dependencies. + */ + +package com.github.serj.jcapslock.agent; + +import java.io.PrintStream; + +/** + * Provides static functions that should be used for any kind of output emitted by the agent. + * + *

Output is printed to {@link System#err} and {@link System#out} until {@link + * Log#fixOutErr(PrintStream, PrintStream)} is called, which locks in the {@link PrintStream}s to be + * used from there on. + */ +public class Log { + + private static final String PREFIX = "[CAPSLOCK] "; + + // Don't use directly, always use getOut() and getErr() instead - when these fields haven't been + // set yet, we want to resolve them dynamically as System.out and System.err, which may change + // over the course of the agent's lifetime. + private static PrintStream fixedOut; + private static PrintStream fixedErr; + + // Whether to print debug messages. This is controlled by the CAPSLOCK_DEBUG environment variable. + private static final boolean isDebug = System.getenv("CAPSLOCK_DEBUG") != null; + + /** The {@link PrintStream}s to use for all output from this call on. */ + public static void fixOutErr(PrintStream out, PrintStream err) { + if (out == null) { + throw new IllegalArgumentException("out must not be null"); + } + if (err == null) { + throw new IllegalArgumentException("err must not be null"); + } + Log.fixedOut = out; + Log.fixedErr = err; + } + + public static void println(String message) { + getErr().println(message); + } + + public static void structuredOutput(String output) { + getOut().println(output); + } + + public static void debug(String message) { + if (isDebug) { + println(PREFIX, "DEBUG: " + message, null); + } + } + + public static void info(String message) { + println(PREFIX, message, null); + } + + public static void warn(String message) { + warn(message, null); + } + + public static void warn(String message, Throwable t) { + println(PREFIX, "WARN: " + message, t); + } + + public static void error(String message) { + error(message, null); + } + + public static void error(Throwable t) { + error(null, t); + } + + public static void error(String message, Throwable t) { + println(PREFIX, "ERROR: " + message, t); + } + + private static void println(String prefix, String message, Throwable t) { + PrintStream err = getErr(); + err.print(prefix); + if (message != null) { + err.println(message + (t != null ? ":" : "")); + } + if (t != null) { + t.printStackTrace(err); + } + } + + private static PrintStream getOut() { + return fixedOut != null ? fixedOut : System.out; + } + + private static PrintStream getErr() { + return fixedErr != null ? fixedErr : System.err; + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java b/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java index eba7550..a1827f9 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java @@ -12,7 +12,6 @@ public class PolicyChecker { public static final String PROP_PREFIX = "capslock.block."; - public static final String LOG_PREFIX = "[CAPSLOCK] "; /** * Check if capability is allowed for the current call stack. @@ -32,7 +31,7 @@ public static void check(String capability) { if (blockedPackages != null) { String violator = findViolator(appFrames, blockedPackages); if (violator != null) { - throw new SecurityException(LOG_PREFIX + capability + " blocked for " + violator); + throw new SecurityException("[CAPSLOCK] " + capability + " blocked for " + violator); } } } @@ -50,11 +49,11 @@ private static List collectAppFrames() { private static void logFrames(String capability, List frames) { StringBuilder sb = new StringBuilder(); - sb.append(LOG_PREFIX).append(capability).append(":\n"); + sb.append(capability).append(":"); for (StackTraceElement frame : frames) { - sb.append(" ").append(frame.getClassName()).append(".").append(frame.getMethodName()).append("()\n"); + sb.append("\n ").append(frame.getClassName()).append(".").append(frame.getMethodName()).append("()"); } - System.err.print(sb); + Log.info(sb.toString()); } private static String findViolator(List frames, String[] blockedPackages) { From 275f152503b18c3017d1f9726934bff57d6dbfd6 Mon Sep 17 00:00:00 2001 From: Sergej Dechand Date: Sat, 13 Dec 2025 19:39:57 +0100 Subject: [PATCH 4/6] Split core module and add runtime call graph capture Module restructuring: - Split core into core/ (lightweight) and core/analysis/ (SootUp) - Agent depends only on jcapslock-core (5.4MB vs ~20MB) - Maven plugin depends on jcapslock-core-analysis Runtime call graph capture: - Add RuntimeCallGraph for capturing capabilities during test execution - Add JUnit 5 extension (CapslockCallGraphExtension) for test integration - Add CallGraphAssertions for fluent graph assertions in tests - Add GraphMerger utility for combining Graph protos Refactoring: - Extract SootUp-specific methods from GraphProtoBuilder to SootGraphBuilder - Move CallGraphBuilder, ImplicitCapabilityDetector, UnifiedAnalyzer to analysis module - Use --release 17 instead of source/target for cleaner compilation - Fix shade plugin manifest handling --- agent/pom.xml | 30 ++++ .../serj/jcapslock/agent/CapslockAgent.java | 69 ++++---- .../serj/jcapslock/agent/PolicyChecker.java | 5 + .../jcapslock/agent/RuntimeCallGraph.java | 136 ++++++++++++++++ .../agent/junit/CallGraphAssertions.java | 114 ++++++++++++++ .../junit/CapslockCallGraphExtension.java | 65 ++++++++ .../agent/junit/CaptureCallGraph.java | 44 ++++++ core/analysis/pom.xml | 77 +++++++++ .../jcapslock/analyzer/CallGraphBuilder.java | 7 +- .../analyzer/ImplicitCapabilityDetector.java | 0 .../jcapslock/analyzer/SootGraphBuilder.java | 98 ++++++++++++ .../jcapslock/analyzer/UnifiedAnalyzer.java | 0 core/pom.xml | 23 --- .../serj/jcapslock/analyzer/GraphMerger.java | 147 ++++++++++++++++++ .../jcapslock/analyzer/GraphProtoBuilder.java | 85 ++++------ maven-plugin/pom.xml | 4 +- pom.xml | 14 +- 17 files changed, 797 insertions(+), 121 deletions(-) create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/RuntimeCallGraph.java create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/junit/CallGraphAssertions.java create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/junit/CapslockCallGraphExtension.java create mode 100644 agent/src/main/java/com/github/serj/jcapslock/agent/junit/CaptureCallGraph.java create mode 100644 core/analysis/pom.xml rename core/{ => analysis}/src/main/java/com/github/serj/jcapslock/analyzer/CallGraphBuilder.java (97%) rename core/{ => analysis}/src/main/java/com/github/serj/jcapslock/analyzer/ImplicitCapabilityDetector.java (100%) create mode 100644 core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/SootGraphBuilder.java rename core/{ => analysis}/src/main/java/com/github/serj/jcapslock/analyzer/UnifiedAnalyzer.java (100%) create mode 100644 core/src/main/java/com/github/serj/jcapslock/analyzer/GraphMerger.java diff --git a/agent/pom.xml b/agent/pom.xml index 607cb51..eac9e1a 100644 --- a/agent/pom.xml +++ b/agent/pom.xml @@ -30,6 +30,16 @@ jcapslock-core ${project.version} + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + org.junit.jupiter + junit-jupiter-api + provided + @@ -72,6 +82,26 @@ com.github.serj.jcapslock.agent.asm + + + *:* + + module-info.class + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + com.github.serj.jcapslock.agent.CapslockAgent + true + true + + + diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java index edd0198..611b1d9 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java @@ -1,13 +1,15 @@ package com.github.serj.jcapslock.agent; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import org.objectweb.asm.Type; -import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; import java.lang.instrument.Instrumentation; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.jar.JarFile; @@ -56,10 +58,6 @@ public static void premain(String args, Instrumentation inst) { } } - /** - * Load policy from YAML file and set system properties. - * Format: capslock.block.CAPABILITY_NAME=pkg1,pkg2,... - */ private static void loadPolicy(String policyPath) { File policyFile = new File(policyPath); if (!policyFile.exists()) { @@ -69,44 +67,41 @@ private static void loadPolicy(String policyPath) { Log.info("Loading policy from: " + policyPath); - // Simple YAML parsing (no external deps) - Map> capabilityToPackages = new HashMap<>(); - - try (BufferedReader reader = new BufferedReader(new FileReader(policyFile))) { - String line; - String currentPackage = null; - boolean inBlocked = false; - - while ((line = reader.readLine()) != null) { - String trimmed = line.trim(); + try { + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + PolicyFile policy = mapper.readValue(policyFile, PolicyFile.class); - // Handle "- package:" or "package:" - if (trimmed.contains("package:")) { - int idx = trimmed.indexOf("package:"); - currentPackage = trimmed.substring(idx + 8).trim().replace("\"", "").replace("'", ""); - inBlocked = false; - } else if (trimmed.equals("blocked:")) { - inBlocked = true; - } else if (inBlocked && trimmed.startsWith("- ")) { - String capability = trimmed.substring(2).trim(); - if (currentPackage != null) { - capabilityToPackages - .computeIfAbsent(capability, k -> new HashSet<>()) - .add(currentPackage); + Map> capabilityToPackages = new HashMap<>(); + if (policy.policies != null) { + for (PolicyEntry entry : policy.policies) { + if (entry.blocked != null) { + for (String capability : entry.blocked) { + capabilityToPackages + .computeIfAbsent(capability, k -> new HashSet<>()) + .add(entry.pkg); + } } } } + + for (Map.Entry> entry : capabilityToPackages.entrySet()) { + String propName = "capslock.block." + entry.getKey(); + String propValue = String.join(",", entry.getValue()); + System.setProperty(propName, propValue); + Log.info("Policy: " + entry.getKey() + " blocked for: " + propValue); + } } catch (Exception e) { Log.error("Failed to load policy: " + e.getMessage()); - return; } + } - // Set system properties for each capability - for (Map.Entry> entry : capabilityToPackages.entrySet()) { - String propName = "capslock.block." + entry.getKey(); - String propValue = String.join(",", entry.getValue()); - System.setProperty(propName, propValue); - Log.info("Policy: " + entry.getKey() + " blocked for: " + propValue); - } + public static class PolicyFile { + public List policies; + } + + public static class PolicyEntry { + @JsonProperty("package") + public String pkg; + public List blocked; } } diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java b/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java index a1827f9..d8c561d 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/PolicyChecker.java @@ -26,6 +26,11 @@ public static void check(String capability) { return; } + // Record to call graph if enabled + if (RuntimeCallGraph.isRecording()) { + RuntimeCallGraph.recordPath(capability, appFrames); + } + logFrames(capability, appFrames); if (blockedPackages != null) { diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/RuntimeCallGraph.java b/agent/src/main/java/com/github/serj/jcapslock/agent/RuntimeCallGraph.java new file mode 100644 index 0000000..580b716 --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/RuntimeCallGraph.java @@ -0,0 +1,136 @@ +package com.github.serj.jcapslock.agent; + +import capslock.proto.CapabilityOuterClass.Capability; +import capslock.proto.GraphOuterClass.Graph; +import com.github.serj.jcapslock.analyzer.GraphProtoBuilder; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Runtime call graph captured during test execution or runtime. + * Thin wrapper around GraphProtoBuilder with recording state and package filtering. + */ +public class RuntimeCallGraph { + + // Must use primitive boolean - AtomicBoolean's triggers capability checks + // which causes a class loading cycle when RuntimeCallGraph itself is being loaded. + private static volatile boolean recording = false; + + private static final GraphProtoBuilder BUILDER = new GraphProtoBuilder(); + private static final Map METHOD_INDEX = new ConcurrentHashMap<>(); + + private static volatile Set includePatterns = Set.of(); + private static volatile Set excludePatterns = Set.of(); + + public static void startRecording() { + recording = true; + } + + public static void stopRecording() { + recording = false; + } + + public static void clear() { + BUILDER.clear(); + METHOD_INDEX.clear(); + } + + public static boolean isRecording() { + return recording; + } + + public static void configure(String include, String exclude) { + includePatterns = parsePatterns(include); + excludePatterns = parsePatterns(exclude); + } + + private static Set parsePatterns(String patterns) { + if (patterns == null || patterns.isBlank()) { + return Set.of(); + } + return Set.of(patterns.split(",")); + } + + /** + * Record a call path from a capability invocation. + * Called from PolicyChecker when a capability method is invoked. + */ + static void recordPath(String capability, List appFrames) { + if (appFrames.isEmpty()) { + return; + } + + Capability cap; + try { + cap = Capability.valueOf(capability); + } catch (IllegalArgumentException e) { + return; + } + + // Record edges between adjacent frames + for (int i = 0; i < appFrames.size() - 1; i++) { + StackTraceElement caller = appFrames.get(i); + StackTraceElement callee = appFrames.get(i + 1); + + if (shouldInclude(caller) && shouldInclude(callee)) { + int callerId = getOrCreateMethod(caller); + int calleeId = getOrCreateMethod(callee); + BUILDER.addCall(callerId, calleeId); + } + } + + // Record capability on innermost frame + StackTraceElement capFrame = appFrames.get(appFrames.size() - 1); + if (shouldInclude(capFrame)) { + int funcId = getOrCreateMethod(capFrame); + BUILDER.addCapability(funcId, cap); + } + } + + private static int getOrCreateMethod(StackTraceElement frame) { + String key = frame.getClassName() + "." + frame.getMethodName(); + return METHOD_INDEX.computeIfAbsent(key, k -> { + String internalName = frame.getClassName().replace('.', '/'); + return BUILDER.addFunction(internalName, frame.getMethodName()); + }); + } + + private static boolean shouldInclude(StackTraceElement frame) { + String className = frame.getClassName(); + int lastDot = className.lastIndexOf('.'); + String pkg = lastDot > 0 ? className.substring(0, lastDot) : ""; + + for (String pattern : excludePatterns) { + if (pkg.startsWith(pattern.trim())) { + return false; + } + } + + if (!includePatterns.isEmpty()) { + for (String pattern : includePatterns) { + if (pkg.startsWith(pattern.trim())) { + return true; + } + } + return false; + } + return true; + } + + /** + * Build and return the current graph. + */ + public static Graph toProto() { + return BUILDER.build(); + } + + /** + * Get the underlying builder for advanced usage. + */ + public static GraphProtoBuilder getBuilder() { + return BUILDER; + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CallGraphAssertions.java b/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CallGraphAssertions.java new file mode 100644 index 0000000..2fea422 --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CallGraphAssertions.java @@ -0,0 +1,114 @@ +package com.github.serj.jcapslock.agent.junit; + +import capslock.proto.CapabilityOuterClass.Capability; +import capslock.proto.GraphOuterClass.Graph; + +import java.util.HashSet; +import java.util.Set; + +/** + * Fluent assertions for verifying call graph properties. + * + *

Usage: + *

+ * import static com.github.serj.jcapslock.agent.junit.CallGraphAssertions.assertThat;
+ *
+ * {@literal @}Test
+ * void testFileCapability(Graph graph) {
+ *     assertThat(graph)
+ *         .hasCapability(Capability.CAPABILITY_FILES)
+ *         .hasEdge("MyClass.readFile", "FileHelper.open");
+ * }
+ * 
+ */ +public class CallGraphAssertions { + + public static CallGraphAssert assertThat(Graph graph) { + return new CallGraphAssert(graph); + } + + public record CallGraphAssert(Graph graph) { + + /** + * Assert that the graph contains a specific capability. + */ + public CallGraphAssert hasCapability(Capability capability) { + boolean found = graph.getCapabilitiesList().stream() + .anyMatch(fc -> fc.getCapability() == capability); + if (!found) { + throw new AssertionError("Expected graph to have capability " + capability + + ", but found: " + getCapabilities()); + } + return this; + } + + /** + * Assert that the graph does not contain a specific capability. + */ + public CallGraphAssert doesNotHaveCapability(Capability capability) { + boolean found = graph.getCapabilitiesList().stream() + .anyMatch(fc -> fc.getCapability() == capability); + if (found) { + throw new AssertionError("Expected graph to NOT have capability " + capability); + } + return this; + } + + /** + * Assert that the graph contains an edge between two methods. + * Method names can be partial (e.g., "MyClass.method" matches "com.example.MyClass.method"). + */ + public CallGraphAssert hasEdge(String callerPattern, String calleePattern) { + for (Graph.Call call : graph.getCallsList()) { + String callerName = graph.getFunctions((int) call.getCaller()).getName(); + String calleeName = graph.getFunctions((int) call.getCallee()).getName(); + + if (callerName.contains(callerPattern) && calleeName.contains(calleePattern)) { + return this; + } + } + throw new AssertionError("Expected edge from '" + callerPattern + "' to '" + calleePattern + + "', but no matching edge found in graph with " + graph.getCallsCount() + " edges"); + } + + /** + * Assert that the graph has at least the specified number of edges. + */ + public CallGraphAssert hasMinimumEdges(int count) { + if (graph.getCallsCount() < count) { + throw new AssertionError("Expected at least " + count + " edges, but found " + graph.getCallsCount()); + } + return this; + } + + /** + * Assert that the graph has at least the specified number of functions. + */ + public CallGraphAssert hasMinimumFunctions(int count) { + if (graph.getFunctionsCount() < count) { + throw new AssertionError("Expected at least " + count + " functions, but found " + graph.getFunctionsCount()); + } + return this; + } + + /** + * Assert that the graph contains a function matching the pattern. + */ + public CallGraphAssert hasFunction(String pattern) { + for (Graph.Function func : graph.getFunctionsList()) { + if (func.getName().contains(pattern)) { + return this; + } + } + throw new AssertionError("Expected function matching '" + pattern + "', but not found"); + } + + private Set getCapabilities() { + Set caps = new HashSet<>(); + for (Graph.FunctionCapability fc : graph.getCapabilitiesList()) { + caps.add(fc.getCapability()); + } + return caps; + } + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CapslockCallGraphExtension.java b/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CapslockCallGraphExtension.java new file mode 100644 index 0000000..68ff94e --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CapslockCallGraphExtension.java @@ -0,0 +1,65 @@ +package com.github.serj.jcapslock.agent.junit; + +import capslock.proto.GraphOuterClass.Graph; +import com.github.serj.jcapslock.agent.RuntimeCallGraph; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; + +/** + * JUnit 5 extension that captures runtime call graphs during tests. + * + *

Usage: + *

+ * {@literal @}ExtendWith(CapslockCallGraphExtension.class)
+ * class MyTest {
+ *     {@literal @}Test
+ *     void myTest(Graph graph) {
+ *         // test code that triggers capabilities
+ *         // graph contains the captured call graph
+ *     }
+ * }
+ * 
+ */ +public class CapslockCallGraphExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { + + private static final Namespace NAMESPACE = Namespace.create(CapslockCallGraphExtension.class); + private static final String GRAPH_KEY = "capturedGraph"; + + @Override + public void beforeEach(ExtensionContext context) { + RuntimeCallGraph.clear(); + + // Apply configuration from annotation if present + context.getTestMethod() + .map(m -> m.getAnnotation(CaptureCallGraph.class)) + .or(() -> context.getTestClass().map(c -> c.getAnnotation(CaptureCallGraph.class))) + .ifPresent(annotation -> { + String include = String.join(",", annotation.include()); + String exclude = String.join(",", annotation.exclude()); + RuntimeCallGraph.configure(include, exclude); + }); + + RuntimeCallGraph.startRecording(); + } + + @Override + public void afterEach(ExtensionContext context) { + RuntimeCallGraph.stopRecording(); + Graph graph = RuntimeCallGraph.toProto(); + context.getStore(NAMESPACE).put(GRAPH_KEY, graph); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return parameterContext.getParameter().getType() == Graph.class; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return extensionContext.getStore(NAMESPACE).get(GRAPH_KEY, Graph.class); + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CaptureCallGraph.java b/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CaptureCallGraph.java new file mode 100644 index 0000000..3f52e49 --- /dev/null +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/junit/CaptureCallGraph.java @@ -0,0 +1,44 @@ +package com.github.serj.jcapslock.agent.junit; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Enables call graph capture for a test class or method. + * + *

Usage: + *

+ * {@literal @}CaptureCallGraph
+ * class MyTest {
+ *     {@literal @}Test
+ *     void myTest(Graph graph) {
+ *         // test code
+ *     }
+ * }
+ * 
+ * + *

With filtering: + *

+ * {@literal @}CaptureCallGraph(include = {"com.myapp"}, exclude = {"com.myapp.generated"})
+ * class MyTest { ... }
+ * 
+ */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(CapslockCallGraphExtension.class) +public @interface CaptureCallGraph { + + /** + * Package prefixes to include (empty = all). + */ + String[] include() default {}; + + /** + * Package prefixes to exclude. + */ + String[] exclude() default {}; +} \ No newline at end of file diff --git a/core/analysis/pom.xml b/core/analysis/pom.xml new file mode 100644 index 0000000..47d09f6 --- /dev/null +++ b/core/analysis/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + com.github.serj + jcapslock-parent + 1.0-SNAPSHOT + ../../pom.xml + + + jcapslock-core-analysis + jar + + JCapsLock Core Analysis + Static analysis with SootUp for capability detection + + + + + com.github.serj + jcapslock-core + ${project.version} + + + + + com.github.soot-oss.SootUp + sootup.core + + + com.github.soot-oss.SootUp + sootup.java.bytecode.frontend + + + com.github.soot-oss.SootUp + sootup.java.core + + + com.github.soot-oss.SootUp + sootup.callgraph + + + + com.github.soot-oss.SootUp + sootup.qilin + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + \ No newline at end of file diff --git a/core/src/main/java/com/github/serj/jcapslock/analyzer/CallGraphBuilder.java b/core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/CallGraphBuilder.java similarity index 97% rename from core/src/main/java/com/github/serj/jcapslock/analyzer/CallGraphBuilder.java rename to core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/CallGraphBuilder.java index 72bf577..8f78c6c 100644 --- a/core/src/main/java/com/github/serj/jcapslock/analyzer/CallGraphBuilder.java +++ b/core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/CallGraphBuilder.java @@ -186,8 +186,9 @@ public static Graph buildUnifiedGraphProto( LOGGER.info("Found " + appClasses.size() + " application classes (excluding JDK)"); - // Create GraphBuilder - GraphProtoBuilder graphBuilder = new GraphProtoBuilder(); + // Create SootGraphBuilder for SootUp-aware graph construction + SootGraphBuilder sootBuilder = new SootGraphBuilder(); + GraphProtoBuilder graphBuilder = sootBuilder.getBuilder(); Map methodToFunctionId = new HashMap<>(); // Create implicit capability detector @@ -249,7 +250,7 @@ public static Graph buildUnifiedGraphProto( int functionId; if (method != null) { - functionId = isRoot ? graphBuilder.addRootFunction(method) : graphBuilder.addFunction(method); + functionId = isRoot ? sootBuilder.addRootFunction(method) : sootBuilder.addFunction(method); Set implicitCaps = implicitDetector.detectImplicitCapabilities(method); for (Capability cap : implicitCaps) { graphBuilder.addCapability(functionId, cap, true); diff --git a/core/src/main/java/com/github/serj/jcapslock/analyzer/ImplicitCapabilityDetector.java b/core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/ImplicitCapabilityDetector.java similarity index 100% rename from core/src/main/java/com/github/serj/jcapslock/analyzer/ImplicitCapabilityDetector.java rename to core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/ImplicitCapabilityDetector.java diff --git a/core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/SootGraphBuilder.java b/core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/SootGraphBuilder.java new file mode 100644 index 0000000..fc0c95c --- /dev/null +++ b/core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/SootGraphBuilder.java @@ -0,0 +1,98 @@ +package com.github.serj.jcapslock.analyzer; + +import capslock.proto.CapabilityOuterClass.Capability; +import capslock.proto.GraphOuterClass.Graph; +import sootup.core.model.SootMethod; +import sootup.core.types.ClassType; + +/** + * Extension of GraphProtoBuilder that adds SootUp-specific methods. + * Wraps GraphProtoBuilder for use in static analysis with SootUp. + */ +public class SootGraphBuilder { + + private final GraphProtoBuilder builder; + + public SootGraphBuilder() { + this.builder = new GraphProtoBuilder(); + } + + public SootGraphBuilder(GraphProtoBuilder builder) { + this.builder = builder; + } + + /** + * Get the underlying GraphProtoBuilder. + */ + public GraphProtoBuilder getBuilder() { + return builder; + } + + /** + * Add a function from a SootUp SootMethod. + * @param method The SootMethod to add + * @return The index of the added function + */ + public int addFunction(SootMethod method) { + ClassType classType = method.getDeclaringClassType(); + String className = classType.getFullyQualifiedName(); + String methodName = method.getName(); + + // Convert to internal name format (with slashes) + String internalName = className.replace('.', '/'); + + return builder.addFunction(internalName, methodName); + } + + /** + * Add a function and mark its package as root. + */ + public int addRootFunction(SootMethod method) { + ClassType classType = method.getDeclaringClassType(); + sootup.core.signatures.PackageName pkg = classType.getPackageName(); + String packageName = pkg != null ? pkg.getName() : ""; + builder.markPackageAsRoot(packageName); + return addFunction(method); + } + + // Delegate methods to underlying builder + public int addFunction(String owner, String name) { + return builder.addFunction(owner, name); + } + + public int addRootFunction(String owner, String name) { + return builder.addRootFunction(owner, name); + } + + public void addCall(int callerId, int calleeId) { + builder.addCall(callerId, calleeId); + } + + public void addCapability(int functionId, Capability capability) { + builder.addCapability(functionId, capability); + } + + public void addCapability(int functionId, Capability capability, boolean implicit) { + builder.addCapability(functionId, capability, implicit); + } + + public void markPackageAsRoot(String packageName) { + builder.markPackageAsRoot(packageName); + } + + public void markPackageAsOptional(String packageName) { + builder.markPackageAsOptional(packageName); + } + + public int getFunctionIndex(String fullyQualifiedName) { + return builder.getFunctionIndex(fullyQualifiedName); + } + + public capslock.proto.GraphOuterClass.Graph build() { + return builder.build(); + } + + public void clear() { + builder.clear(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/github/serj/jcapslock/analyzer/UnifiedAnalyzer.java b/core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/UnifiedAnalyzer.java similarity index 100% rename from core/src/main/java/com/github/serj/jcapslock/analyzer/UnifiedAnalyzer.java rename to core/analysis/src/main/java/com/github/serj/jcapslock/analyzer/UnifiedAnalyzer.java diff --git a/core/pom.xml b/core/pom.xml index d17a9f1..70e553c 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -47,29 +47,6 @@ protobuf-java-util - - - com.github.soot-oss.SootUp - sootup.core - - - com.github.soot-oss.SootUp - sootup.java.bytecode.frontend - - - com.github.soot-oss.SootUp - sootup.java.core - - - com.github.soot-oss.SootUp - sootup.callgraph - - - - com.github.soot-oss.SootUp - sootup.qilin - - org.junit.jupiter diff --git a/core/src/main/java/com/github/serj/jcapslock/analyzer/GraphMerger.java b/core/src/main/java/com/github/serj/jcapslock/analyzer/GraphMerger.java new file mode 100644 index 0000000..e6adcef --- /dev/null +++ b/core/src/main/java/com/github/serj/jcapslock/analyzer/GraphMerger.java @@ -0,0 +1,147 @@ +package com.github.serj.jcapslock.analyzer; + +import capslock.proto.GraphOuterClass.Graph; +import com.google.protobuf.util.JsonFormat; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Utility for loading, saving, and merging Graph protos. + */ +public class GraphMerger { + + private static final JsonFormat.Parser JSON_PARSER = JsonFormat.parser().ignoringUnknownFields(); + private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer(); + + /** + * Load a Graph from a JSON file. + */ + public static Graph loadFromJson(Path jsonFile) throws IOException { + try (InputStream in = Files.newInputStream(jsonFile)) { + return loadFromJson(in); + } + } + + /** + * Load a Graph from a JSON input stream. + */ + public static Graph loadFromJson(InputStream input) throws IOException { + String json = new String(input.readAllBytes()); + Graph.Builder builder = Graph.newBuilder(); + JSON_PARSER.merge(json, builder); + return builder.build(); + } + + /** + * Save a Graph to a JSON file. + */ + public static void saveToJson(Graph graph, Path outputFile) throws IOException { + try (OutputStream out = Files.newOutputStream(outputFile)) { + saveToJson(graph, out); + } + } + + /** + * Save a Graph to a JSON output stream. + */ + public static void saveToJson(Graph graph, OutputStream output) throws IOException { + String json = JSON_PRINTER.print(graph); + output.write(json.getBytes()); + } + + /** + * Merge two graphs into one. + *
    + *
  • Functions are deduplicated by name
  • + *
  • Edges are deduplicated
  • + *
  • Capabilities are deduplicated
  • + *
+ * + * @param base The base graph + * @param other The graph to merge into base + * @return A merged graph + */ + public static Graph merge(Graph base, Graph other) { + // Build index of base functions by name + Map functionIndex = new HashMap<>(); + for (int i = 0; i < base.getFunctionsCount(); i++) { + functionIndex.put(base.getFunctions(i).getName(), i); + } + + // Build set of existing edges + Set existingEdges = new HashSet<>(); + for (Graph.Call call : base.getCallsList()) { + existingEdges.add(call.getCaller() + "->" + call.getCallee()); + } + + // Build set of existing capabilities + Set existingCaps = new HashSet<>(); + for (Graph.FunctionCapability fc : base.getCapabilitiesList()) { + existingCaps.add(fc.getFunction() + ":" + fc.getCapability().name()); + } + + Graph.Builder merged = base.toBuilder(); + Map otherToMerged = new HashMap<>(); + + // Map other functions to merged graph + for (int i = 0; i < other.getFunctionsCount(); i++) { + Graph.Function func = other.getFunctions(i); + Integer existingId = functionIndex.get(func.getName()); + + if (existingId != null) { + otherToMerged.put(i, existingId); + } else { + int newId = merged.getFunctionsCount(); + merged.addFunctions(func); + functionIndex.put(func.getName(), newId); + otherToMerged.put(i, newId); + } + } + + // Add new edges + for (Graph.Call call : other.getCallsList()) { + Integer callerId = otherToMerged.get((int) call.getCaller()); + Integer calleeId = otherToMerged.get((int) call.getCallee()); + + if (callerId == null || calleeId == null) { + continue; + } + + String edgeKey = callerId + "->" + calleeId; + if (!existingEdges.contains(edgeKey)) { + merged.addCalls(Graph.Call.newBuilder() + .setCaller(callerId) + .setCallee(calleeId) + .build()); + existingEdges.add(edgeKey); + } + } + + // Add new capabilities + for (Graph.FunctionCapability fc : other.getCapabilitiesList()) { + Integer funcId = otherToMerged.get((int) fc.getFunction()); + if (funcId == null) { + continue; + } + + String capKey = funcId + ":" + fc.getCapability().name(); + if (!existingCaps.contains(capKey)) { + merged.addCapabilities(Graph.FunctionCapability.newBuilder() + .setFunction(funcId) + .setCapability(fc.getCapability()) + .build()); + existingCaps.add(capKey); + } + } + + return merged.build(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/github/serj/jcapslock/analyzer/GraphProtoBuilder.java b/core/src/main/java/com/github/serj/jcapslock/analyzer/GraphProtoBuilder.java index 0ea03ab..4b7581b 100644 --- a/core/src/main/java/com/github/serj/jcapslock/analyzer/GraphProtoBuilder.java +++ b/core/src/main/java/com/github/serj/jcapslock/analyzer/GraphProtoBuilder.java @@ -1,21 +1,15 @@ package com.github.serj.jcapslock.analyzer; -import capslock.proto.GraphOuterClass; import capslock.proto.GraphOuterClass.Graph; import capslock.proto.CapabilityOuterClass.Capability; import com.github.serj.jcapslock.util.JavaUtils; -import sootup.core.model.SootMethod; -import sootup.core.signatures.MethodSignature; -import sootup.core.types.ClassType; import org.objectweb.asm.Type; -import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; /** @@ -26,44 +20,38 @@ public class GraphProtoBuilder { private static final Logger LOGGER = Logger.getLogger(GraphProtoBuilder.class.getName()); - private final Graph.Builder graphBuilder; + private Graph.Builder graphBuilder; private final Map functionIndex; + private final Map packageBuilders; private final Map packageIndex; - private final List packageBuilders; private final Set rootPackages; private final Set optionalPackages; - private int nextFunctionId = 0; - private int nextPackageId = 0; - + private final AtomicInteger nextFunctionId = new AtomicInteger(0); + private final AtomicInteger nextPackageId = new AtomicInteger(0); + public GraphProtoBuilder() { this.graphBuilder = Graph.newBuilder(); this.graphBuilder.setLanguage("java"); - this.functionIndex = new HashMap<>(); - this.packageIndex = new HashMap<>(); - this.packageBuilders = new ArrayList<>(); - this.rootPackages = new HashSet<>(); - this.optionalPackages = new HashSet<>(); + this.functionIndex = new ConcurrentHashMap<>(); + this.packageIndex = new ConcurrentHashMap<>(); + this.packageBuilders = new ConcurrentHashMap<>(); + this.rootPackages = ConcurrentHashMap.newKeySet(); + this.optionalPackages = ConcurrentHashMap.newKeySet(); } - + /** - * Add a function from a SootUp SootMethod. - * @param method The SootMethod to add - * @return The index of the added function + * Clear all recorded data, resetting the builder for reuse. */ - public int addFunction(SootMethod method) { - ClassType classType = method.getDeclaringClassType(); - String className = classType.getFullyQualifiedName(); - String methodName = method.getName(); - String fullyQualifiedName = className + "." + methodName; - - // Get package name from the class type - sootup.core.signatures.PackageName pkg = classType.getPackageName(); - String packageName = pkg != null ? pkg.getName() : ""; - - // Use SootUp's built-in method to get simple class name - String simpleClassName = classType.getClassName(); - - return addFunctionInternal(fullyQualifiedName, packageName, simpleClassName, methodName); + public void clear() { + this.graphBuilder = Graph.newBuilder(); + this.graphBuilder.setLanguage("java"); + this.functionIndex.clear(); + this.packageIndex.clear(); + this.packageBuilders.clear(); + this.rootPackages.clear(); + this.optionalPackages.clear(); + this.nextFunctionId.set(0); + this.nextPackageId.set(0); } /** @@ -95,7 +83,7 @@ private int addFunctionInternal(String fullyQualifiedName, String packageName, return existingIndex; } - int functionId = nextFunctionId++; + int functionId = nextFunctionId.getAndIncrement(); functionIndex.put(fullyQualifiedName, functionId); int packageId = getOrCreatePackage(packageName); @@ -166,10 +154,10 @@ public int getFunctionIndex(String fullyQualifiedName) { * Packages are built here with their final isRoot flags. */ public Graph build() { - // Build all packages now (with correct isRoot flags) - for (Graph.Package.Builder pkgBuilder : packageBuilders) { - graphBuilder.addPackages(pkgBuilder.build()); - } + // Build all packages in order by ID (with correct isRoot flags) + packageBuilders.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> graphBuilder.addPackages(entry.getValue().build())); Graph graph = graphBuilder.build(); LOGGER.info("Built graph with " + graph.getFunctionsCount() + " functions, " + @@ -189,7 +177,7 @@ private int getOrCreatePackage(String packageName) { return existingIndex; } - int packageId = nextPackageId++; + int packageId = nextPackageId.getAndIncrement(); packageIndex.put(packageName, packageId); // Store builder - don't build yet, isRoot may be set later @@ -198,7 +186,7 @@ private int getOrCreatePackage(String packageName) { .setPath(packageName) .setIsStandardLibrary(JavaUtils.isJdkPackage(packageName)); - packageBuilders.add(pkgBuilder); + packageBuilders.put(packageId, pkgBuilder); return packageId; } @@ -229,17 +217,6 @@ public boolean isRootPackage(String packageName) { return rootPackages.contains(packageName); } - /** - * Add a function and mark its package as root. - */ - public int addRootFunction(SootMethod method) { - ClassType classType = method.getDeclaringClassType(); - sootup.core.signatures.PackageName pkg = classType.getPackageName(); - String packageName = pkg != null ? pkg.getName() : ""; - markPackageAsRoot(packageName); - return addFunction(method); - } - /** * Add a function and mark its package as root. */ diff --git a/maven-plugin/pom.xml b/maven-plugin/pom.xml index c314124..a7fcfa8 100644 --- a/maven-plugin/pom.xml +++ b/maven-plugin/pom.xml @@ -17,10 +17,10 @@ Maven plugin for capability analysis - + com.github.serj - jcapslock-core + jcapslock-core-analysis ${project.version} diff --git a/pom.xml b/pom.xml index ff24f03..11be181 100644 --- a/pom.xml +++ b/pom.xml @@ -14,14 +14,14 @@ core + core/analysis maven-plugin agent examples - 17 - 17 + 17 UTF-8 false 9.9 @@ -71,6 +71,11 @@ jackson-databind ${jackson.version}
+ + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.version} + @@ -130,6 +135,11 @@ ${junit.version} test + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + org.mockito mockito-core From 8e1f99f2ccc08e53ed9136b0c3ac374bc92927a2 Mon Sep 17 00:00:00 2001 From: Sergej Dechand Date: Sat, 13 Dec 2025 20:12:29 +0100 Subject: [PATCH 5/6] Refactor quick-test example package and test setup - Rename package from com.test to com.github.serj.jcapslock.examples.quicktest - Switch from exec-maven-plugin to maven-failsafe-plugin for integration tests - Add -Djdk.attach.allowAttachSelf=true to surefire argLine - Update policy.yaml and snapshot.json for new package structure --- agent/pom.xml | 2 + .../serj/jcapslock/agent/CapslockAgent.java | 15 ++ examples/quick-test/.capslock/policy.yaml | 2 +- examples/quick-test/.capslock/snapshot.json | 142 +++++++++--------- examples/quick-test/pom.xml | 24 +-- .../examples/quicktest}/SimpleApp.java | 10 +- .../quicktest}/blocked/BlockedExec.java | 2 +- .../quicktest/PolicyCheckerUnitTest.java | 66 ++++++++ .../quicktest/PolicyEnforcementIT.java | 86 +++++++++++ .../java/com/test/PolicyEnforcementTest.java | 99 ------------ examples/quick-test/test-policy.sh | 4 +- 11 files changed, 263 insertions(+), 189 deletions(-) rename examples/quick-test/src/main/java/com/{test => github/serj/jcapslock/examples/quicktest}/SimpleApp.java (95%) rename examples/quick-test/src/main/java/com/{test => github/serj/jcapslock/examples/quicktest}/blocked/BlockedExec.java (90%) create mode 100644 examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyCheckerUnitTest.java create mode 100644 examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyEnforcementIT.java delete mode 100644 examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java diff --git a/agent/pom.xml b/agent/pom.xml index eac9e1a..861a6ac 100644 --- a/agent/pom.xml +++ b/agent/pom.xml @@ -59,6 +59,7 @@ com.github.serj.jcapslock.agent.CapslockAgent + com.github.serj.jcapslock.agent.CapslockAgent true true @@ -97,6 +98,7 @@ com.github.serj.jcapslock.agent.CapslockAgent + com.github.serj.jcapslock.agent.CapslockAgent true true diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java index 611b1d9..50b097c 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapslockAgent.java @@ -23,7 +23,18 @@ */ public class CapslockAgent { + private static Instrumentation instrumentation; + public static void premain(String args, Instrumentation inst) { + initialize(args, inst); + } + + public static void agentmain(String args, Instrumentation inst) { + initialize(args, inst); + } + + private static void initialize(String args, Instrumentation inst) { + instrumentation = inst; Log.info("Runtime capability monitoring enabled"); // Load policy if provided @@ -58,6 +69,10 @@ public static void premain(String args, Instrumentation inst) { } } + public static Instrumentation getInstrumentation() { + return instrumentation; + } + private static void loadPolicy(String policyPath) { File policyFile = new File(policyPath); if (!policyFile.exists()) { diff --git a/examples/quick-test/.capslock/policy.yaml b/examples/quick-test/.capslock/policy.yaml index 2d5f408..3a67bd8 100644 --- a/examples/quick-test/.capslock/policy.yaml +++ b/examples/quick-test/.capslock/policy.yaml @@ -2,6 +2,6 @@ # Packages listed here are blocked from using specified capabilities policies: - - package: "com.test.blocked" + - package: "com.github.serj.jcapslock.examples.quicktest.blocked" blocked: - CAPABILITY_EXEC diff --git a/examples/quick-test/.capslock/snapshot.json b/examples/quick-test/.capslock/snapshot.json index 247b2e9..8f695e0 100644 --- a/examples/quick-test/.capslock/snapshot.json +++ b/examples/quick-test/.capslock/snapshot.json @@ -1,164 +1,164 @@ { "capability_info": [{ - "package_name": "com.test", + "package_name": "com.github.serj.jcapslock.examples.quicktest", "capability": "CAPABILITY_READ_SYSTEM_STATE", - "dep_path": "com.test.SimpleApp.executeCommand java.lang.Runtime.getRuntime", - "package_dir": "com.test", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand java.lang.Runtime.getRuntime", + "package_dir": "com.github.serj.jcapslock.examples.quicktest", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.SimpleApp.executeCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { "name": "java.lang.Runtime.getRuntime", "package": "java.lang" }] }, { - "package_name": "com.test", + "package_name": "com.github.serj.jcapslock.examples.quicktest", "capability": "CAPABILITY_READ_SYSTEM_STATE", - "dep_path": "com.test.SimpleApp.main com.test.SimpleApp.runSystemCommand com.test.SimpleApp.executeCommand java.lang.Runtime.getRuntime", - "package_dir": "com.test", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.main com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand java.lang.Runtime.getRuntime", + "package_dir": "com.github.serj.jcapslock.examples.quicktest", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.SimpleApp.main", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.main", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { - "name": "com.test.SimpleApp.runSystemCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { - "name": "com.test.SimpleApp.executeCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { "name": "java.lang.Runtime.getRuntime", "package": "java.lang" }] }, { - "package_name": "com.test", + "package_name": "com.github.serj.jcapslock.examples.quicktest", "capability": "CAPABILITY_READ_SYSTEM_STATE", - "dep_path": "com.test.SimpleApp.runSystemCommand com.test.SimpleApp.executeCommand java.lang.Runtime.getRuntime", - "package_dir": "com.test", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand java.lang.Runtime.getRuntime", + "package_dir": "com.github.serj.jcapslock.examples.quicktest", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.SimpleApp.runSystemCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { - "name": "com.test.SimpleApp.executeCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { "name": "java.lang.Runtime.getRuntime", "package": "java.lang" }] }, { - "package_name": "com.test.blocked", + "package_name": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability": "CAPABILITY_READ_SYSTEM_STATE", - "dep_path": "com.test.blocked.BlockedExec.main com.test.blocked.BlockedExec.runCommand java.lang.Runtime.getRuntime", - "package_dir": "com.test.blocked", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.main com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand java.lang.Runtime.getRuntime", + "package_dir": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.blocked.BlockedExec.main", - "package": "com.test.blocked" + "name": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.main", + "package": "com.github.serj.jcapslock.examples.quicktest.blocked" }, { - "name": "com.test.blocked.BlockedExec.runCommand", - "package": "com.test.blocked" + "name": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand", + "package": "com.github.serj.jcapslock.examples.quicktest.blocked" }, { "name": "java.lang.Runtime.getRuntime", "package": "java.lang" }] }, { - "package_name": "com.test.blocked", + "package_name": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability": "CAPABILITY_READ_SYSTEM_STATE", - "dep_path": "com.test.blocked.BlockedExec.runCommand java.lang.Runtime.getRuntime", - "package_dir": "com.test.blocked", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand java.lang.Runtime.getRuntime", + "package_dir": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.blocked.BlockedExec.runCommand", - "package": "com.test.blocked" + "name": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand", + "package": "com.github.serj.jcapslock.examples.quicktest.blocked" }, { "name": "java.lang.Runtime.getRuntime", "package": "java.lang" }] }, { - "package_name": "com.test", + "package_name": "com.github.serj.jcapslock.examples.quicktest", "capability": "CAPABILITY_REFLECT", - "dep_path": "com.test.SimpleApp.useReflection", - "package_dir": "com.test", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.useReflection", + "package_dir": "com.github.serj.jcapslock.examples.quicktest", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.SimpleApp.useReflection", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.useReflection", + "package": "com.github.serj.jcapslock.examples.quicktest" }] }, { - "package_name": "com.test", + "package_name": "com.github.serj.jcapslock.examples.quicktest", "capability": "CAPABILITY_EXEC", - "dep_path": "com.test.SimpleApp.executeCommand java.lang.Runtime.exec", - "package_dir": "com.test", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand java.lang.Runtime.exec", + "package_dir": "com.github.serj.jcapslock.examples.quicktest", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.SimpleApp.executeCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { "name": "java.lang.Runtime.exec", "package": "java.lang" }] }, { - "package_name": "com.test", + "package_name": "com.github.serj.jcapslock.examples.quicktest", "capability": "CAPABILITY_EXEC", - "dep_path": "com.test.SimpleApp.main com.test.SimpleApp.runSystemCommand com.test.SimpleApp.executeCommand java.lang.Runtime.exec", - "package_dir": "com.test", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.main com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand java.lang.Runtime.exec", + "package_dir": "com.github.serj.jcapslock.examples.quicktest", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.SimpleApp.main", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.main", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { - "name": "com.test.SimpleApp.runSystemCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { - "name": "com.test.SimpleApp.executeCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { "name": "java.lang.Runtime.exec", "package": "java.lang" }] }, { - "package_name": "com.test", + "package_name": "com.github.serj.jcapslock.examples.quicktest", "capability": "CAPABILITY_EXEC", - "dep_path": "com.test.SimpleApp.runSystemCommand com.test.SimpleApp.executeCommand java.lang.Runtime.exec", - "package_dir": "com.test", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand java.lang.Runtime.exec", + "package_dir": "com.github.serj.jcapslock.examples.quicktest", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.SimpleApp.runSystemCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.runSystemCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { - "name": "com.test.SimpleApp.executeCommand", - "package": "com.test" + "name": "com.github.serj.jcapslock.examples.quicktest.SimpleApp.executeCommand", + "package": "com.github.serj.jcapslock.examples.quicktest" }, { "name": "java.lang.Runtime.exec", "package": "java.lang" }] }, { - "package_name": "com.test.blocked", + "package_name": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability": "CAPABILITY_EXEC", - "dep_path": "com.test.blocked.BlockedExec.main com.test.blocked.BlockedExec.runCommand java.lang.Runtime.exec", - "package_dir": "com.test.blocked", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.main com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand java.lang.Runtime.exec", + "package_dir": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.blocked.BlockedExec.main", - "package": "com.test.blocked" + "name": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.main", + "package": "com.github.serj.jcapslock.examples.quicktest.blocked" }, { - "name": "com.test.blocked.BlockedExec.runCommand", - "package": "com.test.blocked" + "name": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand", + "package": "com.github.serj.jcapslock.examples.quicktest.blocked" }, { "name": "java.lang.Runtime.exec", "package": "java.lang" }] }, { - "package_name": "com.test.blocked", + "package_name": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability": "CAPABILITY_EXEC", - "dep_path": "com.test.blocked.BlockedExec.runCommand java.lang.Runtime.exec", - "package_dir": "com.test.blocked", + "dep_path": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand java.lang.Runtime.exec", + "package_dir": "com.github.serj.jcapslock.examples.quicktest.blocked", "capability_type": "CAPABILITY_TYPE_DIRECT", "path": [{ - "name": "com.test.blocked.BlockedExec.runCommand", - "package": "com.test.blocked" + "name": "com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec.runCommand", + "package": "com.github.serj.jcapslock.examples.quicktest.blocked" }, { "name": "java.lang.Runtime.exec", "package": "java.lang" diff --git a/examples/quick-test/pom.xml b/examples/quick-test/pom.xml index 86073f8..181c0c0 100644 --- a/examples/quick-test/pom.xml +++ b/examples/quick-test/pom.xml @@ -55,6 +55,7 @@ org.apache.maven.plugins maven-surefire-plugin + -Djdk.attach.allowAttachSelf=true ${com.github.serj:jcapslock-agent:jar} ${project.basedir}/.capslock/policy.yaml @@ -79,21 +80,24 @@ true
- + - org.codehaus.mojo - exec-maven-plugin - 3.1.0 + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.4 + + + ${com.github.serj:jcapslock-agent:jar} + ${project.basedir}/.capslock/policy.yaml + ${project.build.outputDirectory} + + - policy-e2e-test - integration-test - exec + integration-test + verify - - ./test-policy.sh - diff --git a/examples/quick-test/src/main/java/com/test/SimpleApp.java b/examples/quick-test/src/main/java/com/github/serj/jcapslock/examples/quicktest/SimpleApp.java similarity index 95% rename from examples/quick-test/src/main/java/com/test/SimpleApp.java rename to examples/quick-test/src/main/java/com/github/serj/jcapslock/examples/quicktest/SimpleApp.java index 65f0a9c..84c7730 100644 --- a/examples/quick-test/src/main/java/com/test/SimpleApp.java +++ b/examples/quick-test/src/main/java/com/github/serj/jcapslock/examples/quicktest/SimpleApp.java @@ -1,18 +1,18 @@ -package com.test; +package com.github.serj.jcapslock.examples.quicktest; import java.io.File; import java.io.FileReader; import java.io.IOException; public class SimpleApp { - + public static void main(String[] args) { System.out.println("Hello from SimpleApp!"); SimpleApp app = new SimpleApp(); app.readFile("test.txt"); app.runSystemCommand("ls"); } - + public void readFile(String filename) { try { File file = new File(filename); @@ -24,7 +24,7 @@ public void readFile(String filename) { e.printStackTrace(); } } - + public void runSystemCommand(String cmd) { executeCommand(cmd); } @@ -36,7 +36,7 @@ public void executeCommand(String cmd) { e.printStackTrace(); } } - + public void useReflection() { try { Class clazz = Class.forName("java.lang.String"); diff --git a/examples/quick-test/src/main/java/com/test/blocked/BlockedExec.java b/examples/quick-test/src/main/java/com/github/serj/jcapslock/examples/quicktest/blocked/BlockedExec.java similarity index 90% rename from examples/quick-test/src/main/java/com/test/blocked/BlockedExec.java rename to examples/quick-test/src/main/java/com/github/serj/jcapslock/examples/quicktest/blocked/BlockedExec.java index 9c7001d..7417212 100644 --- a/examples/quick-test/src/main/java/com/test/blocked/BlockedExec.java +++ b/examples/quick-test/src/main/java/com/github/serj/jcapslock/examples/quicktest/blocked/BlockedExec.java @@ -1,4 +1,4 @@ -package com.test.blocked; +package com.github.serj.jcapslock.examples.quicktest.blocked; import java.io.IOException; diff --git a/examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyCheckerUnitTest.java b/examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyCheckerUnitTest.java new file mode 100644 index 0000000..f126050 --- /dev/null +++ b/examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyCheckerUnitTest.java @@ -0,0 +1,66 @@ +package com.github.serj.jcapslock.examples.quicktest; + +import com.github.serj.jcapslock.agent.PolicyChecker; +import com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec; +import com.sun.tools.attach.VirtualMachine; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.management.ManagementFactory; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests that dynamically load the agent and test actual method calls. + * Tests that BlockedExec.runCommand() is blocked while SimpleApp.runSystemCommand() is allowed. + */ +class PolicyCheckerUnitTest { + + private static final String CAPABILITY = "CAPABILITY_EXEC"; + private static final String BLOCKED_PACKAGE = "com.github.serj.jcapslock.examples.quicktest.blocked"; + + @BeforeAll + static void loadAgentAndSetupPolicy() throws Exception { + // Load the agent dynamically + String agentJar = System.getProperty("agent.jar"); + assertNotNull(agentJar, "agent.jar system property must be set"); + + String pid = ManagementFactory.getRuntimeMXBean().getName().split("@")[0]; + VirtualMachine vm = VirtualMachine.attach(pid); + try { + vm.loadAgent(agentJar); + } finally { + vm.detach(); + } + + // Set up policy: block CAPABILITY_EXEC for the blocked package + System.setProperty(PolicyChecker.PROP_PREFIX + CAPABILITY, BLOCKED_PACKAGE); + } + + @AfterAll + static void clearPolicy() { + System.clearProperty(PolicyChecker.PROP_PREFIX + CAPABILITY); + } + + @Test + void blockedPackageThrowsSecurityException() { + BlockedExec blocked = new BlockedExec(); + + SecurityException ex = assertThrows(SecurityException.class, + () -> blocked.runCommand("echo test"), + "BlockedExec.runCommand() should throw SecurityException"); + + assertTrue(ex.getMessage().contains("blocked"), + "Exception message should mention 'blocked'. Got: " + ex.getMessage()); + } + + @Test + void allowedPackageCanExecute() { + SimpleApp app = new SimpleApp(); + + // Should not throw - SimpleApp is in allowed package + assertDoesNotThrow(() -> app.runSystemCommand("echo test"), + "SimpleApp.runSystemCommand() should be allowed"); + } +} \ No newline at end of file diff --git a/examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyEnforcementIT.java b/examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyEnforcementIT.java new file mode 100644 index 0000000..44bf207 --- /dev/null +++ b/examples/quick-test/src/test/java/com/github/serj/jcapslock/examples/quicktest/PolicyEnforcementIT.java @@ -0,0 +1,86 @@ +package com.github.serj.jcapslock.examples.quicktest; + +import com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for runtime policy enforcement. + * These tests spawn separate JVMs with the agent attached to verify end-to-end behavior. + */ +class PolicyEnforcementIT { + + private static final String SIMPLE_APP = SimpleApp.class.getCanonicalName(); + private static final String BLOCKED_EXEC = BlockedExec.class.getCanonicalName(); + + private static String agentJar; + private static String policyFile; + private static String testClasses; + private static String javaHome; + + @BeforeAll + static void setup() { + agentJar = System.getProperty("agent.jar"); + policyFile = System.getProperty("policy.file"); + testClasses = System.getProperty("test.classes"); + javaHome = System.getProperty("java.home"); + + assertNotNull(agentJar, "agent.jar system property must be set"); + assertNotNull(policyFile, "policy.file system property must be set"); + assertNotNull(testClasses, "test.classes system property must be set"); + } + + private ProcessResult runWithAgent(String mainClass) throws Exception { + ProcessBuilder pb = new ProcessBuilder( + javaHome + "/bin/java", + "-javaagent:" + agentJar + "=" + policyFile, + "-cp", testClasses, + mainClass + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output = new BufferedReader(new InputStreamReader(process.getInputStream())) + .lines().collect(Collectors.joining("\n")); + + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + int exitCode = finished ? process.exitValue() : -1; + + return new ProcessResult(exitCode, output, finished); + } + + private record ProcessResult(int exitCode, String output, boolean finished) {} + + @Test + void blockedPackageThrowsSecurityException() throws Exception { + var result = runWithAgent(BLOCKED_EXEC); + + assertTrue(result.finished(), "Process should complete within timeout"); + assertNotEquals(0, result.exitCode(), "BlockedExec should fail with non-zero exit code"); + assertTrue(result.output().contains("SecurityException"), + "Output should contain SecurityException. Got: " + result.output()); + } + + @Test + void allowedPackageCanExecute() throws Exception { + var result = runWithAgent(SIMPLE_APP); + + assertTrue(result.finished(), "Process should complete within timeout"); + assertEquals(0, result.exitCode(), "SimpleApp should succeed. Output: " + result.output()); + } + + @Test + void capabilityLoggingOccurs() throws Exception { + var result = runWithAgent(SIMPLE_APP); + + assertTrue(result.output().contains("[CAPSLOCK]"), + "Capability usage should be logged. Got: " + result.output()); + } +} \ No newline at end of file diff --git a/examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java b/examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java deleted file mode 100644 index 69e334f..0000000 --- a/examples/quick-test/src/test/java/com/test/PolicyEnforcementTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.test; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for runtime policy enforcement. - * These tests spawn separate JVMs with the agent attached to verify behavior. - */ -class PolicyEnforcementTest { - - private static String agentJar; - private static String policyFile; - private static String testClasses; - private static String javaHome; - - @BeforeAll - static void setup() { - agentJar = System.getProperty("agent.jar"); - policyFile = System.getProperty("policy.file"); - testClasses = System.getProperty("test.classes"); - javaHome = System.getProperty("java.home"); - - assertNotNull(agentJar, "agent.jar system property must be set"); - assertNotNull(policyFile, "policy.file system property must be set"); - assertNotNull(testClasses, "test.classes system property must be set"); - } - - @Test - void blockedPackageThrowsSecurityException() throws Exception { - ProcessBuilder pb = new ProcessBuilder( - javaHome + "/bin/java", - "-javaagent:" + agentJar + "=" + policyFile, - "-cp", testClasses, - "com.test.blocked.BlockedExec" - ); - pb.redirectErrorStream(true); - - Process process = pb.start(); - String output = new BufferedReader(new InputStreamReader(process.getInputStream())) - .lines().collect(Collectors.joining("\n")); - - boolean finished = process.waitFor(30, TimeUnit.SECONDS); - assertTrue(finished, "Process should complete within timeout"); - - int exitCode = process.exitValue(); - assertNotEquals(0, exitCode, "BlockedExec should fail with non-zero exit code"); - assertTrue(output.contains("SecurityException"), - "Output should contain SecurityException. Got: " + output); - } - - @Test - void allowedPackageCanExecute() throws Exception { - ProcessBuilder pb = new ProcessBuilder( - javaHome + "/bin/java", - "-javaagent:" + agentJar + "=" + policyFile, - "-cp", testClasses, - "com.test.SimpleApp" - ); - pb.redirectErrorStream(true); - - Process process = pb.start(); - String output = new BufferedReader(new InputStreamReader(process.getInputStream())) - .lines().collect(Collectors.joining("\n")); - - boolean finished = process.waitFor(30, TimeUnit.SECONDS); - assertTrue(finished, "Process should complete within timeout"); - - int exitCode = process.exitValue(); - assertEquals(0, exitCode, "SimpleApp should succeed. Output: " + output); - } - - @Test - void capabilityLoggingOccurs() throws Exception { - ProcessBuilder pb = new ProcessBuilder( - javaHome + "/bin/java", - "-javaagent:" + agentJar + "=" + policyFile, - "-cp", testClasses, - "com.test.SimpleApp" - ); - pb.redirectErrorStream(true); - - Process process = pb.start(); - String output = new BufferedReader(new InputStreamReader(process.getInputStream())) - .lines().collect(Collectors.joining("\n")); - - process.waitFor(30, TimeUnit.SECONDS); - - assertTrue(output.contains("[CAPSLOCK]"), - "Capability usage should be logged. Got: " + output); - } -} \ No newline at end of file diff --git a/examples/quick-test/test-policy.sh b/examples/quick-test/test-policy.sh index ec9b5d1..3f26ed3 100755 --- a/examples/quick-test/test-policy.sh +++ b/examples/quick-test/test-policy.sh @@ -7,7 +7,7 @@ CLASSES="target/classes" # Test 1: BlockedExec should FAIL with SecurityException echo "Test 1: BlockedExec should be blocked..." -OUTPUT=$(java -javaagent:$AGENT_JAR=$POLICY -cp $CLASSES com.test.blocked.BlockedExec 2>&1 || true) +OUTPUT=$(java -javaagent:$AGENT_JAR=$POLICY -cp $CLASSES com.github.serj.jcapslock.examples.quicktest.blocked.BlockedExec 2>&1 || true) if ! echo "$OUTPUT" | grep -q "SecurityException"; then echo "FAIL: BlockedExec should have thrown SecurityException" echo "Output was:" @@ -18,7 +18,7 @@ echo "PASS: BlockedExec was blocked with SecurityException" # Test 2: SimpleApp should PASS and show [CAPSLOCK] logging echo "Test 2: SimpleApp should be allowed and log capabilities..." -OUTPUT=$(java -javaagent:$AGENT_JAR=$POLICY -cp $CLASSES com.test.SimpleApp 2>&1) +OUTPUT=$(java -javaagent:$AGENT_JAR=$POLICY -cp $CLASSES com.github.serj.jcapslock.examples.quicktest.SimpleApp 2>&1) if ! echo "$OUTPUT" | grep -q "\[CAPSLOCK\]"; then echo "FAIL: Expected [CAPSLOCK] logging output for capability monitoring" echo "Output was:" From 7f439f6c569460bfa1facf99372fbf27cac61d5f Mon Sep 17 00:00:00 2001 From: Sergej Dechand Date: Sat, 13 Dec 2025 20:45:02 +0100 Subject: [PATCH 6/6] Fix tests and minor issues --- .../serj/jcapslock/agent/CapabilityTransformer.java | 6 +++++- core/pom.xml | 12 ++---------- pom.xml | 12 +----------- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java index 86ed3ec..90a6dfd 100644 --- a/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java +++ b/agent/src/main/java/com/github/serj/jcapslock/agent/CapabilityTransformer.java @@ -1,7 +1,11 @@ package com.github.serj.jcapslock.agent; import com.github.serj.jcapslock.capability.CapabilityMapper; -import org.objectweb.asm.*; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; import org.objectweb.asm.commons.AdviceAdapter; import java.lang.instrument.ClassFileTransformer; diff --git a/core/pom.xml b/core/pom.xml index 70e553c..827d291 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -17,20 +17,12 @@ Core capability analysis engine for Java packages - + org.ow2.asm asm - - org.ow2.asm - asm-tree - - - org.ow2.asm - asm-analysis - - + com.fasterxml.jackson.core diff --git a/pom.xml b/pom.xml index 11be181..a9e546c 100644 --- a/pom.xml +++ b/pom.xml @@ -15,8 +15,8 @@ core core/analysis - maven-plugin agent + maven-plugin examples @@ -48,16 +48,6 @@ asm ${asm.version} - - org.ow2.asm - asm-tree - ${asm.version} - - - org.ow2.asm - asm-analysis - ${asm.version} - org.ow2.asm asm-commons