From 04f8378af6a5e45bad41a4fa8c09b7a84ee9b3d3 Mon Sep 17 00:00:00 2001 From: Sergej Dechand Date: Fri, 12 Dec 2025 23:38:28 +0100 Subject: [PATCH 1/3] 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/3] 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/3] 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) {