diff --git a/.github/workflows/bin-solr-test.yml b/.github/workflows/bin-solr-test.yml index 0e7369ad2cce..d4418340bcc4 100644 --- a/.github/workflows/bin-solr-test.yml +++ b/.github/workflows/bin-solr-test.yml @@ -19,7 +19,7 @@ jobs: name: Run Solr Script Tests runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 70 steps: - name: Checkout code diff --git a/AGENTS.md b/AGENTS.md index d53f75fe777e..46843a339e2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ While README.md and CONTRIBUTING.md are mainly written for humans, this file is - Use the project's custom `EnvUtils` to read system properties. It auto converts env.var SOLR_FOO_BAR to system property solr.foo.bar - Be careful to not add non-essential logging! If you add slf4j log calls, make sure to wrap debug/trace level calls in `logger.isXxxEnabled()` clause - Validate user input. For file paths, always call `myCoreContainer.assertPathAllowed(myPath)` before using +- Do not use Fully Qualified Class Names (FQCNs) in code unless absolutely necessary; do use imports. ## Running Tests @@ -63,7 +64,7 @@ While README.md and CONTRIBUTING.md are mainly written for humans, this file is - For major or breaking changes, add a prominent note in reference guide major-changes-in-solr-X.adoc - Always consider whether a reference-guide page needs updating due to the new/changed features. Target audience is end user - For changes to build system and other developer-focused changes, consider updating or adding docs in dev-docs/ folder -- Keep all documentation including javadoc concise +- Keep all documentation including javadoc concise and not overly verbose, do not add a comment if it states the obvious. - New classes should have some javadocs - Changes should not have code comments communicating the change, which are instead great comments to leave for code review / commentary diff --git a/NOTICE.txt b/NOTICE.txt index a99704fd7ab6..0d3aaceed2bd 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -652,3 +652,18 @@ The S3 Output Stream is based on ASL 2.0 reference implementations found at: https://github.com/confluentinc/kafka-connect-storage-cloud/blob/5.0.x/kafka-connect-s3/src/main/java/io/confluent/connect/s3/storage/S3OutputStream.java This files resides at: modules/s3-repository/src/java/org/apache/solr/s3/S3OutputStream.java + +========================================================================= +== java agent notice == +========================================================================= + +This project contains source files in the "solr/agent-sm" module that were copied and adapted from the +OpenSearch project (https://github.com/opensearch-project/OpenSearch), git tag "3.6.0", originally +licensed under the Apache License 2.0. + +The following files were adapted from the interceptor classes in libs/agent-sm/agent/: + FileInterceptor.java, SocketChannelInterceptor.java, SystemExitInterceptor.java, + RuntimeHaltInterceptor.java, StackCallerClassChainExtractor.java + +The following files were adapted from the policy parser in libs/agent-sm/agent-policy/: + PolicyFileParser.java, PolicyToken.java, PolicyTokenStream.java diff --git a/changelog/unreleased/SOLR-17767-java-agent.yml b/changelog/unreleased/SOLR-17767-java-agent.yml new file mode 100644 index 000000000000..f8985936abac --- /dev/null +++ b/changelog/unreleased/SOLR-17767-java-agent.yml @@ -0,0 +1,9 @@ +title: > + Introduce a Java agent (solr-agent-sm) that enforces file access, outbound network, + System.exit(), and process-spawn controls via ByteBuddy instrumentation. +type: added +authors: + - name: Jan Høydahl +links: + - name: SOLR-17767 + url: https://issues.apache.org/jira/browse/SOLR-17767 diff --git a/dev-docs/security-agent.adoc b/dev-docs/security-agent.adoc new file mode 100644 index 000000000000..b0981ef185f8 --- /dev/null +++ b/dev-docs/security-agent.adoc @@ -0,0 +1,106 @@ += Security Agent Developer Guide +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 document is for Solr developers who need to work with or extend the `solr/agent-sm` security agent. + +== Module Overview + +`solr/agent-sm` is a standalone Gradle subproject that produces a Java agent JAR. +The agent must be a standalone JAR (not a Solr module) because it is loaded by the JVM before Solr initializes. +It intercepts bytecode using ByteBuddy and enforces a policy derived from JDK-style `.policy` files. + +Key classes: + +* `SolrAgentEntryPoint` — `premain()` and `agentmain()` entry points; wires all ByteBuddy interceptors +* `PolicyLoader` — parses `.policy` files and performs variable substitution +* `AgentPolicy` — immutable singleton holding the merged policy +* `StackCallerClassChainExtractor` — `StackWalker`-based call chain analysis (virtual-thread safe) +* `SecurityViolationLogger` — emits structured SLF4J log entries for violations +* `ViolationMetricsReporter` — `LongAdder` counters per violation type; deferred registration with `SolrMetricManager` + +== Adding an Approved Exit Caller + +To allow a new class to call `System.exit()` or `Runtime.halt()`, add it to the approved callers list in `AgentPolicy`. +The list is populated from the policy file using `java.lang.RuntimePermission "exitVM"` entries. +Add an entry to `agent-security.policy`: + +[source,text] +---- +grant codeBase "file:${solr.install.dir}/..." { + permission java.lang.RuntimePermission "exitVM"; +}; +---- + +Alternatively, add the class name to the default approved list in `AgentPolicy` if it is always a trusted Solr internal class. + +== Adding an Approved Exec Caller + +The default exec allow-list is empty — `ProcessBuilder.start()` and `Runtime.exec()` are blocked for all callers. +To permit a specific class, add a `java.lang.RuntimePermission "exec"` entry to the policy or the `AgentPolicy` defaults. +Document the reason in a code comment. + +== Adding a Trusted Filesystem Scheme + +Paths on certain virtual filesystems (e.g., `jrt:/` for JDK resources) are excluded from file policy checks. +To add a trusted scheme, extend the `trustedFileSystems` set in `AgentPolicy`. +Trusted schemes bypass all file access checks. + +== Deferred Metrics Registration + +The agent starts before Solr and begins counting violations immediately via `ViolationMetricsReporter`. +Once `CoreContainer` initializes, it calls `ViolationMetricsReporter.registerWithSolrMetrics()` via reflection (so `solr:core` has no compile-time dependency on `solr:agent-sm`). +Counts accumulated before registration are preserved in the `LongAdder` instances and become visible in metrics after registration. + +If you change the `registerWithSolrMetrics` method signature, update the reflective call in `CoreContainer.java` to match. + +== Writing Tests Alongside the Agent + +Tests for `solr:agent-sm` extend `SolrTestCase` (not `SolrTestCaseJ4`). + +The agent-sm test suite runs with `SOLR_SECURITY_AGENT_MODE=enforce` so that blocking behaviour is tested directly. +The broader Solr test suite runs in `warn` mode — violations are logged but do not fail tests. + +To run only the agent-sm tests: + +[source,bash] +---- +./gradlew :solr:agent-sm:test +---- + +To reproduce a flaky test with a specific randomization seed: + +[source,bash] +---- +./gradlew :solr:agent-sm:test --tests "org.apache.solr.security.agent.SolrAgentIntegrationTest" -Ptests.seed=HEXSEEDHERE +---- + +== Flipping the Broader Test Suite to Enforce Mode + +The broader Solr test suite currently runs in warn mode. +Before flipping to enforce mode: + +1. Run the full suite in warn mode and collect all `SECURITY VIOLATION` log entries. +2. Triage each violation — legitimate Solr operations need policy entries; plugin violations need plugin fixes or policy entries. +3. Update `agent-security.policy` or test-specific extra-policy files as needed. + +== No Compile-Time Dependency from solr:core on solr:agent-sm + +`solr:core` must not declare a compile-time dependency on `solr:agent-sm`. +The agent JAR is loaded by the JVM bootstrap classloader before application classloaders exist. +All interaction from `solr:core` must go through reflection (`Class.forName` with bootstrap classloader). +See `CoreContainer.java` for the pattern. diff --git a/gradle/validation/rat-sources.gradle b/gradle/validation/rat-sources.gradle index f8556d2ffa4f..92c573c29f6f 100644 --- a/gradle/validation/rat-sources.gradle +++ b/gradle/validation/rat-sources.gradle @@ -92,6 +92,12 @@ allprojects { // Exclude github stuff (templates, workflows). exclude ".github" + // Exclude speckit planning artifacts and Claude Code tooling. + exclude "specs/**" + exclude ".claude/**" + exclude ".specify/**" + exclude "CLAUDE.md" + exclude "dev-tools/scripts/cloud.sh" exclude "dev-tools/scripts/README.md" exclude "dev-tools/scripts/create_line_file_docs.py" @@ -145,6 +151,11 @@ allprojects { exclude "src/test-files/**/*-snippet-*.xml" break + case ":solr:agent-sm": + // Test-programs are tiny standalone programs for BATS tests, not shipped artifacts. + exclude "src/test-programs/**" + break + case ":solr:server": exclude "**/*.xml" exclude "**/*.sh" diff --git a/settings.gradle b/settings.gradle index 782edec43251..a8f48ac9883d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,6 +36,7 @@ rootProject.name = "solr-root" includeBuild("build-tools/missing-doclet") include ":platform" +include "solr:agent-sm" include "solr:api" include "solr:solrj" include "solr:solrj-jetty" diff --git a/solr/agent-sm/build.gradle b/solr/agent-sm/build.gradle new file mode 100644 index 000000000000..a29e0e661c0d --- /dev/null +++ b/solr/agent-sm/build.gradle @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +apply plugin: 'java-library' + +description = 'Apache Solr Security Agent' + +dependencies { + // ByteBuddy references SpotBugs annotations in its class files; needed to suppress -Xlint:classfile + // Not included in the release so exclude from license/checksum checks. + compileOnly libs.spotbugs.annotations + + // !! DO NOT ADD SLF4J OR OTHER LOGGING JARS !! + // They are on Boot-Class-Path and initialise the logging binding before Log4j2 loads, + // permanently caching a NOP logger and preventing solr.log from being written. + + // ByteBuddy for bytecode instrumentation - bundled into the fat agent JAR + implementation libs.bytebuddy + // byte-buddy-agent is the Java agent JAR itself; not directly imported in compiled classes + // but required at runtime for agent loading and bundled into the fat JAR. + implementation libs.bytebuddy.agent + permitUnusedDeclared libs.bytebuddy.agent + + + // Test dependencies - NOT bundled + testImplementation project(':solr:test-framework') + testImplementation project(':solr:core') + testImplementation libs.apache.lucene.testframework + testImplementation libs.junit.junit + testImplementation libs.carrotsearch.randomizedtesting.runner + testImplementation libs.hamcrest.hamcrest + // These are used indirectly via SolrTestCase (test-framework) and randomized runner base classes; + // the static analyzer does not see direct usage in agent-sm test sources. + permitTestUnusedDeclared project(':solr:core') + permitTestUnusedDeclared libs.apache.lucene.testframework + permitTestUnusedDeclared libs.carrotsearch.randomizedtesting.runner + permitTestUnusedDeclared libs.hamcrest.hamcrest +} + +// Build a self-contained fat JAR so the agent can run before Solr's classpath is set up. +// ByteBuddy is bundled; Solr classes are NOT included. +// +// Build a fat JAR so all agent classes (and ByteBuddy) are available on Boot-Class-Path before +// the application main class loads. SLF4J is intentionally excluded from this JAR's dependencies: +// placing SLF4J on Boot-Class-Path would initialise it before Log4j2 is loaded, permanently +// caching a NOP logger and preventing solr.log from being created. +jar { + manifest { + attributes( + 'Premain-Class': 'org.apache.solr.security.agent.SolrAgentEntryPoint', + 'Agent-Class': 'org.apache.solr.security.agent.SolrAgentEntryPoint', + 'Can-Redefine-Classes': 'true', + 'Can-Retransform-Classes': 'true', + 'Boot-Class-Path': archiveFileName + ) + } + + // Bundle runtime dependencies (ByteBuddy) into the fat agent JAR. + // Test dependencies are excluded because they are not on runtimeClasspath. + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +// Copy the built agent JAR to server/lib/ext/ so startup scripts can find it at +// ${SOLR_SERVER_DIR}/lib/ext/solr-agent-sm-*.jar, and so the dev distribution works. +def agentJarTarget = file("${rootDir}/solr/server/lib/ext") + +task copyAgentJar(type: Copy, dependsOn: jar) { + description = 'Copies the agent fat JAR to solr/server/lib/ext/ for startup-script detection' + group = 'build' + from jar.outputs + into agentJarTarget + // Keep only the current version; remove stale versions on each build. + doFirst { + delete fileTree(agentJarTarget) { include 'solr-agent-sm-*.jar' } + } +} + +// spotbugs-annotations is compileOnly and not included in the release. +configurations.jarValidation { + exclude group: "com.github.spotbugs", module: "spotbugs-annotations" +} + +assemble.dependsOn copyAgentJar + +// --------------------------------------------------------------------------- +// Test programs — standalone Java programs compiled for BATS integration tests. +// These have no Solr/ByteBuddy dependencies; pure JDK APIs only. +// --------------------------------------------------------------------------- +sourceSets { + testPrograms { + java.srcDirs = ['src/test-programs/java'] + } +} + +// These are tiny standalone programs with no annotation-processor needs. +// Disabling annotation processing prevents the auto-created testProgramsAnnotationProcessor +// configuration from pulling in error-prone and friends, which are not in the lock file. +tasks.named('compileTestProgramsJava') { + options.annotationProcessorPath = files() +} + +// Error-prone must also be disabled for compileTestProgramsJava: its Gradle plugin uses +// tasks.withType(JavaCompile) and would inject -Xplugin:ErrorProne, but the +// testProgramsAnnotationProcessor configuration has no errorprone JAR on it. +plugins.withId(libs.plugins.ltgt.errorprone.get().pluginId) { + tasks.named('compileTestProgramsJava') { + options.errorprone.enabled = false + } +} + +task testProgramsJar(type: Jar) { + description = 'JAR of standalone violation programs used by BATS integration tests' + group = 'build' + archiveClassifier = 'test-programs' + from sourceSets.testPrograms.output +} + diff --git a/solr/agent-sm/gradle.lockfile b/solr/agent-sm/gradle.lockfile new file mode 100644 index 000000000000..043ae4fd3554 --- /dev/null +++ b/solr/agent-sm/gradle.lockfile @@ -0,0 +1,168 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.8.4=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.carrotsearch:hppc:0.10.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.jackson.core:jackson-annotations:2.21=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.jackson.core:jackson-core:2.21.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.jackson.core:jackson-databind:2.21.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.21.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.21.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.21.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.jackson:jackson-bom:2.21.2=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.fasterxml.woodstox:woodstox-core:7.0.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,errorprone,jarValidation,permitTestUnusedDeclared,testAnnotationProcessor,testRuntimeClasspath,testRuntimeClasspathCopy +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,errorprone,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,compileOnlyHelper +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,errorprone,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.11.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,errorprone,testAnnotationProcessor +com.google.errorprone:error_prone_annotation:2.41.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.google.errorprone:error_prone_annotations:2.43.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.41.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.41.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.27.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,errorprone,jarValidation,permitTestUnusedDeclared,testAnnotationProcessor,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.google.guava:guava:33.5.0-jre=annotationProcessor,errorprone,jarValidation,permitTestUnusedDeclared,testAnnotationProcessor,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,errorprone,jarValidation,permitTestUnusedDeclared,testAnnotationProcessor,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,errorprone,jarValidation,permitTestUnusedDeclared,testAnnotationProcessor,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +com.google.protobuf:protobuf-java:3.25.8=annotationProcessor,errorprone,testAnnotationProcessor +com.j256.simplemagic:simplemagic:1.17=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.jayway.jsonpath:json-path:2.9.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +com.tdunning:t-digest:3.3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +commons-cli:commons-cli:1.11.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +commons-codec:commons-codec:1.21.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +commons-io:commons-io:2.21.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.dropwizard.metrics:metrics-core:3.2.5=testRuntimeClasspathCopy +io.dropwizard.metrics:metrics-core:4.2.33=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor +io.netty:netty-buffer:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-codec-base:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-common:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-handler:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-resolver:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-tcnative-boringssl-static:2.0.75.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-tcnative-classes:2.0.75.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-transport-classes-epoll:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-transport-native-epoll:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-transport-native-unix-common:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.netty:netty-transport:4.2.12.Final=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator:2.22.0-alpha=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:2.22.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry-java17:2.22.0-alpha=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry-java8:2.22.0-alpha=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-api-incubator:1.56.0-alpha=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-api:1.56.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-common:1.56.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-context:1.56.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-exporter-prometheus:1.56.0-alpha=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-sdk-common:1.56.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-sdk-metrics:1.56.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-sdk-trace:1.56.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.opentelemetry:opentelemetry-sdk:1.56.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.prometheus:prometheus-metrics-exposition-formats:1.1.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.prometheus:prometheus-metrics-model:1.1.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.sgr:s2-geometry-library-java:1.0.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +io.swagger.core.v3:swagger-annotations-jakarta:2.2.22=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +jakarta.activation:jakarta.activation-api:2.1.3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +jakarta.annotation:jakarta.annotation-api:3.0.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +jakarta.inject:jakarta.inject-api:2.0.1=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +jakarta.servlet:jakarta.servlet-api:6.1.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +jakarta.validation:jakarta.validation-api:3.1.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor +junit:junit:4.13.2=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +net.bytebuddy:byte-buddy-agent:1.18.8-jdk5=compileClasspath,compileClasspathCopy,jarValidation,permitUnusedDeclared,runtimeClasspath,runtimeClasspathCopy,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +net.bytebuddy:byte-buddy:1.18.8-jdk5=compileClasspath,compileClasspathCopy,jarValidation,runtimeClasspath,runtimeClasspathCopy,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.antlr:antlr4-runtime:4.13.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.commons:commons-exec:1.6.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.commons:commons-lang3:3.20.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.commons:commons-math3:3.6.1=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.curator:curator-client:5.9.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.curator:curator-framework:5.9.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.curator:curator-test:5.9.0=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.httpcomponents:httpclient:4.5.14=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.httpcomponents:httpcore:4.4.16=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.httpcomponents:httpmime:4.5.14=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.logging.log4j:log4j-api:2.25.3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.logging.log4j:log4j-core:2.25.3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.logging.log4j:log4j-slf4j2-impl:2.25.3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-analysis-common:10.4.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-analysis-kuromoji:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-analysis-nori:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-analysis-phonetic:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-backward-codecs:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-classification:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-codecs:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-core:10.4.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-expressions:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-facet:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-grouping:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-highlighter:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-join:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-memory:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-misc:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-queries:10.4.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-queryparser:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-sandbox:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-spatial-extras:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-spatial3d:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-suggest:10.4.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.lucene:lucene-test-framework:10.4.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.zookeeper:zookeeper-jute:3.9.4=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apache.zookeeper:zookeeper:3.9.4=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.apiguardian:apiguardian-api:1.1.2=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.codehaus.woodstox:stax2-api:4.2.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty.ee10:jetty-ee10-servlet:12.0.34=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty.http2:jetty-http2-client-transport:12.0.34=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty.http2:jetty-http2-client:12.0.34=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty.http2:jetty-http2-common:12.0.34=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty.http2:jetty-http2-hpack:12.0.34=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty.http2:jetty-http2-server:12.0.34=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-alpn-client:12.0.34=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-alpn-java-client:12.0.34=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-alpn-java-server:12.0.34=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-alpn-server:12.0.34=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-client:12.0.34=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-http:12.0.34=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-io:12.0.34=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-rewrite:12.0.34=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-security:12.0.34=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-server:12.0.34=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-session:12.0.34=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.eclipse.jetty:jetty-util:12.0.34=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.hk2.external:aopalliance-repackaged:4.0.0-M3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.hk2:hk2-api:4.0.0-M3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.hk2:hk2-locator:4.0.0-M3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.hk2:hk2-utils:4.0.0-M3=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.hk2:osgi-resource-locator:3.0.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey.containers:jersey-container-jetty-http:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey.core:jersey-client:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey.core:jersey-common:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey.core:jersey-server:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey.ext:jersey-entity-filtering:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey.inject:jersey-hk2:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey.media:jersey-media-json-jackson:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.glassfish.jersey:jersey-bom:4.0.2=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.hamcrest:hamcrest:3.0=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.javassist:javassist:3.30.2-GA=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.jspecify:jspecify:1.0.0=annotationProcessor,errorprone,jarValidation,permitTestUnusedDeclared,testAnnotationProcessor,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.junit.jupiter:junit-jupiter-api:5.6.2=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.junit.platform:junit-platform-commons:1.6.2=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.junit:junit-bom:5.14.0=compileOnlyHelper +org.junit:junit-bom:5.6.2=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.locationtech.spatial4j:spatial4j:0.8=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.opentest4j:opentest4j:1.2.0=jarValidation,testRuntimeClasspath,testRuntimeClasspathCopy +org.ow2.asm:asm-commons:9.8=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.ow2.asm:asm-tree:9.8=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.ow2.asm:asm:9.8=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.pcollections:pcollections:4.0.1=annotationProcessor,errorprone,testAnnotationProcessor +org.semver4j:semver4j:6.0.0=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.slf4j:jcl-over-slf4j:2.0.17=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +org.slf4j:slf4j-api:2.0.17=jarValidation,permitTestUnusedDeclared,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +org.xerial.snappy:snappy-java:1.1.10.8=jarValidation,permitTestUnusedDeclared,testRuntimeClasspath,testRuntimeClasspathCopy +empty=apiHelper,apiHelperTest,apiHelperTestPrograms,compileOnlyHelperTest,compileOnlyHelperTestPrograms,missingdoclet,permitAggregatorUse,permitTestAggregatorUse,permitTestProgramsAggregatorUse,permitTestProgramsUnusedDeclared,permitTestProgramsUsedUndeclared,permitTestUsedUndeclared,permitUsedUndeclared,testProgramsAnnotationProcessor,testProgramsCompileClasspath,testProgramsRuntimeClasspath diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/AgentPolicy.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/AgentPolicy.java new file mode 100644 index 000000000000..716a636d25a7 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/AgentPolicy.java @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.solr.security.agent; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Immutable singleton holding the active security policy for the Solr JVM. + * + *
Loaded once at startup by {@link PolicyLoader}; replacing it throws {@link SecurityException}.
+ * Stored as {@code static volatile} so it is visible to the bootstrap classloader used by the agent
+ * JAR.
+ */
+public final class AgentPolicy {
+
+ /** Whether violations block the operation or are merely logged. */
+ public enum EnforcementMode {
+ /** Violations are logged at WARN level; the operation is allowed to proceed. */
+ WARN,
+ /** Violations are logged at ERROR level and blocked with a {@link SecurityException}. */
+ ENFORCE
+ }
+
+ // Singleton holder — set once at premain; never null after initialization.
+ private static volatile AgentPolicy instance;
+
+ private final List Class-name matching ({@link #codeBase()} is {@code null}): {@code "*"} matches any class; a
+ * pattern ending in {@code ".*"} matches that package and sub-packages; otherwise exact match.
+ * codeBase matching: the calling class must have been loaded from a location matching the JDK
+ * policy {@code codeBase} URL ({@code file:/path/-} recursive, {@code file:/path/to.jar} exact).
+ */
+public record ApprovedCallSite(
+ String classNamePattern, String codeBase, Operation operation, PolicySource source) {
+
+ /** The restricted operation covered by this approval. */
+ public enum Operation {
+ EXIT,
+ EXEC
+ }
+
+ ApprovedCallSite(String classNamePattern, Operation operation, PolicySource source) {
+ this(classNamePattern, null, operation, source);
+ }
+
+ /**
+ * Returns {@code true} if {@code className} matches the pattern; {@code false} for codeBase
+ * entries.
+ */
+ public boolean matches(String className) {
+ if (codeBase != null) return false; // codeBase entries must be checked via matchesCodeBase
+ if ("*".equals(classNamePattern)) return true;
+ if (classNamePattern.endsWith(".*")) {
+ String prefix = classNamePattern.substring(0, classNamePattern.length() - 2);
+ return className.equals(prefix) || className.startsWith(prefix + ".");
+ }
+ return classNamePattern.equals(className);
+ }
+
+ /**
+ * Returns {@code true} if {@code cls} was loaded from this entry's codeBase; {@code false} for
+ * class-name entries.
+ */
+ public boolean matchesCodeBase(Class> cls) {
+ if (codeBase == null) return false;
+ return SocketChannelInterceptor.isCallerFromCodeBase(List.of(cls), codeBase);
+ }
+}
diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/FileInterceptor.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/FileInterceptor.java
new file mode 100644
index 000000000000..f6ebe6442d53
--- /dev/null
+++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/FileInterceptor.java
@@ -0,0 +1,267 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.Collection;
+import java.util.Set;
+import net.bytebuddy.asm.Advice;
+
+/**
+ * ByteBuddy {@link Advice} interceptor for file-system operations.
+ *
+ * This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+public class FileInterceptor {
+
+ /** FileInterceptor */
+ public FileInterceptor() {}
+
+ /**
+ * Guard to prevent re-entrant calls: set to {@code true} while symlink resolution is in progress
+ * on the current thread, so that any file operation triggered by {@code toRealPath()} is not
+ * intercepted again (which would cause infinite recursion).
+ */
+ static final ThreadLocal Entries are loaded at startup and are immutable thereafter. An entry permits outbound
+ * connections to a host-and-port pair. The {@code hostPort} string follows JDK {@code
+ * SocketPermission} syntax: {@code "host:port"}, {@code "*:8983"} (wildcard host), {@code
+ * "host:1-65535"} (port range), etc.
+ *
+ * An optional {@code codeBase} restricts the grant to code loaded from a specific JAR location.
+ * A {@code null} codeBase means the grant applies to all code.
+ */
+public record PermittedEndpoint(
+ String hostPort, String actions, String codeBase, PolicySource source) {
+ public PermittedEndpoint {
+ if (actions == null) actions = "connect,resolve";
+ }
+}
diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PermittedPath.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PermittedPath.java
new file mode 100644
index 000000000000..041bebf2c163
--- /dev/null
+++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PermittedPath.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * A single file-system access rule from the security policy.
+ *
+ * Rules are loaded at startup and are immutable thereafter. A rule grants access to a path (and
+ * optionally all descendants) for the set of operations encoded in the {@code actions} string
+ * ({@code "read"}, {@code "write"}, {@code "delete"}, or any comma-separated combination).
+ */
+public final class PermittedPath {
+
+ private final String path;
+ private final Set Uses {@link StreamTokenizer} which natively handles {@code //} line comments, {@code /* *\/}
+ * block comments, and quoted strings — no regex involved.
+ *
+ * This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+class PolicyFileParser {
+
+ private PolicyFileParser() {}
+
+ record GrantEntry(String codeBase, List Throws {@link ExpandException} for any unresolved placeholder — fail-fast behaviour so that a
+ * misconfigured policy (missing system property) is caught at startup rather than silently
+ * resulting in an overly-permissive or broken policy.
+ *
+ * Special cases:
+ *
+ * This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+class PolicyPropertyExpander {
+
+ private PolicyPropertyExpander() {}
+
+ /** Thrown when a {@code ${property}} placeholder cannot be resolved. */
+ static class ExpandException extends Exception {
+ ExpandException(String message) {
+ super(message);
+ }
+ }
+
+ // Matches ${{escaped}} (literal pass-through) or ${normal} (property lookup)
+ private static final Pattern PLACEHOLDER_PATTERN =
+ Pattern.compile("\\$\\{\\{(? This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+class PolicyTokenStream {
+ private final StreamTokenizer tokenizer;
+ private final Deque Identical logic to {@link SystemExitInterceptor}; registered separately because ByteBuddy
+ * requires one advice class per instrumented method.
+ *
+ * This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+public class RuntimeHaltInterceptor {
+
+ private RuntimeHaltInterceptor() {}
+
+ @Advice.OnMethodEnter
+ public static void intercept(@Advice.Argument(0) int code) throws Exception {
+ if (!AgentPolicy.isInitialized()) return;
+ final AgentPolicy policy = AgentPolicy.getInstance();
+
+ final StackWalker walker = StackWalker.getInstance(Option.RETAIN_CLASS_REFERENCE);
+ final Class> caller = walker.getCallerClass();
+ final Collection In {@link AgentPolicy.EnforcementMode#WARN warn mode} entries are emitted and the operation
+ * proceeds. In {@link AgentPolicy.EnforcementMode#ENFORCE enforce mode} the operation is blocked.
+ *
+ * During early startup violations go to {@code System.err}. Once {@code
+ * AgentViolationBridge.wire()} is called from {@code CoreContainer}, the {@link #reporter} bridge
+ * routes them to {@code solr.log} at {@code WARN} level.
+ */
+public final class SecurityViolationLogger {
+
+ private static volatile Consumer This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+public class SocketChannelInterceptor {
+
+ public SocketChannelInterceptor() {}
+
+ @Advice.OnMethodEnter
+ public static void intercept(@Advice.AllArguments Object[] args) throws Exception {
+ if (!AgentPolicy.isInitialized()) return;
+ final AgentPolicy policy = AgentPolicy.getInstance();
+
+ final StackWalker walker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE);
+ final String caller = walker.getCallerClass().getName();
+
+ if (args[0] instanceof InetSocketAddress address) {
+ if (!policy.trustedHosts().contains(address.getHostString())) {
+ enforceNetworkAccess(policy, address.getHostString(), address.getPort(), caller);
+ }
+ } else if (args[0] instanceof UnixDomainSocketAddress) {
+ // Unix domain socket — local IPC, always allow
+ } else if (args[0] != null) {
+ // Unknown SocketAddress subclass — fail closed (host/port unknown, cannot consult policy)
+ final String target = args[0].toString();
+ ViolationMetricsReporter.incrementNetwork();
+ SecurityViolationLogger.log(
+ SecurityViolationLogger.ViolationType.NETWORK_CONNECT,
+ target,
+ caller,
+ policy.enforcementMode());
+ if (policy.enforcementMode() == AgentPolicy.EnforcementMode.ENFORCE) {
+ throw new SecurityException(
+ "Outbound network connection denied (unknown address type): " + target);
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Shared enforcement helper
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Checks the policy for {@code host:port}; logs and throws {@link SecurityException} in enforce
+ * mode.
+ */
+ public static void enforceNetworkAccess(
+ AgentPolicy policy, String host, int port, String caller) {
+ if (!isEndpointPermitted(policy, host, port)) {
+ String target = host + ":" + port;
+ ViolationMetricsReporter.incrementNetwork();
+ SecurityViolationLogger.log(
+ SecurityViolationLogger.ViolationType.NETWORK_CONNECT,
+ target,
+ caller,
+ policy.enforcementMode());
+ if (policy.enforcementMode() == AgentPolicy.EnforcementMode.ENFORCE) {
+ throw new SecurityException(
+ "Outbound network connection denied by Solr security agent: " + target);
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Endpoint matching helpers
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Returns {@code true} if the policy permits an outbound connection to {@code host:port}.
+ * Matching rules: {@code *:port} — any host on that port; {@code host:low-high} — port range;
+ * {@code *} — everything. codeBase-restricted entries are checked lazily via {@link StackWalker}.
+ */
+ public static boolean isEndpointPermitted(AgentPolicy policy, String host, int port) {
+ Collection This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+public final class StackCallerClassChainExtractor
+ implements Function This local copy mirrors the semantics of {@code org.apache.solr.common.util.SuppressForbidden}
+ * and {@code org.apache.lucene.util.SuppressForbidden}. The Gradle forbidden-apis plugin matches
+ * any annotation named {@code SuppressForbidden} regardless of package.
+ */
+@Retention(RetentionPolicy.CLASS)
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR})
+public @interface SuppressForbidden {
+ /** Explanation of why the forbidden API is necessary at this call site. */
+ String reason();
+}
diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/SystemExitInterceptor.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/SystemExitInterceptor.java
new file mode 100644
index 000000000000..8b328ec09dc5
--- /dev/null
+++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/SystemExitInterceptor.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+import java.lang.StackWalker.Option;
+import java.util.Collection;
+import net.bytebuddy.asm.Advice;
+
+/**
+ * ByteBuddy {@link Advice} interceptor for {@link System#exit(int)}.
+ *
+ * Uses the full call-chain check: any class in the stack that matches an approved exit-caller
+ * pattern in {@link AgentPolicy} grants permission. In {@link AgentPolicy.EnforcementMode#WARN warn
+ * mode} a violation is logged but the exit is allowed to proceed; in {@link
+ * AgentPolicy.EnforcementMode#ENFORCE enforce mode} a {@link SecurityException} is thrown.
+ *
+ * This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for
+ * attribution.
+ */
+public class SystemExitInterceptor {
+
+ private SystemExitInterceptor() {}
+
+ @Advice.OnMethodEnter
+ public static void intercept(@Advice.Argument(0) int code) throws Exception {
+ if (!AgentPolicy.isInitialized()) return;
+ final AgentPolicy policy = AgentPolicy.getInstance();
+
+ final StackWalker walker = StackWalker.getInstance(Option.RETAIN_CLASS_REFERENCE);
+ final Class> caller = walker.getCallerClass();
+ final Collection {@code AgentViolationMetrics} (in {@code solr:core}) reads these counters reflectively and
+ * registers them as a single labeled OTel observable counter.
+ */
+public final class ViolationMetricsReporter {
+
+ private static final LongAdder FILE_COUNTER = new LongAdder();
+ private static final LongAdder NETWORK_COUNTER = new LongAdder();
+ private static final LongAdder EXIT_COUNTER = new LongAdder();
+ private static final LongAdder EXEC_COUNTER = new LongAdder();
+
+ private ViolationMetricsReporter() {}
+
+ public static void incrementFile() {
+ FILE_COUNTER.increment();
+ }
+
+ public static void incrementNetwork() {
+ NETWORK_COUNTER.increment();
+ }
+
+ public static void incrementExit() {
+ EXIT_COUNTER.increment();
+ }
+
+ public static void incrementExec() {
+ EXEC_COUNTER.increment();
+ }
+
+ public static long fileCount() {
+ return FILE_COUNTER.sum();
+ }
+
+ public static long networkCount() {
+ return NETWORK_COUNTER.sum();
+ }
+
+ public static long exitCount() {
+ return EXIT_COUNTER.sum();
+ }
+
+ public static long execCount() {
+ return EXEC_COUNTER.sum();
+ }
+}
diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/package-info.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/package-info.java
new file mode 100644
index 000000000000..bac66a206f0f
--- /dev/null
+++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/package-info.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * Solr Security Agent — Java Security Manager replacement via ByteBuddy instrumentation.
+ *
+ * Intercepts file access, outbound network connections, {@code System.exit()}, {@code
+ * Runtime.halt()}, and process spawning at the bytecode level, enforcing a policy loaded from
+ * JDK-style {@code .policy} files. Entry point: {@link
+ * org.apache.solr.security.agent.SolrAgentEntryPoint}. Policy: {@link
+ * org.apache.solr.security.agent.AgentPolicy} (loaded by {@link
+ * org.apache.solr.security.agent.PolicyLoader}).
+ *
+ * Advice visibility rule: Every static helper method called directly from an
+ * {@code @Advice.OnMethodEnter} body must be {@code public}. ByteBuddy inlines the advice bytecode
+ * into the target JDK method (e.g. {@code java.nio.file.Files}, {@code
+ * sun.nio.ch.SocketChannelImpl}), so the call site is in {@code java.base}. A package-private
+ * method in this package is inaccessible from there and causes {@code IllegalAccessError} at
+ * runtime.
+ *
+ * Several files ({@link org.apache.solr.security.agent.FileInterceptor}, {@link
+ * org.apache.solr.security.agent.SocketChannelInterceptor}, {@link
+ * org.apache.solr.security.agent.SystemExitInterceptor}, {@link
+ * org.apache.solr.security.agent.RuntimeHaltInterceptor}, {@link
+ * org.apache.solr.security.agent.StackCallerClassChainExtractor}, {@link
+ * org.apache.solr.security.agent.PolicyFileParser}, {@link
+ * org.apache.solr.security.agent.PolicyTokenStream}, {@link
+ * org.apache.solr.security.agent.PolicyPropertyExpander}) were derived from the OpenSearch project
+ * (Apache License 2.0). See {@code NOTICE.txt} for attribution.
+ */
+package org.apache.solr.security.agent;
diff --git a/solr/agent-sm/src/test-programs/java/ExecViolation.java b/solr/agent-sm/src/test-programs/java/ExecViolation.java
new file mode 100644
index 000000000000..6851c0ac7f97
--- /dev/null
+++ b/solr/agent-sm/src/test-programs/java/ExecViolation.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * BATS test program: spawns a child process via ProcessBuilder. The default policy grants no exec
+ * permission, so this should be blocked by ProcessExecInterceptor in enforce mode. Expected:
+ * SecurityException thrown, process exits non-zero.
+ */
+public class ExecViolation {
+ public static void main(String[] args) throws Exception {
+ System.out.println("attempting process exec");
+ new ProcessBuilder("echo", "blocked").start().waitFor();
+ System.out.println("exec succeeded -- agent did NOT block");
+ }
+}
diff --git a/solr/agent-sm/src/test-programs/java/ExitViolation.java b/solr/agent-sm/src/test-programs/java/ExitViolation.java
new file mode 100644
index 000000000000..1350ff40bea7
--- /dev/null
+++ b/solr/agent-sm/src/test-programs/java/ExitViolation.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * BATS test program: calls System.exit(123). The default policy grants no exitVM permission to
+ * anonymous codeBase, so this should be blocked by SystemExitInterceptor in enforce mode.
+ *
+ * Exit code 123 is used as a sentinel: if the process exits with that code the agent did NOT
+ * block the call. The BATS test asserts both a SecurityException in the output and that {@code
+ * $status != 123}.
+ */
+public class ExitViolation {
+ public static void main(String[] args) {
+ System.out.println("attempting System.exit");
+ System.exit(123);
+ }
+}
diff --git a/solr/agent-sm/src/test-programs/java/FileViolation.java b/solr/agent-sm/src/test-programs/java/FileViolation.java
new file mode 100644
index 000000000000..db6aaa3d7a44
--- /dev/null
+++ b/solr/agent-sm/src/test-programs/java/FileViolation.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * BATS test program: reads /etc/hosts, which is outside every path the default policy permits.
+ * Expected: SecurityException thrown by FileInterceptor in enforce mode.
+ */
+public class FileViolation {
+ public static void main(String[] args) throws Exception {
+ java.nio.file.Files.readAllBytes(java.nio.file.Path.of("/etc/hosts"));
+ System.out.println("read succeeded -- agent did NOT block");
+ }
+}
diff --git a/solr/agent-sm/src/test-programs/java/NetworkViolation.java b/solr/agent-sm/src/test-programs/java/NetworkViolation.java
new file mode 100644
index 000000000000..1f0983a53088
--- /dev/null
+++ b/solr/agent-sm/src/test-programs/java/NetworkViolation.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * BATS test program: opens a SocketChannel to 192.0.2.1:443 (TEST-NET-1, RFC 5737 — guaranteed
+ * non-routable). The address is not in the trusted-hosts set and port 443 is not in the default
+ * policy. SocketChannelInterceptor fires before any TCP I/O, so the test completes instantly.
+ * Expected: SecurityException thrown by SocketChannelInterceptor in enforce mode.
+ */
+public class NetworkViolation {
+ public static void main(String[] args) throws Exception {
+ System.out.println("attempting socket connect");
+ var addr = new java.net.InetSocketAddress("192.0.2.1", 443);
+ try (var ch = java.nio.channels.SocketChannel.open()) {
+ ch.connect(addr);
+ }
+ System.out.println("connect succeeded -- agent did NOT block");
+ }
+}
diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/InterceptorTestHelper.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/InterceptorTestHelper.java
new file mode 100644
index 000000000000..b56b922b36ab
--- /dev/null
+++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/InterceptorTestHelper.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+import java.net.InetSocketAddress;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.util.Locale;
+
+/**
+ * Test-only helpers that exercise interceptor enforcement logic without ByteBuddy instrumentation.
+ * All enforcement is delegated to the same shared methods used by the live advice.
+ */
+class InterceptorTestHelper {
+
+ private InterceptorTestHelper() {}
+
+ /**
+ * Resolves {@code path} to its real path (following symlinks), falling back to {@code
+ * normalize().toAbsolutePath()} on I/O error.
+ */
+ static String resolveRealPath(Path path) {
+ try {
+ return path.toRealPath(new LinkOption[0]).toString();
+ } catch (Exception e) {
+ return path.normalize().toAbsolutePath().toString();
+ }
+ }
+
+ /** Returns the name of the calling class for use as the {@code caller} field in violations. */
+ static String callerClassName() {
+ try {
+ return StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
+ .getCallerClass()
+ .getName();
+ } catch (Exception e) {
+ return " Verifies that:
+ *
+ * These tests exercise the policy engine and interceptor check logic end-to-end without
+ * requiring the ByteBuddy instrumentation to be active (which requires premain). They validate that
+ * permitted operations pass, denied operations throw {@link SecurityException}, and that the {@link
+ * ViolationMetricsReporter} counters increment correctly on each violation.
+ */
+public class SolrAgentIntegrationTest extends SolrTestCase {
+
+ @After
+ public void resetSingleton() {
+ AgentPolicy.resetForTesting();
+ }
+
+ private AgentPolicy buildEnforcePolicy(
+ List A symlink inside a permitted directory that points to a target outside permitted directories
+ * must be denied even though the symlink path itself would have matched a permitted rule. The
+ * interceptor resolves the real path via {@code Path.toRealPath()} before the policy check.
+ *
+ * Tests that require symlink creation are skipped on filesystems that do not support symbolic
+ * links (e.g. certain Windows configurations).
+ */
+public class SymlinkEscapeTest extends SolrTestCase {
+
+ @After
+ public void resetSingleton() {
+ AgentPolicy.resetForTesting();
+ }
+
+ private void resetSingletonSilent() {
+ AgentPolicy.resetForTesting();
+ }
+
+ private AgentPolicy buildPolicyPermitting(Path dir) {
+ resetSingletonSilent();
+ // Use the real (symlink-resolved) path so the policy matches after toRealPath() resolution
+ String realDirStr;
+ try {
+ realDirStr = dir.toRealPath().toString();
+ } catch (IOException e) {
+ realDirStr = dir.toAbsolutePath().toString();
+ }
+ PermittedPath allowed = new PermittedPath(realDirStr, "read", true, PolicySource.DEFAULT);
+ AgentPolicy policy =
+ new AgentPolicy(
+ List.of(allowed), List.of(), List.of(), List.of(), AgentPolicy.EnforcementMode.ENFORCE);
+ AgentPolicy.initialize(policy);
+ return policy;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Symlink pointing inside the permitted directory — should be allowed
+ // ---------------------------------------------------------------------------
+
+ @Test
+ public void testSymlinkInsidePermittedDirIsAllowed() throws Exception {
+ Path tmpDir = createTempDir();
+ buildPolicyPermitting(tmpDir);
+
+ Path realFile = tmpDir.resolve("real.txt");
+ Files.writeString(realFile, "data");
+ Path symlink = tmpDir.resolve("link.txt");
+
+ try {
+ Files.createSymbolicLink(symlink, realFile);
+ } catch (UnsupportedOperationException | SecurityException e) {
+ Assume.assumeTrue("Symlinks not supported or permitted in this test environment", false);
+ return;
+ }
+
+ // Symlink resolves to a path inside the permitted dir — should NOT throw
+ InterceptorTestHelper.checkPath(
+ symlink, "read", SecurityViolationLogger.ViolationType.FILE_READ);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Symlink escaping the permitted directory — must be blocked
+ // ---------------------------------------------------------------------------
+
+ @Test(expected = SecurityException.class)
+ public void testSymlinkEscapeBlockedInEnforceMode() throws Exception {
+ Path permittedDir = createTempDir("permitted");
+ Path otherDir = createTempDir("other");
+ buildPolicyPermitting(permittedDir);
+
+ // Create a real file outside the permitted directory
+ Path outsideFile = otherDir.resolve("secret.txt");
+ Files.writeString(outsideFile, "secret data");
+
+ // Create a symlink INSIDE the permitted dir that points OUTSIDE it
+ Path symlink = permittedDir.resolve("escaped.txt");
+ try {
+ Files.createSymbolicLink(symlink, outsideFile);
+ } catch (UnsupportedOperationException | SecurityException e) {
+ Assume.assumeTrue("Symlinks not supported or permitted in this test environment", false);
+ // Satisfy the @Test(expected=SecurityException.class) — fake a throw
+ throw new SecurityException("symlinks not available — skip");
+ }
+
+ // Accessing via the symlink path should be denied because the REAL path is outside permitted
+ // dir
+ InterceptorTestHelper.checkPath(
+ symlink, "read", SecurityViolationLogger.ViolationType.FILE_READ);
+ }
+
+ @Test
+ public void testSymlinkEscapeIncrementsFileCounter() throws Exception {
+ Path permittedDir = createTempDir("permitted");
+ Path otherDir = createTempDir("other");
+ buildPolicyPermitting(permittedDir);
+
+ Path outsideFile = otherDir.resolve("secret.txt");
+ Files.writeString(outsideFile, "secret");
+
+ Path symlink = permittedDir.resolve("escaped.txt");
+ try {
+ Files.createSymbolicLink(symlink, outsideFile);
+ } catch (UnsupportedOperationException | SecurityException e) {
+ Assume.assumeTrue("Symlinks not supported or permitted in this test environment", false);
+ return;
+ }
+
+ long before = ViolationMetricsReporter.fileCount();
+ try {
+ InterceptorTestHelper.checkPath(
+ symlink, "read", SecurityViolationLogger.ViolationType.FILE_READ);
+ } catch (SecurityException ignored) {
+ // expected
+ }
+ assertEquals(before + 1, ViolationMetricsReporter.fileCount());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Non-existent path — graceful fallback to normalized path check
+ // ---------------------------------------------------------------------------
+
+ @Test
+ public void testNonExistentPathFallsBackToNormalizedCheck() {
+ Path tmpDir = createTempDir();
+ buildPolicyPermitting(tmpDir);
+
+ // Non-existent file inside the permitted dir — toRealPath() fails → normalized path used
+ Path nonExistent = tmpDir.resolve("does-not-exist.txt");
+ // Should NOT throw — the normalized path falls within the permitted dir
+ InterceptorTestHelper.checkPath(
+ nonExistent, "read", SecurityViolationLogger.ViolationType.FILE_READ);
+ }
+}
diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/SystemExitInterceptorTest.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/SystemExitInterceptorTest.java
new file mode 100644
index 000000000000..a5215702ad3d
--- /dev/null
+++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/SystemExitInterceptorTest.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.apache.solr.SolrTestCase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link SystemExitInterceptor} and {@link RuntimeHaltInterceptor} policy logic. */
+public class SystemExitInterceptorTest extends SolrTestCase {
+
+ @Before
+ public void snapshotCounters() {}
+
+ @After
+ public void resetSingleton() {
+ AgentPolicy.resetForTesting();
+ }
+
+ private AgentPolicy initPolicy(
+ boolean approved, String callerClass, AgentPolicy.EnforcementMode mode) {
+ AgentPolicy.resetForTesting();
+ List The agent JAR ({@code solr:agent-sm}) has no OTel compile dependency; this class reads the raw
+ * {@code long} counts from {@code ViolationMetricsReporter} via reflection and builds the OTel
+ * callback natively. A single observable counter named {@code solr.security.agent.violations} is
+ * registered with label {@code type=file|network|exit|exec}. In Prometheus format:
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * Returns {@code null} if neither source has a value.
+ */
+ static String getPropertyOrEnv(String sysprop) {
+ String val = System.getProperty(sysprop);
+ if (val != null && !val.isBlank()) return val.trim();
+ String envVar =
+ CUSTOM_ENV_NAMES.getOrDefault(sysprop, sysprop.toUpperCase(Locale.ROOT).replace('.', '_'));
+ val = System.getenv(envVar);
+ return (val != null && !val.isBlank()) ? val.trim() : null;
+ }
+
+ /**
+ * Expands all {@code ${property}} placeholders in {@code value}. Returns {@code null} if {@code
+ * value} is {@code null}.
+ *
+ * @throws ExpandException if a placeholder references a system property that is not set
+ */
+ static String expand(String value) throws ExpandException {
+ if (value == null || !value.contains("${")) {
+ return value;
+ }
+ Matcher matcher = PLACEHOLDER_PATTERN.matcher(value);
+ StringBuffer sb = new StringBuffer();
+ while (matcher.find()) {
+ String replacement = handleMatch(matcher);
+ matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
+ }
+ matcher.appendTail(sb);
+ return sb.toString();
+ }
+
+ private static String handleMatch(Matcher match) throws ExpandException {
+ String escaped = match.group("escaped");
+ if (escaped != null) {
+ // ${{literal}} — return verbatim
+ return "${{" + escaped + "}}";
+ }
+ return expandPlaceholder(match.group("normal"));
+ }
+
+ private static String expandPlaceholder(String placeholder) throws ExpandException {
+ // solr.zk.port is optional — default to solr.port.listen + 1000
+ if ("solr.zk.port".equals(placeholder)) {
+ String val = getPropertyOrEnv("solr.zk.port");
+ if (val != null) return val;
+ String solrPort = getPropertyOrEnv("solr.port.listen");
+ if (solrPort == null) solrPort = "8983";
+ try {
+ return String.valueOf(Integer.parseInt(solrPort.trim()) + 1000);
+ } catch (NumberFormatException e) {
+ return "9983";
+ }
+ }
+ // solr.install.symDir is optional — falls back to solr.install.dir when absent.
+ if ("solr.install.symDir".equals(placeholder)) {
+ String val = getPropertyOrEnv("solr.install.symDir");
+ if (val != null) return val;
+ val = getPropertyOrEnv("solr.install.dir");
+ if (val != null) return val;
+ throw new ExpandException(
+ "Unresolved policy variable: ${solr.install.symDir} (and ${solr.install.dir} is also unset)");
+ }
+ // solr.data.home is optional — when not configured, Solr stores data under solr.solr.home
+ if ("solr.data.home".equals(placeholder)) {
+ String val = getPropertyOrEnv("solr.data.home");
+ if (val != null) return val;
+ val = getPropertyOrEnv("solr.solr.home");
+ if (val != null) return val;
+ throw new ExpandException(
+ "Unresolved policy variable: ${solr.data.home} (and ${solr.solr.home} is also unset)");
+ }
+ String value = getPropertyOrEnv(placeholder);
+ if (value == null) {
+ throw new ExpandException("Unresolved policy variable: ${" + placeholder + "}");
+ }
+ return value;
+ }
+}
diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicySource.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicySource.java
new file mode 100644
index 000000000000..1566f098ad66
--- /dev/null
+++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicySource.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+/**
+ * Whether a security policy entry came from the default bundled policy ({@code
+ * agent-security.policy}) or an operator extension. Standalone top-level type (not nested in {@link
+ * PolicyLoader}) because {@link PermittedPath}, {@link PermittedEndpoint}, and {@link
+ * ApprovedCallSite} hold it as a field and are injected into bootstrap — a nested type would force
+ * {@link PolicyLoader} into bootstrap too.
+ */
+public enum PolicySource {
+ DEFAULT,
+ OPERATOR
+}
diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyToken.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyToken.java
new file mode 100644
index 000000000000..b7a97d84be37
--- /dev/null
+++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyToken.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+record PolicyToken(int type, String text, int line) {}
diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyTokenStream.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyTokenStream.java
new file mode 100644
index 000000000000..9fa790cee24f
--- /dev/null
+++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyTokenStream.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.solr.security.agent;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StreamTokenizer;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Token stream backed by a {@link StreamTokenizer} for policy file parsing.
+ *
+ * Log format
+ *
+ * {@code
+ * [Solr SecurityAgent] SECURITY VIOLATION [TYPE] target=
+ *
+ *
+ *
+ */
+ static boolean isCallerFromCodeBase(Collection
+ *
+ */
+public class PolicyLoaderOperatorExtensionTest extends SolrTestCase {
+
+ @After
+ public void clearExtraPolicy() {
+ System.clearProperty("solr.security.agent.extra.policy");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private Path writeDefaultPolicy(Path dir) throws Exception {
+ Path policy = dir.resolve("agent-security.policy");
+ Files.writeString(
+ policy,
+ "grant {\n" + " permission java.io.FilePermission \"/opt/solr/-\", \"read\";\n" + "};\n",
+ StandardCharsets.UTF_8);
+ return policy;
+ }
+
+ private AgentPolicy loadWithExtra(Path defaultPolicy, Path extraPolicy) {
+ if (extraPolicy != null) {
+ System.setProperty("solr.security.agent.extra.policy", extraPolicy.toString());
+ }
+ return new PolicyLoader().load(defaultPolicy);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Extra policy present — entries merged and tagged OPERATOR
+ // ---------------------------------------------------------------------------
+
+ @Test
+ public void testExtraPolicyPathIsPermitted() throws Exception {
+ Path tmpDir = createTempDir();
+ Path defaultPolicy = writeDefaultPolicy(tmpDir);
+
+ Path extraPolicy = tmpDir.resolve("agent-security-extra.policy");
+ Files.writeString(
+ extraPolicy,
+ "grant {\n"
+ + " permission java.io.FilePermission \""
+ + tmpDir
+ + "/-\", \"read\";\n"
+ + "};\n",
+ StandardCharsets.UTF_8);
+
+ AgentPolicy policy = loadWithExtra(defaultPolicy, extraPolicy);
+ assertTrue(policy.isPathPermitted(tmpDir.resolve("data.txt").toString(), "read"));
+ }
+
+ @Test
+ public void testUnlistedPathStillBlockedWhenExtraPolicyPresent() throws Exception {
+ Path tmpDir = createTempDir();
+ Path defaultPolicy = writeDefaultPolicy(tmpDir);
+
+ Path extraPolicy = tmpDir.resolve("agent-security-extra.policy");
+ Files.writeString(
+ extraPolicy,
+ "grant {\n"
+ + " permission java.io.FilePermission \""
+ + tmpDir
+ + "/-\", \"read\";\n"
+ + "};\n",
+ StandardCharsets.UTF_8);
+
+ AgentPolicy policy = loadWithExtra(defaultPolicy, extraPolicy);
+ // /etc is not in either policy
+ assertFalse(policy.isPathPermitted("/etc/shadow", "read"));
+ }
+
+ @Test
+ public void testExtraPolicyEntriesTaggedOperator() throws Exception {
+ Path tmpDir = createTempDir();
+ Path defaultPolicy = writeDefaultPolicy(tmpDir);
+
+ Path extraPolicy = tmpDir.resolve("agent-security-extra.policy");
+ Files.writeString(
+ extraPolicy,
+ "grant {\n"
+ + " permission java.io.FilePermission \""
+ + tmpDir
+ + "/-\", \"read\";\n"
+ + "};\n",
+ StandardCharsets.UTF_8);
+
+ AgentPolicy policy = loadWithExtra(defaultPolicy, extraPolicy);
+ List
+ * solr_security_agent_violations_total{type="file"} N
+ * solr_security_agent_violations_total{type="network"} N
+ * solr_security_agent_violations_total{type="exit"} N
+ * solr_security_agent_violations_total{type="exec"} N
+ *
+ */
+public final class AgentViolationMetrics {
+
+ private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+ static final String METRIC_NAME = "solr.security.agent.violations";
+ private static final AttributeKey