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 permittedPaths; + private final List permittedEndpoints; + private final List approvedExitCallers; + private final List approvedExecCallers; + private final EnforcementMode enforcementMode; + private final Set trustedFileSystems; + private final Set trustedHosts; + + /** Constructs the policy. Called exclusively by {@link PolicyLoader#load(java.nio.file.Path)}. */ + AgentPolicy( + List permittedPaths, + List permittedEndpoints, + List approvedExitCallers, + List approvedExecCallers, + EnforcementMode enforcementMode) { + this( + permittedPaths, + permittedEndpoints, + approvedExitCallers, + approvedExecCallers, + enforcementMode, + Set.of(), + Set.of()); + } + + /** + * Constructs the policy with explicit trusted filesystem schemes (e.g. {@code "jrt"}, {@code + * "memory"}) and trusted hosts (e.g. loopback addresses). + */ + AgentPolicy( + List permittedPaths, + List permittedEndpoints, + List approvedExitCallers, + List approvedExecCallers, + EnforcementMode enforcementMode, + Set trustedFileSystems, + Set trustedHosts) { + this.permittedPaths = Collections.unmodifiableList(permittedPaths); + this.permittedEndpoints = Collections.unmodifiableList(permittedEndpoints); + this.approvedExitCallers = Collections.unmodifiableList(approvedExitCallers); + this.approvedExecCallers = Collections.unmodifiableList(approvedExecCallers); + this.enforcementMode = enforcementMode; + this.trustedFileSystems = Collections.unmodifiableSet(trustedFileSystems); + this.trustedHosts = Collections.unmodifiableSet(trustedHosts); + } + + // --------------------------------------------------------------------------- + // Singleton management + // --------------------------------------------------------------------------- + + /** + * Sets the global singleton policy. May only be called once; subsequent calls throw {@link + * SecurityException}. + */ + public static void initialize(AgentPolicy policy) { + synchronized (AgentPolicy.class) { + if (instance != null) { + throw new SecurityException( + "AgentPolicy has already been initialized and cannot be replaced. " + + "This is a programming error; only SolrAgentEntryPoint.premain() should call initialize()."); + } + instance = policy; + } + } + + /** + * Returns the active global policy. + * + * @throws IllegalStateException if the policy has not yet been initialized + */ + public static AgentPolicy getInstance() { + AgentPolicy p = instance; + if (p == null) { + throw new IllegalStateException( + "AgentPolicy has not been initialized. " + + "Ensure the Solr security agent JAR is on the -javaagent: command-line."); + } + return p; + } + + /** Returns {@code true} if the singleton has been initialized. */ + public static boolean isInitialized() { + return instance != null; + } + + /** Resets the singleton for tests; must not be called from production code. */ + static void resetForTesting() { + instance = null; + } + + // --------------------------------------------------------------------------- + // Policy accessors + // --------------------------------------------------------------------------- + + /** Permitted file-system paths derived from both the default policy and operator extensions. */ + public List permittedPaths() { + return permittedPaths; + } + + /** Permitted outbound network endpoints. */ + public List permittedEndpoints() { + return permittedEndpoints; + } + + /** Classes approved to call {@code System.exit()} or {@code Runtime.halt()}. */ + public List approvedExitCallers() { + return approvedExitCallers; + } + + /** + * Classes approved to spawn child processes via {@code ProcessBuilder} or {@code Runtime.exec()}. + */ + public List approvedExecCallers() { + return approvedExecCallers; + } + + /** Current enforcement mode. */ + public EnforcementMode enforcementMode() { + return enforcementMode; + } + + /** + * Filesystem scheme names that are exempt from path-based checks (e.g. in-memory filesystems used + * in tests). + */ + public Set trustedFileSystems() { + return trustedFileSystems; + } + + /** Host strings exempt from outbound network checks (e.g. {@code "localhost"}). */ + public Set trustedHosts() { + return trustedHosts; + } + + // --------------------------------------------------------------------------- + // Policy checks (convenience helpers called by interceptors) + // --------------------------------------------------------------------------- + + /** Returns {@code true} if the policy permits {@code action} on the resolved path. */ + public boolean isPathPermitted(String resolvedPath, String action) { + for (PermittedPath p : permittedPaths) { + if (p.permits(resolvedPath, action)) return true; + } + return false; + } + + /** Returns {@code true} if {@code className} matches an approved exit call-site. */ + public boolean isExitApproved(String className) { + for (ApprovedCallSite cs : approvedExitCallers) { + // codeBase entries require a Class object; skip them here + if (cs.operation() == ApprovedCallSite.Operation.EXIT + && cs.codeBase() == null + && cs.matches(className)) return true; + } + return false; + } + + /** Returns {@code true} if {@code className} matches an approved exec call-site. */ + public boolean isExecApproved(String className) { + for (ApprovedCallSite cs : approvedExecCallers) { + // codeBase entries require a Class object; skip them here + if (cs.operation() == ApprovedCallSite.Operation.EXEC + && cs.codeBase() == null + && cs.matches(className)) return true; + } + return false; + } + + /** + * Returns {@code true} if any class in {@code chain} is approved to call {@code System.exit()} or + * {@code Runtime.halt()}. Any match anywhere in the chain is sufficient. + */ + public boolean isChainThatCanExit(Collection> chain) { + for (Class cls : chain) { + for (ApprovedCallSite cs : approvedExitCallers) { + if (cs.operation() != ApprovedCallSite.Operation.EXIT) continue; + if (cs.codeBase() != null ? cs.matchesCodeBase(cls) : cs.matches(cls.getName())) { + return true; + } + } + } + return false; + } + + /** + * Returns {@code true} if any class in {@code chain} is approved to spawn child processes. Same + * semantics as {@link #isChainThatCanExit(Collection)}. + */ + public boolean isChainThatCanExec(Collection> chain) { + for (Class cls : chain) { + for (ApprovedCallSite cs : approvedExecCallers) { + if (cs.operation() != ApprovedCallSite.Operation.EXEC) continue; + if (cs.codeBase() != null ? cs.matchesCodeBase(cls) : cs.matches(cls.getName())) { + return true; + } + } + } + return false; + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/ApprovedCallSite.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/ApprovedCallSite.java new file mode 100644 index 000000000000..e73bd2a95cb9 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/ApprovedCallSite.java @@ -0,0 +1,64 @@ +/* + * 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.List; + +/** + * A policy entry approving a class (or code source) to perform a restricted operation. + * + *

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 IN_SYMLINK_RESOLVE = ThreadLocal.withInitial(() -> false); + + /** + * Resolves a path to its real, symlink-free form for policy checks. Falls back to {@link + * Path#toAbsolutePath() toAbsolutePath().normalize()} when (a) the path does not exist yet, or + * (b) {@code toRealPath()} itself triggers a re-entrant interception (detected via {@link + * #IN_SYMLINK_RESOLVE}). + */ + public static String resolveRealPath(Path path) { + if (Boolean.TRUE.equals(IN_SYMLINK_RESOLVE.get())) { + return path.toAbsolutePath().normalize().toString(); + } + IN_SYMLINK_RESOLVE.set(true); + try { + return path.toRealPath().toString(); + } catch (IOException | SecurityException e) { + // Path does not exist yet, or the Old Java SecurityManager blocked checkRead() on the + // resolved real path — fall back to the normalized (non-symlink-resolved) path. + return path.toAbsolutePath().normalize().toString(); + } finally { + IN_SYMLINK_RESOLVE.set(false); + } + } + + /** + * Intercepts file operations. + * + * @param args arguments + * @param method method + * @throws Exception exceptions + */ + @Advice.OnMethodEnter + public static void intercept(@Advice.AllArguments Object[] args, @Advice.Origin Method method) + throws Exception { + if (!AgentPolicy.isInitialized()) return; + final AgentPolicy policy = AgentPolicy.getInstance(); + + FileSystemProvider provider = null; + String filePath = null; + if (args.length > 0 && args[0] instanceof String pathStr) { + filePath = Path.of(pathStr).toAbsolutePath().normalize().toString(); + } else if (args.length > 0 && args[0] instanceof Path path) { + filePath = resolveRealPath(path); + provider = path.getFileSystem().provider(); + } + + if (filePath == null) { + return; // No valid file path found + } + + if (provider != null && policy.trustedFileSystems().contains(provider.getScheme())) { + return; + } + + final StackWalker walker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE); + final String caller = walker.getCallerClass().getName(); + + final String name = method.getName(); + // "move" and "copy" are handled separately below (both endpoints must be checked). + boolean isMutating = name.equals("write") || name.startsWith("create"); + final boolean isDelete = !isMutating && name.startsWith("delete"); + + // This is Windows implementation of UNIX Domain Sockets (close) + boolean isUnixSocketCaller = false; + if (isDelete == true) { + final Collection> chain = walker.walk(StackCallerClassChainExtractor.INSTANCE); + for (final Class cls : chain) { + if (cls.getName().equalsIgnoreCase("sun.nio.ch.PipeImpl$Initializer$LoopbackConnector")) { + isUnixSocketCaller = true; + break; + } + } + } + + if (isDelete == true && isUnixSocketCaller == true) { + // Unix domain socket cleanup — local IPC, always allow + return; + } else { + String targetFilePath = null; + if (isMutating == false && isDelete == false) { + if (name.equals("newByteChannel") == true || name.equals("open") == true) { + if (args.length > 1) { + if (args[1] instanceof OpenOption[] opts) { + for (final OpenOption opt : opts) { + if (opt != StandardOpenOption.READ) { + isMutating = true; + break; + } + } + } else if (args[1] instanceof Set opts) { + @SuppressWarnings("unchecked") + final Set options = (Set) args[1]; + for (final OpenOption opt : options) { + if (opt != StandardOpenOption.READ) { + isMutating = true; + break; + } + } + } else if (args[1] instanceof Object[] opts) { + for (final Object opt : opts) { + if (opt != StandardOpenOption.READ) { + isMutating = true; + break; + } + } + } else { + isMutating = true; // unknown option type — treat conservatively as mutating + } + } + } else if (name.equals("copy") == true || name.equals("move") == true) { + if (args.length > 1 && args[1] instanceof String pathStr) { + targetFilePath = Path.of(pathStr).toAbsolutePath().normalize().toString(); + } else if (args.length > 1 && args[1] instanceof Path path) { + targetFilePath = resolveRealPath(path); + } + } + } + + // Handle FileChannel.open() and newByteChannel() — check read/write permissions + if (name.equals("open") || name.equals("newByteChannel")) { + final String action = isMutating ? "write" : "read"; + enforceFileAccess( + policy, + filePath, + action, + isMutating + ? SecurityViolationLogger.ViolationType.FILE_WRITE + : SecurityViolationLogger.ViolationType.FILE_READ, + caller, + "Denied " + + (isMutating ? "OPEN (read/write)" : "OPEN (read)") + + " access to file: " + + filePath); + return; // fully handled; do not fall through + } + + // Handle Files.copy() — source requires read, destination requires write + if (name.equals("copy")) { + enforceFileAccess( + policy, + filePath, + "read", + SecurityViolationLogger.ViolationType.FILE_READ, + caller, + "Denied COPY (read) access to file: " + filePath); + if (targetFilePath != null) { + enforceFileAccess( + policy, + targetFilePath, + "write", + SecurityViolationLogger.ViolationType.FILE_WRITE, + caller, + "Denied COPY (write) access to file: " + targetFilePath); + } + return; // fully handled; do not fall through + } + + // Handle Files.move() — source requires delete, destination requires write + if (name.equals("move")) { + enforceFileAccess( + policy, + filePath, + "delete", + SecurityViolationLogger.ViolationType.FILE_DELETE, + caller, + "Denied MOVE (delete source) access to file: " + filePath); + if (targetFilePath != null) { + enforceFileAccess( + policy, + targetFilePath, + "write", + SecurityViolationLogger.ViolationType.FILE_WRITE, + caller, + "Denied MOVE (write destination) access to file: " + targetFilePath); + } + return; // fully handled; do not fall through + } + + // Remaining mutating operations (write, createFile, createDirectories, createLink) + if (isMutating) { + enforceFileAccess( + policy, + filePath, + "write", + SecurityViolationLogger.ViolationType.FILE_WRITE, + caller, + "Denied WRITE access to file: " + filePath); + } + + // File deletion operations + if (isDelete) { + enforceFileAccess( + policy, + filePath, + "delete", + SecurityViolationLogger.ViolationType.FILE_DELETE, + caller, + "Denied DELETE access to file: " + filePath); + } + } + } + + /** + * Shared enforcement: checks whether the policy permits {@code action} on {@code resolvedPath}. + * Increments the file violation counter and logs; throws {@link SecurityException} in enforce + * mode. Used by both the {@link #intercept} advice and the test-side check helpers. + */ + public static void enforceFileAccess( + AgentPolicy policy, + String resolvedPath, + String action, + SecurityViolationLogger.ViolationType violationType, + String caller, + String securityMessage) { + if (!policy.isPathPermitted(resolvedPath, action)) { + ViolationMetricsReporter.incrementFile(); + SecurityViolationLogger.log(violationType, resolvedPath, caller, policy.enforcementMode()); + if (policy.enforcementMode() == AgentPolicy.EnforcementMode.ENFORCE) { + throw new SecurityException(securityMessage); + } + } + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PermittedEndpoint.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PermittedEndpoint.java new file mode 100644 index 000000000000..03ba4ab4eb50 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PermittedEndpoint.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** + * A single outbound network access rule from the security policy. + * + *

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 actionSet; + private final String actions; // retained for the actions() accessor + private final boolean recursive; + private final PolicySource source; + + PermittedPath(String path, String actions, boolean recursive, PolicySource source) { + this.path = path; + String normalized = actions != null ? actions.toLowerCase(Locale.ROOT) : "read"; + this.actions = normalized; + this.actionSet = + Arrays.stream(normalized.split(",")).map(String::trim).collect(Collectors.toSet()); + this.recursive = recursive; + this.source = source; + } + + /** The base path after variable substitution. */ + public String path() { + return path; + } + + /** Comma-separated actions string, lower-cased (e.g. {@code "read,write,delete"}). */ + public String actions() { + return actions; + } + + /** Whether the rule covers all descendants ({@code path/-} in policy syntax). */ + public boolean recursive() { + return recursive; + } + + /** Whether this rule came from the default bundled policy or an operator extension. */ + public PolicySource source() { + return source; + } + + /** Returns {@code true} if this rule permits the given action on the given resolved path. */ + public boolean permits(String resolvedPath, String action) { + boolean pathMatch; + if (recursive) { + // Path.startsWith(Path) respects component boundaries and cross-platform separator style. + pathMatch = Path.of(resolvedPath).startsWith(Path.of(path)); + } else { + pathMatch = resolvedPath.equals(path); + } + return pathMatch && actionSet.contains(action.toLowerCase(Locale.ROOT)); + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyFileParser.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyFileParser.java new file mode 100644 index 000000000000..ccb55def8d93 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyFileParser.java @@ -0,0 +1,187 @@ +/* + * 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.ArrayList; +import java.util.List; + +/** + * Tokenizer-based parser for JDK-style {@code .policy} files. + * + *

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 permissions) {} + + record PermEntry(String permission, String name, String action) {} + + static class ParsingException extends Exception { + ParsingException(String message) { + super(message); + } + + ParsingException(int line, String expected, String found) { + super("line " + line + ": expected [" + expected + "], found [" + found + "]"); + } + } + + static List read(Reader policy) throws ParsingException, IOException { + return read(policy, true); + } + + /** + * @param strictTopLevel if {@code true}, non-{@code grant} top-level tokens throw {@link + * ParsingException}; if {@code false}, they are silently skipped + */ + static List read(Reader policy, boolean strictTopLevel) + throws ParsingException, IOException { + List entries = new ArrayList<>(); + PolicyTokenStream ts = new PolicyTokenStream(policy); + while (!ts.isEOF()) { + if (peek(ts, "grant")) { + entries.add(parseGrantEntry(ts)); + } else if (strictTopLevel) { + PolicyToken tok = ts.peek(); + throw new ParsingException(tok.line(), "grant", tok.text()); + } else { + ts.consume(); + } + } + return entries; + } + + private static GrantEntry parseGrantEntry(PolicyTokenStream ts) + throws ParsingException, IOException { + String codeBase = null; + List perms = new ArrayList<>(); + + poll(ts, "grant"); + + while (!peek(ts, "{")) { + if (pollOnMatch(ts, "codeBase")) { + String raw = poll(ts, ts.peek().text()); + codeBase = expand(ts, raw); + } else { + // Skip unknown grant modifiers + ts.consume(); + } + } + + poll(ts, "{"); + + while (!peek(ts, "}")) { + if (ts.isEOF()) break; + if (peek(ts, "permission")) { + perms.add(parsePermEntry(ts)); + pollOnMatch(ts, ";"); + } else { + ts.consume(); // skip unexpected token inside grant block + } + } + + poll(ts, "}"); + pollOnMatch(ts, ";"); + + return new GrantEntry(codeBase, List.copyOf(perms)); + } + + private static PermEntry parsePermEntry(PolicyTokenStream ts) + throws ParsingException, IOException { + poll(ts, "permission"); + String permClass = poll(ts, ts.peek().text()); + + String name = null; + if (isQuoted(ts.peek())) { + name = expand(ts, poll(ts, ts.peek().text())); + } + + String action = null; + pollOnMatch(ts, ","); + if (isQuoted(ts.peek())) { + action = expand(ts, poll(ts, ts.peek().text())); + } + + return new PermEntry(permClass, name, action); + } + + private static String expand(PolicyTokenStream ts, String raw) throws ParsingException { + int lineNum = -1; + try { + lineNum = ts.line(); + } catch (IOException ignored) { + // -1 = line unknown + } + try { + return PolicyPropertyExpander.expand(raw); + } catch (PolicyPropertyExpander.ExpandException e) { + ParsingException pe = + lineNum >= 0 + ? new ParsingException(lineNum, e.getMessage(), raw) + : new ParsingException(e.getMessage()); + pe.initCause(e); + throw pe; + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static boolean peek(PolicyTokenStream ts, String expected) throws IOException { + return expected.equalsIgnoreCase(ts.peek().text()); + } + + private static boolean pollOnMatch(PolicyTokenStream ts, String expected) + throws ParsingException, IOException { + if (peek(ts, expected)) { + poll(ts, expected); + return true; + } + return false; + } + + private static String poll(PolicyTokenStream ts, String expected) + throws ParsingException, IOException { + PolicyToken token = ts.consume(); + boolean isKeywordOrSymbol = + expected.equalsIgnoreCase("grant") + || expected.equalsIgnoreCase("codeBase") + || expected.equalsIgnoreCase("permission") + || expected.equals("{") + || expected.equals("}") + || expected.equals(";") + || expected.equals(","); + if (isKeywordOrSymbol && !expected.equalsIgnoreCase(token.text())) { + throw new ParsingException(token.line(), expected, token.text()); + } + return token.text(); + } + + private static boolean isQuoted(PolicyToken token) { + return token.type() == '"' || token.type() == '\''; + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyLoader.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyLoader.java new file mode 100644 index 000000000000..926aee0d95d6 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyLoader.java @@ -0,0 +1,261 @@ +/* + * 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.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Parses JDK-style {@code .policy} files with Solr variable substitution and produces an {@link + * AgentPolicy}. Loads the mandatory default policy and an optional operator extension file (path + * from {@code solr.security.agent.extra.policy} or {@code + * ${server.dir}/etc/agent-security-extra.policy}). Unresolved {@code ${property}} placeholders fail + * fast at startup. + */ +public class PolicyLoader { + + /** + * Loads and merges the default policy file and the optional operator extension file. + * + * @param defaultPolicyPath absolute path to the default {@code agent-security.policy} file + * @return a fully initialized {@link AgentPolicy} + * @throws IllegalStateException if the default policy file is absent or cannot be parsed + */ + public AgentPolicy load(Path defaultPolicyPath) { + if (!Files.exists(defaultPolicyPath)) { + throw new IllegalStateException( + "Security agent default policy not found: " + + defaultPolicyPath + + ". Solr cannot start without a valid security policy. " + + "Check that agent-security.policy is present in server/etc/."); + } + + String defaultContent; + try { + defaultContent = Files.readString(defaultPolicyPath, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to read security agent default policy: " + defaultPolicyPath, e); + } + + List grants = new ArrayList<>(); + parsePolicy(defaultContent, PolicySource.DEFAULT, grants); + if (grants.isEmpty()) { + throw new IllegalStateException( + "Security agent default policy contains no grant blocks: " + + defaultPolicyPath + + ". The default policy must define at least one grant."); + } + + Path extraPolicyPath = resolveExtraPolicyPath(); + if (extraPolicyPath != null && Files.exists(extraPolicyPath)) { + String extraContent; + try { + extraContent = Files.readString(extraPolicyPath, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to read operator security policy extension: " + extraPolicyPath, e); + } + int beforeCount = grants.size(); + parsePolicy(extraContent, PolicySource.OPERATOR, grants); + if (grants.size() == beforeCount) { + agentOut( + "[Solr SecurityAgent] Operator extension policy is empty (no grant blocks): " + + extraPolicyPath); + } + } + + return buildPolicy(grants); + } + + /** + * Returns the extra-policy path, or {@code null} if neither sysprop nor {@code jetty.home} is + * set. + */ + static Path resolveExtraPolicyPath() { + String explicitPath = + PolicyPropertyExpander.getPropertyOrEnv("solr.security.agent.extra.policy"); + if (explicitPath != null && !explicitPath.isBlank()) { + return Path.of(explicitPath); + } + String serverDir = System.getProperty("jetty.home", System.getProperty("server.dir")); + if (serverDir != null && !serverDir.isBlank()) { + return Path.of(serverDir, "etc", "agent-security-extra.policy"); + } + return null; + } + + static void parsePolicy(String content, PolicySource source, List out) { + parsePolicyBlocks(content, source, out); + } + + /** + * Parses grant blocks. Recognized permission types: {@code java.io.FilePermission} → {@link + * PermittedPath}, {@code java.net.SocketPermission} → {@link PermittedEndpoint}, {@code + * java.lang.RuntimePermission "exitVM"|"exec"} → {@link ApprovedCallSite}. + */ + static void parsePolicyBlocks(String text, PolicySource source, List out) { + List grantEntries; + try { + grantEntries = PolicyFileParser.read(new StringReader(text)); + } catch (PolicyFileParser.ParsingException | IOException e) { + throw new IllegalStateException("Failed to parse security policy: " + e.getMessage(), e); + } + for (PolicyFileParser.GrantEntry ge : grantEntries) { + GrantBlock block = new GrantBlock(ge.codeBase(), source); + for (PolicyFileParser.PermEntry pe : ge.permissions()) { + addPermission(pe, block); + } + out.add(block); + } + } + + private static void addPermission(PolicyFileParser.PermEntry pe, GrantBlock block) { + String permClass = pe.permission(); + String target = pe.name(); + String actions = pe.action(); + switch (permClass) { + case "java.io.FilePermission": + if (target != null) { + block.filePaths.add( + new RawFilePermission(target, actions != null ? actions : "read", block.source)); + } + break; + case "java.net.SocketPermission": + if (target != null) { + block.socketPerms.add( + new RawSocketPermission( + target, + actions != null ? actions : "connect,resolve", + block.codeBase, + block.source)); + } + break; + case "java.lang.RuntimePermission": + if ("exitVM".equals(target) || (target != null && target.startsWith("exitVM."))) { + block.runtimePerms.add(new RawRuntimePermission("exitVM", block.codeBase, block.source)); + } else if ("exec".equals(target)) { + block.runtimePerms.add(new RawRuntimePermission("exec", block.codeBase, block.source)); + } + break; + default: + // Unrecognised permission types are ignored (e.g. PropertyPermission in legacy policy) + break; + } + } + + /** Converts raw parsed grant blocks into an immutable {@link AgentPolicy}. */ + private AgentPolicy buildPolicy(List grants) { + List paths = new ArrayList<>(); + List endpoints = new ArrayList<>(); + List exitCallers = new ArrayList<>(); + List execCallers = new ArrayList<>(); + + for (GrantBlock block : grants) { + for (RawFilePermission fp : block.filePaths) { + boolean recursive = fp.target.endsWith("/-") || fp.target.endsWith("\\-"); + String basePath = recursive ? fp.target.substring(0, fp.target.length() - 2) : fp.target; + // Resolve symlinks so policy paths compare apples-to-apples with FileInterceptor. + try { + basePath = Path.of(basePath).toRealPath().toString(); + } catch (IOException | SecurityException e) { + try { + basePath = Path.of(basePath).toAbsolutePath().normalize().toString(); + } catch (Exception ignored) { + // keep the variable-substituted string as-is + } + } + paths.add(new PermittedPath(basePath, fp.actions, recursive, fp.source)); + } + for (RawSocketPermission sp : block.socketPerms) { + endpoints.add(new PermittedEndpoint(sp.hostPort, sp.actions, sp.codeBase, sp.source)); + } + for (RawRuntimePermission rp : block.runtimePerms) { + if ("exitVM".equals(rp.type)) { + // Grant without codeBase: "*" (any class may exit); with codeBase: match by code source + exitCallers.add( + new ApprovedCallSite( + rp.codeBase != null ? null : "*", + rp.codeBase, + ApprovedCallSite.Operation.EXIT, + rp.source)); + } else if ("exec".equals(rp.type)) { + execCallers.add( + new ApprovedCallSite( + rp.codeBase != null ? null : "*", + rp.codeBase, + ApprovedCallSite.Operation.EXEC, + rp.source)); + } + } + } + + // Read enforcement mode from sysprop or env var (agent has no dep on solr:core/EnvUtils) + String modeStr = PolicyPropertyExpander.getPropertyOrEnv("solr.security.agent.mode"); + if (modeStr == null) modeStr = "warn"; + AgentPolicy.EnforcementMode mode = + "enforce".equalsIgnoreCase(modeStr.trim()) + ? AgentPolicy.EnforcementMode.ENFORCE + : AgentPolicy.EnforcementMode.WARN; + + // 0.0.0.0 is the unspecified bind address, not a loopback — intentionally excluded. + Set trustedHosts = Set.of("localhost", "127.0.0.1", "0:0:0:0:0:0:0:1", "::1"); + // jar/zip/jrt: JVM class-loading file systems. Intercepting them causes a + // classloader deadlock (violation logger → SLF4J → Log4j2 init → JAR read → repeat). + Set trustedFileSystems = Set.of("jar", "zip", "jrt"); + return new AgentPolicy( + paths, endpoints, exitCallers, execCallers, mode, trustedFileSystems, trustedHosts); + } + + // --------------------------------------------------------------------------- + // Internal data transfer objects for parsed policy entries + // --------------------------------------------------------------------------- + + /** Holds the parsed contents of one grant { } block. */ + static class GrantBlock { + final String codeBase; // null for global grants + final PolicySource source; + final List filePaths = new ArrayList<>(); + final List socketPerms = new ArrayList<>(); + final List runtimePerms = new ArrayList<>(); + + GrantBlock(String codeBase, PolicySource source) { + this.codeBase = codeBase; + this.source = source; + } + } + + record RawFilePermission(String target, String actions, PolicySource source) {} + + record RawSocketPermission( + String hostPort, String actions, String codeBase, PolicySource source) {} + + record RawRuntimePermission(String type, String codeBase, PolicySource source) {} + + @SuppressForbidden( + reason = + "System.err is the only safe output channel during premain, before SLF4J is available.") + private static void agentOut(String msg) { + System.err.println(msg); + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyPropertyExpander.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyPropertyExpander.java new file mode 100644 index 000000000000..8e59727fd517 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/PolicyPropertyExpander.java @@ -0,0 +1,156 @@ +/* + * 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.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Expands {@code ${property}} placeholders in policy file token values using system properties. + * + *

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("\\$\\{\\{(?.*?)}}|\\$\\{(?.*?)}"); + + /** + * Non-standard env var names for sysprops that do not follow the SOLR_FOO_BAR convention. Mirrors + * the custom entries in {@code EnvToSyspropMappings.properties} in SolrJ. + */ + private static final Map CUSTOM_ENV_NAMES = + Map.of( + "solr.solr.home", "SOLR_HOME", + "solr.install.dir", "SOLR_TIP", + "solr.install.symDir", "SOLR_TIP_SYM"); + + /** + * Returns the value for {@code sysprop} by checking, in order: + * + *

    + *
  1. {@code System.getProperty(sysprop)} + *
  2. The env var derived from the sysprop name (standard {@code SOLR_FOO_BAR} convention, with + * custom overrides for non-standard names) + *
+ * + * 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. + * + *

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 buffer = new ArrayDeque<>(); + + PolicyTokenStream(Reader reader) { + StreamTokenizer st = new StreamTokenizer(reader); + st.resetSyntax(); + st.wordChars('a', 'z'); + st.wordChars('A', 'Z'); + st.wordChars('.', '.'); + st.wordChars('0', '9'); + st.wordChars('_', '_'); + st.wordChars('$', '$'); + st.wordChars(128 + 32, 255); + st.whitespaceChars(0, ' '); + st.commentChar('/'); + st.quoteChar('\''); + st.quoteChar('"'); + st.lowerCaseMode(false); + st.ordinaryChar('/'); + st.slashSlashComments(true); + st.slashStarComments(true); + this.tokenizer = st; + } + + PolicyToken peek() throws IOException { + if (buffer.isEmpty()) { + buffer.push(nextToken()); + } + return buffer.peek(); + } + + PolicyToken consume() throws IOException { + return buffer.isEmpty() ? nextToken() : buffer.pop(); + } + + boolean isEOF() throws IOException { + return peek().type() == StreamTokenizer.TT_EOF; + } + + int line() throws IOException { + return peek().line(); + } + + private PolicyToken nextToken() throws IOException { + int type = tokenizer.nextToken(); + String text = + switch (type) { + case StreamTokenizer.TT_WORD, '"', '\'' -> tokenizer.sval; + case StreamTokenizer.TT_EOF -> ""; + default -> Character.toString((char) type); + }; + return new PolicyToken(type, text, tokenizer.lineno()); + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/ProcessExecInterceptor.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/ProcessExecInterceptor.java new file mode 100644 index 000000000000..09e77e9d9560 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/ProcessExecInterceptor.java @@ -0,0 +1,79 @@ +/* + * 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.util.Collection; +import net.bytebuddy.asm.Advice; + +/** + * ByteBuddy {@link Advice} interceptor for {@code ProcessBuilder.start()} and {@code + * Runtime.exec()}. Permission is granted if any class in the full call chain matches an approved + * entry — same semantics as {@link SystemExitInterceptor}. + */ +public final class ProcessExecInterceptor { + + private ProcessExecInterceptor() {} + + /** Shared entry point for {@code ProcessBuilder.start()} and {@code Runtime.exec()}. */ + @Advice.OnMethodEnter(suppress = IOException.class) + public static void onExec(@Advice.AllArguments Object[] args, @Advice.Origin Method method) { + checkExec(deriveTarget(method.getName(), args)); + } + + public static String deriveTarget(String methodName, Object[] args) { + if (args == null || args.length == 0) return methodName + "()"; + Object arg0 = args[0]; + if (arg0 instanceof String[]) { + String[] cmd = (String[]) arg0; + return methodName + "(" + (cmd.length > 0 ? cmd[0] : "") + ")"; + } + if (arg0 instanceof String) return methodName + "(" + arg0 + ")"; + return methodName + "()"; + } + + // --------------------------------------------------------------------------- + // Core check logic + // --------------------------------------------------------------------------- + + /** + * Checks the call chain against the approved exec-caller list; logs and throws in enforce mode. + */ + public static void checkExec(String target) { + if (!AgentPolicy.isInitialized()) return; + + AgentPolicy policy = AgentPolicy.getInstance(); + final StackWalker walker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE); + final Class caller = walker.getCallerClass(); + final Collection> chain = walker.walk(StackCallerClassChainExtractor.INSTANCE); + + if (!policy.isChainThatCanExec(chain)) { + ViolationMetricsReporter.incrementExec(); + SecurityViolationLogger.log( + SecurityViolationLogger.ViolationType.PROCESS_EXEC, + target, + caller.getName(), + policy.enforcementMode()); + if (policy.enforcementMode() == AgentPolicy.EnforcementMode.ENFORCE) { + throw new SecurityException( + "Process spawning denied by Solr security agent — unapproved caller: " + + caller.getName()); + } + } + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/RuntimeHaltInterceptor.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/RuntimeHaltInterceptor.java new file mode 100644 index 000000000000..513e1ea9ce89 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/RuntimeHaltInterceptor.java @@ -0,0 +1,62 @@ +/* + * 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 Runtime#halt(int)}. + * + *

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> chain = walker.walk(StackCallerClassChainExtractor.INSTANCE); + + if (!policy.isChainThatCanExit(chain)) { + ViolationMetricsReporter.incrementExit(); + SecurityViolationLogger.log( + SecurityViolationLogger.ViolationType.SYSTEM_EXIT, + "halt(" + code + ")", + caller.getName(), + policy.enforcementMode()); + if (policy.enforcementMode() == AgentPolicy.EnforcementMode.ENFORCE) { + throw new SecurityException( + "The class " + + caller.getName() + + " is not allowed to call Runtime::halt(" + + code + + ")"); + } + } + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/SecurityViolationLogger.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/SecurityViolationLogger.java new file mode 100644 index 000000000000..486d355bf795 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/SecurityViolationLogger.java @@ -0,0 +1,106 @@ +/* + * 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.function.Consumer; + +/** + * Emits structured log entries for security policy violations detected by the Solr security agent. + * + *

Log format

+ * + *
{@code
+ * [Solr SecurityAgent] SECURITY VIOLATION [TYPE] target= caller= mode= source=
+ * }
+ * + *

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 reporter = null; + + /** Sets the violation reporter. Called by {@code AgentViolationBridge.wire()} via reflection. */ + public static void setReporter(Consumer r) { + reporter = r; + } + + /** The operation types that can trigger a violation. */ + public enum ViolationType { + FILE_READ, + FILE_WRITE, + FILE_DELETE, + NETWORK_CONNECT, + SYSTEM_EXIT, + PROCESS_EXEC + } + + private SecurityViolationLogger() {} + + /** Records a security violation. */ + @SuppressForbidden( + reason = + "System.err is the only output channel safe from classloader conflicts in a bootstrap " + + "agent. SLF4J in Boot-Class-Path permanently poisons the JVM-wide SLF4J binding.") + public static void log( + ViolationType type, + String target, + String caller, + AgentPolicy.EnforcementMode mode, + String source) { + + String message = buildMessage(type, target, caller, mode, source); + Consumer r = reporter; + if (r != null) { + r.accept(message); + } else { + System.err.println("[Solr SecurityAgent] " + message); + } + } + + /** Convenience overload without a {@code source} field (used before policy source tagging). */ + public static void log( + ViolationType type, String target, String caller, AgentPolicy.EnforcementMode mode) { + log(type, target, caller, mode, null); + } + + public static String buildMessage( + ViolationType type, + String target, + String caller, + AgentPolicy.EnforcementMode mode, + String source) { + + StringBuilder sb = new StringBuilder(); + sb.append("SECURITY VIOLATION [") + .append(type.name()) + .append("] target=") + .append(target) + .append(" caller=") + .append(caller) + .append(" mode=") + .append(mode.name()); + if (source != null) { + sb.append(" source=").append(source); + } + return sb.toString(); + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/SocketChannelInterceptor.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/SocketChannelInterceptor.java new file mode 100644 index 000000000000..b986c6e94b83 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/SocketChannelInterceptor.java @@ -0,0 +1,215 @@ +/* + * 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.net.URL; +import java.net.UnixDomainSocketAddress; +import java.security.CodeSource; +import java.util.Collection; +import net.bytebuddy.asm.Advice; + +/** + * ByteBuddy {@link Advice} interceptor for outbound socket connections. + * + *

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> chain = null; // lazily populated + + for (PermittedEndpoint entry : policy.permittedEndpoints()) { + if (!matchesEndpoint(entry.hostPort(), host, port)) continue; + + if (entry.codeBase() == null) { + // Global grant — no code-source restriction + return true; + } + + // codeBase-scoped grant: check if any class in the call chain was loaded from that codeBase + if (chain == null) { + chain = + StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(StackCallerClassChainExtractor.INSTANCE); + } + if (isCallerFromCodeBase(chain, entry.codeBase())) return true; + } + return false; + } + + /** + * Returns {@code true} if any class in {@code chain} has a code source whose location is under + * the given {@code codeBase} path. Supports JDK policy file {@code codeBase} syntax: + * + *

+ */ + static boolean isCallerFromCodeBase(Collection> chain, String codeBase) { + // Strip "file:" scheme prefix if present + String base = codeBase.startsWith("file:") ? codeBase.substring(5) : codeBase; + boolean recursive = base.endsWith("/-"); + if (recursive) base = base.substring(0, base.length() - 2); + // Normalise: strip trailing "/" so startsWith checks are consistent + while (base.endsWith("/") || base.endsWith("\\")) base = base.substring(0, base.length() - 1); + + for (Class cls : chain) { + try { + CodeSource cs = cls.getProtectionDomain().getCodeSource(); + if (cs == null) continue; + URL loc = cs.getLocation(); + if (loc == null) continue; + String locPath = loc.getPath(); + if (locPath == null) continue; + // Normalise: strip trailing separators + while (locPath.endsWith("/") || locPath.endsWith("\\")) + locPath = locPath.substring(0, locPath.length() - 1); + + // Normalise path separators for cross-platform comparison (Windows may use backslashes) + String normBase = base.replace('\\', '/'); + String normLocPath = locPath.replace('\\', '/'); + if (recursive) { + if (normLocPath.equals(normBase) || normLocPath.startsWith(normBase + "/")) return true; + } else { + if (normLocPath.equals(normBase)) return true; + } + } catch (Exception ignored) { + // SecurityException or other runtime exception — skip this frame + } + } + return false; + } + + public static boolean matchesEndpoint(String hostPortEntry, String host, int port) { + if ("*".equals(hostPortEntry)) return true; + + String entryHost; + String entryPort; + if (hostPortEntry.startsWith("[")) { + // IPv6 bracket notation: "[::1]:8983" or "[::1]:1-65535" + int closeBracket = hostPortEntry.indexOf(']'); + if (closeBracket < 0) return false; + entryHost = hostPortEntry.substring(1, closeBracket); // strip brackets + int colonAfterBracket = hostPortEntry.indexOf(':', closeBracket + 1); + entryPort = colonAfterBracket >= 0 ? hostPortEntry.substring(colonAfterBracket + 1) : null; + } else { + int colonIdx = hostPortEntry.lastIndexOf(':'); + if (colonIdx < 0) { + return matchesHost(hostPortEntry, host); + } + entryHost = hostPortEntry.substring(0, colonIdx); + entryPort = hostPortEntry.substring(colonIdx + 1); + } + + if (!matchesHost(entryHost, host)) return false; + return entryPort == null || matchesPort(entryPort, port); + } + + public static boolean matchesHost(String entryHost, String actualHost) { + if ("*".equals(entryHost)) return true; + return entryHost.equalsIgnoreCase(actualHost); + } + + public static boolean matchesPort(String entryPort, int actualPort) { + if ("*".equals(entryPort)) return true; + int dashIdx = entryPort.indexOf('-'); + if (dashIdx < 0) { + try { + return Integer.parseInt(entryPort.trim()) == actualPort; + } catch (NumberFormatException e) { + return false; + } + } + try { + int low = Integer.parseInt(entryPort.substring(0, dashIdx).trim()); + int high = Integer.parseInt(entryPort.substring(dashIdx + 1).trim()); + return actualPort >= low && actualPort <= high; + } catch (NumberFormatException e) { + return false; + } + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/SolrAgentEntryPoint.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/SolrAgentEntryPoint.java new file mode 100644 index 000000000000..d8732c0a6e0f --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/SolrAgentEntryPoint.java @@ -0,0 +1,185 @@ +/* + * 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.instrument.Instrumentation; +import java.net.Socket; +import java.nio.channels.FileChannel; +import java.nio.channels.SocketChannel; +import java.nio.file.Path; +import java.nio.file.spi.FileSystemProvider; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.implementation.Implementation; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * Java agent entry point. Invoked by the JVM before the application main class when the agent JAR + * is specified via {@code -javaagent:}. Loads the policy, initializes {@link AgentPolicy}, and + * installs ByteBuddy interceptors. The agent JAR is on {@code Boot-Class-Path} so interceptor + * classes are visible to the bootstrap classloader. + */ +public final class SolrAgentEntryPoint { + + private SolrAgentEntryPoint() {} + + public static void premain(String agentArgs, Instrumentation inst) { + bootAgent(inst); + } + + /** Called by the JVM when the agent is attached dynamically; delegates to {@link #premain}. */ + public static void agentmain(String agentArgs, Instrumentation inst) { + premain(agentArgs, inst); + } + + // --------------------------------------------------------------------------- + // Internal bootstrap logic + // --------------------------------------------------------------------------- + + @SuppressForbidden( + reason = + "System.err is the only safe output during premain; System.exit(1) is required to halt " + + "the JVM on fatal policy-load failure in enforce mode.") + private static void bootAgent(Instrumentation inst) { + Path defaultPolicyPath = resolveDefaultPolicyPath(); + + AgentPolicy policy = null; + try { + PolicyLoader loader = new PolicyLoader(); + policy = loader.load(defaultPolicyPath); + } catch (Exception e) { + String modeStr = PolicyPropertyExpander.getPropertyOrEnv("solr.security.agent.mode"); + if (modeStr == null) modeStr = "warn"; + if ("enforce".equalsIgnoreCase(modeStr.trim())) { + System.err.println( + "[Solr SecurityAgent] FATAL: Cannot load security policy in enforce mode. " + + "Solr will not start. Error: " + + e.getMessage()); + System.exit(1); + } else { + System.err.println( + "[Solr SecurityAgent] WARNING: Cannot load security policy (" + + e.getMessage() + + "). Security controls are inactive."); + return; + } + } + + AgentPolicy.initialize(policy); + + try { + installInterceptors(inst); + } catch (Exception e) { + System.err.println("[Solr SecurityAgent] Failed to install interceptors: " + e); + } + + System.err.println( + "[Solr SecurityAgent] Security agent active — mode=" + + policy.enforcementMode() + + ", permitted paths=" + + policy.permittedPaths().size() + + ", permitted endpoints=" + + policy.permittedEndpoints().size()); + } + + private static final String[] FILE_INTERCEPTED_METHODS = { + "write", + "createFile", + "createDirectories", + "createLink", + "copy", + "move", + "newByteChannel", + "delete", + "deleteIfExists", + "open" + }; + + private static void installInterceptors(Instrumentation inst) { + // Context.Disabled + NoOp: required for REDEFINE — ByteBuddy must not add auxiliary types or + // static initializers when redefining already-loaded JDK classes. + final ByteBuddy byteBuddy = + new ByteBuddy().with(Implementation.Context.Disabled.Factory.INSTANCE); + + new AgentBuilder.Default(byteBuddy) + .with(AgentBuilder.InitializationStrategy.NoOp.INSTANCE) + .with(AgentBuilder.RedefinitionStrategy.REDEFINITION) + .with(AgentBuilder.TypeStrategy.Default.REDEFINE) + // Custom ignore filter: allows bootstrap JDK classes (java.lang, java.nio, …) to be + // instrumented; excludes ByteBuddy's own classes to prevent circular instrumentation. + .ignore(ElementMatchers.isSynthetic().or(ElementMatchers.nameStartsWith("net.bytebuddy."))) + // Intercept FileSystemProvider subclasses, java.nio.file.Files, and FileChannel subtypes. + .type( + ElementMatchers.isSubTypeOf(FileSystemProvider.class) + .or(ElementMatchers.named("java.nio.file.Files")) + .or(ElementMatchers.isSubTypeOf(FileChannel.class))) + .transform( + (builder, type, classLoader, module, domain) -> + builder.visit( + Advice.to(FileInterceptor.class) + .on( + ElementMatchers.namedOneOf(FILE_INTERCEPTED_METHODS) + .and(ElementMatchers.not(ElementMatchers.isAbstract()))))) + // Intercept SocketChannel / Socket outbound connections → SocketChannelInterceptor + .type( + ElementMatchers.isSubTypeOf(SocketChannel.class) + .or(ElementMatchers.isSubTypeOf(Socket.class))) + .transform( + (builder, type, classLoader, module, domain) -> + builder.visit( + Advice.to(SocketChannelInterceptor.class) + .on( + ElementMatchers.named("connect") + .and(ElementMatchers.not(ElementMatchers.isAbstract()))))) + // Intercept System.exit(int) → SystemExitInterceptor + .type(ElementMatchers.is(System.class)) + .transform( + (builder, type, classLoader, module, domain) -> + builder.visit( + Advice.to(SystemExitInterceptor.class).on(ElementMatchers.named("exit")))) + // halt and exec together: avoids two separate redefinitions of Runtime + .type(ElementMatchers.is(Runtime.class)) + .transform( + (builder, type, classLoader, module, domain) -> + builder + .visit( + Advice.to(RuntimeHaltInterceptor.class).on(ElementMatchers.named("halt"))) + .visit( + Advice.to(ProcessExecInterceptor.class).on(ElementMatchers.named("exec")))) + // Intercept ProcessBuilder.start() → ProcessExecInterceptor + .type(ElementMatchers.is(ProcessBuilder.class)) + .transform( + (builder, type, classLoader, module, domain) -> + builder.visit( + Advice.to(ProcessExecInterceptor.class).on(ElementMatchers.named("start")))) + .installOn(inst); + } + + /** + * Resolves {@code server/etc/agent-security.policy} from {@code solr.install.dir} or {@code + * jetty.home}. + */ + private static Path resolveDefaultPolicyPath() { + String installDir = System.getProperty("solr.install.dir"); + if (installDir != null && !installDir.isBlank()) { + return Path.of(installDir, "server", "etc", "agent-security.policy"); + } + String jettyHome = System.getProperty("jetty.home", System.getProperty("server.dir", ".")); + return Path.of(jettyHome, "etc", "agent-security.policy"); + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/StackCallerClassChainExtractor.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/StackCallerClassChainExtractor.java new file mode 100644 index 000000000000..db9258bb592f --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/StackCallerClassChainExtractor.java @@ -0,0 +1,53 @@ +/* + * 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.StackFrame; +import java.util.Collection; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Extracts the set of all non-hidden declaring classes from the current call stack. + * + *

This file was derived from the OpenSearch project and modified. See {@code NOTICE.txt} for + * attribution. + */ +public final class StackCallerClassChainExtractor + implements Function, Collection>> { + + public static final StackCallerClassChainExtractor INSTANCE = + new StackCallerClassChainExtractor(); + + private StackCallerClassChainExtractor() {} + + @Override + public Collection> apply(Stream frames) { + return cast(frames); + } + + @SuppressWarnings("unchecked") + private static Set cast(Stream frames) { + return (Set) + frames + .map(StackFrame::getDeclaringClass) + .filter(c -> !c.isHidden()) + .collect(Collectors.toSet()); + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/SuppressForbidden.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/SuppressForbidden.java new file mode 100644 index 000000000000..555c7be5a9c4 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/SuppressForbidden.java @@ -0,0 +1,37 @@ +/* + * 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.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Suppresses forbidden-API checker violations for the annotated element. The {@code reason} field + * must explain why the forbidden API is necessary here. + * + *

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> chain = walker.walk(StackCallerClassChainExtractor.INSTANCE); + + if (!policy.isChainThatCanExit(chain)) { + ViolationMetricsReporter.incrementExit(); + SecurityViolationLogger.log( + SecurityViolationLogger.ViolationType.SYSTEM_EXIT, + "exit(" + code + ")", + caller.getName(), + policy.enforcementMode()); + if (policy.enforcementMode() == AgentPolicy.EnforcementMode.ENFORCE) { + throw new SecurityException( + "The class " + caller.getName() + " is not allowed to call System::exit(" + code + ")"); + } + } + } +} diff --git a/solr/agent-sm/src/java/org/apache/solr/security/agent/ViolationMetricsReporter.java b/solr/agent-sm/src/java/org/apache/solr/security/agent/ViolationMetricsReporter.java new file mode 100644 index 000000000000..4a3b3728f755 --- /dev/null +++ b/solr/agent-sm/src/java/org/apache/solr/security/agent/ViolationMetricsReporter.java @@ -0,0 +1,67 @@ +/* + * 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.concurrent.atomic.LongAdder; + +/** + * Per-type violation counters incremented by the agent interceptors. + * + *

{@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 ""; + } + } + + /** + * Checks whether {@code path} may be accessed with {@code action} under the active policy. + * Delegates to {@link FileInterceptor#enforceFileAccess}. + */ + static void checkPath(Path path, String action, SecurityViolationLogger.ViolationType type) { + if (!AgentPolicy.isInitialized()) return; + AgentPolicy policy = AgentPolicy.getInstance(); + String resolved = resolveRealPath(path); + FileInterceptor.enforceFileAccess( + policy, + resolved, + action, + type, + callerClassName(), + "Denied " + action.toUpperCase(Locale.ROOT) + " access to: " + resolved); + } + + /** + * Checks whether a move from {@code source} to {@code target} is permitted. Source requires + * "delete"; target requires "write". Delegates to {@link FileInterceptor#enforceFileAccess}. + */ + static void checkMove(Path source, Path target) { + if (!AgentPolicy.isInitialized()) return; + AgentPolicy policy = AgentPolicy.getInstance(); + String srcPath = resolveRealPath(source); + String dstPath = resolveRealPath(target); + String caller = callerClassName(); + FileInterceptor.enforceFileAccess( + policy, + srcPath, + "delete", + SecurityViolationLogger.ViolationType.FILE_DELETE, + caller, + "Denied MOVE (delete source) access to: " + srcPath); + FileInterceptor.enforceFileAccess( + policy, + dstPath, + "write", + SecurityViolationLogger.ViolationType.FILE_WRITE, + caller, + "Denied MOVE (write destination) access to: " + dstPath); + } + + /** + * Checks whether connecting to {@code address} is permitted under the active policy. Delegates to + * {@link SocketChannelInterceptor#enforceNetworkAccess}. + */ + static void checkConnect(InetSocketAddress address) { + if (!AgentPolicy.isInitialized()) return; + if (address.isUnresolved()) return; + AgentPolicy policy = AgentPolicy.getInstance(); + if (policy.trustedHosts().contains(address.getHostString())) return; + String host = address.getHostString(); + int port = address.getPort(); + SocketChannelInterceptor.enforceNetworkAccess(policy, host, port, callerClassName()); + } +} diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/PolicyLoaderOperatorExtensionTest.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/PolicyLoaderOperatorExtensionTest.java new file mode 100644 index 000000000000..74b4d20e96c1 --- /dev/null +++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/PolicyLoaderOperatorExtensionTest.java @@ -0,0 +1,256 @@ +/* + * 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.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.apache.solr.SolrTestCase; +import org.junit.After; +import org.junit.Test; + +/** + * Tests for the operator extension policy file ({@code agent-security-extra.policy}). + * + *

Verifies that: + * + *

+ */ +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 paths = policy.permittedPaths(); + boolean hasOperator = paths.stream().anyMatch(p -> p.source() == PolicySource.OPERATOR); + assertTrue("Expected at least one OPERATOR-sourced path entry", hasOperator); + } + + @Test + public void testDefaultPolicyEntriesTaggedDefault() throws Exception { + Path tmpDir = createTempDir(); + Path defaultPolicy = writeDefaultPolicy(tmpDir); + + AgentPolicy policy = loadWithExtra(defaultPolicy, null); + List paths = policy.permittedPaths(); + boolean hasDefault = paths.stream().anyMatch(p -> p.source() == PolicySource.DEFAULT); + assertTrue("Expected at least one DEFAULT-sourced path entry", hasDefault); + } + + // --------------------------------------------------------------------------- + // Extra policy absent — default still loads + // --------------------------------------------------------------------------- + + @Test + public void testExtraPolicyAbsentIsNonFatal() throws Exception { + Path tmpDir = createTempDir(); + Path defaultPolicy = writeDefaultPolicy(tmpDir); + + // Point to a non-existent extra policy + System.setProperty( + "solr.security.agent.extra.policy", tmpDir.resolve("nonexistent.policy").toString()); + + // Should not throw; default policy still loads + AgentPolicy policy = new PolicyLoader().load(defaultPolicy); + assertTrue(policy.isPathPermitted("/opt/solr/conf", "read")); + } + + // --------------------------------------------------------------------------- + // Malformed extra policy — any non-comment content that is not a valid grant causes ISE + // --------------------------------------------------------------------------- + + @Test(expected = IllegalStateException.class) + public void testGarbageExtraPolicyThrowsIllegalStateException() throws Exception { + Path tmpDir = createTempDir(); + Path defaultPolicy = writeDefaultPolicy(tmpDir); + + Path extraPolicy = tmpDir.resolve("agent-security-extra.policy"); + // Non-comment content that is not a valid grant block → fail fast + Files.writeString(extraPolicy, "THIS IS NOT A VALID POLICY\n", StandardCharsets.UTF_8); + + System.setProperty("solr.security.agent.extra.policy", extraPolicy.toString()); + + new PolicyLoader().load(defaultPolicy); + } + + @Test(expected = IllegalStateException.class) + public void testUnclosedGrantExtraPolicyThrowsIllegalStateException() throws Exception { + Path tmpDir = createTempDir(); + Path defaultPolicy = writeDefaultPolicy(tmpDir); + + Path extraPolicy = tmpDir.resolve("agent-security-extra.policy"); + // Syntactically invalid: unclosed grant block + Files.writeString( + extraPolicy, "grant { permission java.io.FilePermission\n", StandardCharsets.UTF_8); + + System.setProperty("solr.security.agent.extra.policy", extraPolicy.toString()); + + new PolicyLoader().load(defaultPolicy); + } + + @Test + public void testEmptyExtraPolicyIsAccepted() throws Exception { + Path tmpDir = createTempDir(); + Path defaultPolicy = writeDefaultPolicy(tmpDir); + + Path extraPolicy = tmpDir.resolve("agent-security-extra.policy"); + Files.writeString(extraPolicy, "", StandardCharsets.UTF_8); + + System.setProperty("solr.security.agent.extra.policy", extraPolicy.toString()); + + // Empty operator file is silently accepted; default policy is still active + AgentPolicy policy = new PolicyLoader().load(defaultPolicy); + assertTrue(policy.isPathPermitted("/opt/solr/conf", "read")); + } + + @Test + public void testCommentOnlyExtraPolicyIsAccepted() throws Exception { + Path tmpDir = createTempDir(); + Path defaultPolicy = writeDefaultPolicy(tmpDir); + + Path extraPolicy = tmpDir.resolve("agent-security-extra.policy"); + Files.writeString( + extraPolicy, "// This is a comment\n/* Block comment too */\n", StandardCharsets.UTF_8); + + System.setProperty("solr.security.agent.extra.policy", extraPolicy.toString()); + + // Comment-only operator file is silently accepted; default policy is still active + AgentPolicy policy = new PolicyLoader().load(defaultPolicy); + assertTrue(policy.isPathPermitted("/opt/solr/conf", "read")); + } + + // --------------------------------------------------------------------------- + // Source field in violation log + // --------------------------------------------------------------------------- + + @Test + public void testViolationLogIncludesSourceField() { + // Verify the log message builder includes the source field for OPERATOR entries + String msg = + SecurityViolationLogger.buildMessage( + SecurityViolationLogger.ViolationType.FILE_READ, + "/tmp/secret.txt", + "com.example.Caller", + AgentPolicy.EnforcementMode.WARN, + "OPERATOR"); + assertTrue("Expected source=OPERATOR in log message", msg.contains("source=OPERATOR")); + } + + @Test + public void testViolationLogOmitsSourceWhenNull() { + String msg = + SecurityViolationLogger.buildMessage( + SecurityViolationLogger.ViolationType.FILE_READ, + "/tmp/secret.txt", + "com.example.Caller", + AgentPolicy.EnforcementMode.WARN, + null); + assertFalse("Expected no source= field when source is null", msg.contains("source=")); + } +} diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/PolicyLoaderTest.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/PolicyLoaderTest.java new file mode 100644 index 000000000000..6853d1ca4338 --- /dev/null +++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/PolicyLoaderTest.java @@ -0,0 +1,247 @@ +/* + * 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.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.apache.solr.SolrTestCase; +import org.junit.Test; + +/** Unit tests for {@link PolicyLoader} — policy parsing, variable substitution, and merging. */ +public class PolicyLoaderTest extends SolrTestCase { + + // --------------------------------------------------------------------------- + // Variable expansion (PolicyPropertyExpander) + // --------------------------------------------------------------------------- + + @Test + public void testExpandKnownSystemProperty() throws Exception { + System.setProperty("solr.solr.home", "/opt/solr"); + assertEquals("/opt/solr", PolicyPropertyExpander.expand("${solr.solr.home}")); + } + + @Test + public void testExpandSolrPort() throws Exception { + System.setProperty("solr.port.listen", "8983"); + assertEquals("*:8983", PolicyPropertyExpander.expand("*:${solr.port.listen}")); + } + + @Test + public void testSolrZkPortDefaultsToSolrPortPlusOneThousand() throws Exception { + System.setProperty("solr.port.listen", "8983"); + System.clearProperty("solr.zk.port"); + assertEquals("*:9983", PolicyPropertyExpander.expand("*:${solr.zk.port}")); + } + + @Test + public void testSolrZkPortExplicitOverride() throws Exception { + System.setProperty("solr.zk.port", "2181"); + try { + assertEquals("*:2181", PolicyPropertyExpander.expand("*:${solr.zk.port}")); + } finally { + System.clearProperty("solr.zk.port"); + } + } + + @Test + public void testExpandNullReturnsNull() throws Exception { + assertNull(PolicyPropertyExpander.expand(null)); + } + + @Test(expected = PolicyPropertyExpander.ExpandException.class) + public void testUnknownVariableThrows() throws Exception { + PolicyPropertyExpander.expand("path=${solr.this.property.does.not.exist.xyz}"); + } + + // --------------------------------------------------------------------------- + // Policy block parsing + // --------------------------------------------------------------------------- + + @Test + public void testGlobalGrantFilePermissionParsed() { + String policy = + "grant {\n" + " permission java.io.FilePermission \"/solr/home/-\", \"read\";\n" + "};"; + List blocks = new ArrayList<>(); + PolicyLoader.parsePolicyBlocks(policy, PolicySource.DEFAULT, blocks); + + assertEquals(1, blocks.size()); + PolicyLoader.GrantBlock block = blocks.get(0); + assertNull(block.codeBase); + assertEquals(1, block.filePaths.size()); + assertEquals("/solr/home/-", block.filePaths.get(0).target()); + assertEquals("read", block.filePaths.get(0).actions()); + assertEquals(PolicySource.DEFAULT, block.filePaths.get(0).source()); + } + + @Test + public void testGlobalGrantSocketPermissionParsed() { + String policy = + "grant {\n" + + " permission java.net.SocketPermission \"*:8983\", \"connect,resolve\";\n" + + "};"; + List blocks = new ArrayList<>(); + PolicyLoader.parsePolicyBlocks(policy, PolicySource.DEFAULT, blocks); + + assertEquals(1, blocks.size()); + assertEquals(1, blocks.get(0).socketPerms.size()); + assertEquals("*:8983", blocks.get(0).socketPerms.get(0).hostPort()); + assertNull(blocks.get(0).socketPerms.get(0).codeBase()); + } + + @Test + public void testCodeBaseScopedGrantParsed() { + String policy = + "grant codeBase \"file:/opt/solr/modules/jwt-auth/-\" {\n" + + " permission java.net.SocketPermission \"*\", \"connect,resolve\";\n" + + "};"; + List blocks = new ArrayList<>(); + PolicyLoader.parsePolicyBlocks(policy, PolicySource.DEFAULT, blocks); + + assertEquals(1, blocks.size()); + PolicyLoader.GrantBlock block = blocks.get(0); + assertEquals("file:/opt/solr/modules/jwt-auth/-", block.codeBase); + assertEquals(1, block.socketPerms.size()); + assertEquals("*", block.socketPerms.get(0).hostPort()); + assertEquals("file:/opt/solr/modules/jwt-auth/-", block.socketPerms.get(0).codeBase()); + } + + @Test + public void testLineCommentsStripped() { + String policy = + "grant { // this is a comment\n" + + " // another comment\n" + + " permission java.io.FilePermission \"/tmp/-\", \"read,write,delete\";\n" + + "};"; + List blocks = new ArrayList<>(); + PolicyLoader.parsePolicyBlocks(policy, PolicySource.DEFAULT, blocks); + + assertEquals(1, blocks.size()); + assertEquals(1, blocks.get(0).filePaths.size()); + } + + @Test + public void testUnrecognisedPermissionTypeIgnored() { + String policy = + "grant {\n" + + " permission java.util.PropertyPermission \"*\", \"read\";\n" + + " permission java.io.FilePermission \"/tmp/-\", \"read\";\n" + + "};"; + List blocks = new ArrayList<>(); + PolicyLoader.parsePolicyBlocks(policy, PolicySource.DEFAULT, blocks); + + assertEquals(1, blocks.size()); + assertEquals(1, blocks.get(0).filePaths.size()); + // PropertyPermission is silently ignored + } + + // --------------------------------------------------------------------------- + // Full load — file I/O + // --------------------------------------------------------------------------- + + @Test + public void testValidPolicyParsesCorrectly() throws Exception { + Path policyFile = createTempFile("agent-security", ".policy"); + Files.writeString( + policyFile, + "grant {\n" + + " permission java.io.FilePermission \"/solr/home/-\", \"read\";\n" + + " permission java.net.SocketPermission \"localhost:8983\", \"connect,resolve\";\n" + + "};\n", + StandardCharsets.UTF_8); + + PolicyLoader loader = new PolicyLoader(); + AgentPolicy policy = loader.load(policyFile); + + assertFalse(policy.permittedPaths().isEmpty()); + assertFalse(policy.permittedEndpoints().isEmpty()); + } + + @Test(expected = IllegalStateException.class) + public void testMissingDefaultPolicyThrowsOnLoad() throws Exception { + Path missing = createTempDir().resolve("no-such-policy.policy"); + new PolicyLoader().load(missing); + } + + @Test + public void testExtraPolicyAbsentIsNonFatal() throws Exception { + Path defaultPolicy = createTempFile("agent-security", ".policy"); + Files.writeString( + defaultPolicy, + "grant { permission java.io.FilePermission \"/solr/home/-\", \"read\"; };\n", + StandardCharsets.UTF_8); + + // Point extra-policy at a non-existent file + System.setProperty( + "solr.security.agent.extra.policy", createTempDir().resolve("absent.policy").toString()); + try { + AgentPolicy policy = new PolicyLoader().load(defaultPolicy); + // Must succeed — absent extra policy is not an error + assertNotNull(policy); + } finally { + System.clearProperty("solr.security.agent.extra.policy"); + } + } + + @Test + public void testExtraPolicyMergedAndTaggedOperator() throws Exception { + Path defaultPolicy = createTempFile("default", ".policy"); + Files.writeString( + defaultPolicy, + "grant { permission java.io.FilePermission \"/solr/home/-\", \"read\"; };\n", + StandardCharsets.UTF_8); + + Path extraPolicy = createTempFile("extra", ".policy"); + Files.writeString( + extraPolicy, + "grant { permission java.io.FilePermission \"/mnt/nfs/-\", \"read\"; };\n", + StandardCharsets.UTF_8); + + System.setProperty("solr.security.agent.extra.policy", extraPolicy.toString()); + try { + AgentPolicy policy = new PolicyLoader().load(defaultPolicy); + + // Both paths must be present + List paths = policy.permittedPaths(); + assertTrue(paths.stream().anyMatch(p -> p.path().equals("/solr/home"))); + assertTrue(paths.stream().anyMatch(p -> p.path().equals("/mnt/nfs"))); + + // Operator path carries OPERATOR source tag + PermittedPath operatorPath = + paths.stream().filter(p -> p.path().equals("/mnt/nfs")).findFirst().orElseThrow(); + assertEquals(PolicySource.OPERATOR, operatorPath.source()); + } finally { + System.clearProperty("solr.security.agent.extra.policy"); + } + } + + @Test + public void testRecursivePathFlagSet() throws Exception { + Path policyFile = createTempFile("agent-security", ".policy"); + Files.writeString( + policyFile, + "grant { permission java.io.FilePermission \"/data/-\", \"read,write,delete\"; };\n", + StandardCharsets.UTF_8); + + AgentPolicy policy = new PolicyLoader().load(policyFile); + PermittedPath path = policy.permittedPaths().get(0); + assertEquals("/data", path.path()); + assertTrue(path.recursive()); + } +} diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/ProcessExecInterceptorTest.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/ProcessExecInterceptorTest.java new file mode 100644 index 000000000000..033984535390 --- /dev/null +++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/ProcessExecInterceptorTest.java @@ -0,0 +1,135 @@ +/* + * 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 ProcessExecInterceptor} enforcement logic. */ +public class ProcessExecInterceptorTest extends SolrTestCase { + + private long execCountBefore; + + @Before + public void snapshotCounters() { + execCountBefore = ViolationMetricsReporter.execCount(); + } + + @After + public void resetSingleton() { + AgentPolicy.resetForTesting(); + } + + private void initPolicy(boolean approved, String callerClass, AgentPolicy.EnforcementMode mode) { + resetPolicySingleton(); + List execCallers = + approved + ? List.of( + new ApprovedCallSite( + callerClass, ApprovedCallSite.Operation.EXEC, PolicySource.DEFAULT)) + : List.of(); + AgentPolicy policy = new AgentPolicy(List.of(), List.of(), List.of(), execCallers, mode); + AgentPolicy.initialize(policy); + } + + private static void resetPolicySingleton() { + AgentPolicy.resetForTesting(); + } + + @Test + public void testApprovedCallerDoesNotIncreaseCounter() { + initPolicy( + true, ProcessExecInterceptorTest.class.getName(), AgentPolicy.EnforcementMode.ENFORCE); + ProcessExecInterceptor.checkExec("ProcessBuilder.start()"); + assertEquals(execCountBefore, ViolationMetricsReporter.execCount()); + resetPolicySingleton(); + } + + @Test + public void testUnapprovedCallerInWarnModeIncrementsCounter() { + initPolicy(false, "nobody", AgentPolicy.EnforcementMode.WARN); + ProcessExecInterceptor.checkExec("ProcessBuilder.start()"); + assertEquals(execCountBefore + 1, ViolationMetricsReporter.execCount()); + resetPolicySingleton(); + } + + @Test(expected = SecurityException.class) + public void testUnapprovedCallerInEnforceModeThrows() { + initPolicy(false, "nobody", AgentPolicy.EnforcementMode.ENFORCE); + try { + ProcessExecInterceptor.checkExec("ProcessBuilder.start()"); + } finally { + resetPolicySingleton(); + } + } + + @Test + public void testRuntimeExecBlocked() { + initPolicy(false, "nobody", AgentPolicy.EnforcementMode.WARN); + long before = ViolationMetricsReporter.execCount(); + ProcessExecInterceptor.checkExec("Runtime.exec(ls)"); + assertEquals(before + 1, ViolationMetricsReporter.execCount()); + resetPolicySingleton(); + } + + @Test + public void testWildcardApprovalMatchesAny() { + initPolicy(true, "*", AgentPolicy.EnforcementMode.ENFORCE); + // Should not throw even for an unknown caller + ProcessExecInterceptor.checkExec("ProcessBuilder.start()"); + assertEquals(execCountBefore, ViolationMetricsReporter.execCount()); + resetPolicySingleton(); + } + + @Test + public void testApprovedCallerAnywhereInChainPermits() { + // isChainThatCanExec mirrors isChainThatCanExit: any approved class anywhere grants permission. + AgentPolicy.resetForTesting(); + List execCallers = + List.of( + new ApprovedCallSite( + ProcessExecInterceptorTest.class.getName(), + ApprovedCallSite.Operation.EXEC, + PolicySource.DEFAULT)); + AgentPolicy policy = + new AgentPolicy( + List.of(), List.of(), List.of(), execCallers, AgentPolicy.EnforcementMode.ENFORCE); + AgentPolicy.initialize(policy); + + // Approved class is deep in the chain — should still grant permission + Collection> chain = + Set.of(String.class, Integer.class, ProcessExecInterceptorTest.class); + assertTrue(policy.isChainThatCanExec(chain)); + } + + @Test + public void testUnapprovedChainDenies() { + AgentPolicy.resetForTesting(); + AgentPolicy policy = + new AgentPolicy( + List.of(), List.of(), List.of(), List.of(), AgentPolicy.EnforcementMode.ENFORCE); + AgentPolicy.initialize(policy); + + Collection> chain = Set.of(String.class, Integer.class); + assertFalse(policy.isChainThatCanExec(chain)); + } +} diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/SocketChannelInterceptorTest.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/SocketChannelInterceptorTest.java new file mode 100644 index 000000000000..4e229b08ce71 --- /dev/null +++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/SocketChannelInterceptorTest.java @@ -0,0 +1,159 @@ +/* + * 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.InetAddress; +import java.net.InetSocketAddress; +import java.net.URL; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import org.apache.solr.SolrTestCase; +import org.junit.After; +import org.junit.Test; + +/** Unit tests for {@link SocketChannelInterceptor} policy matching logic. */ +public class SocketChannelInterceptorTest extends SolrTestCase { + + @After + public void resetSingleton() { + AgentPolicy.resetForTesting(); + } + + private AgentPolicy policyWithEndpoint(String hostPort) { + PermittedEndpoint ep = + new PermittedEndpoint(hostPort, "connect,resolve", null, PolicySource.DEFAULT); + return new AgentPolicy( + List.of(), List.of(ep), List.of(), List.of(), AgentPolicy.EnforcementMode.ENFORCE); + } + + @Test + public void testLoopbackPermittedViaTrustedHosts() throws Exception { + AgentPolicy policy = + new AgentPolicy( + List.of(), + List.of(), + List.of(), + List.of(), + AgentPolicy.EnforcementMode.ENFORCE, + Set.of(), + Set.of("127.0.0.1", "localhost", "::1")); + AgentPolicy.initialize(policy); + + InetSocketAddress loopback = new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 8983); + assertTrue(policy.trustedHosts().contains(loopback.getHostString())); + // checkConnect skips trusted hosts before policy lookup + InterceptorTestHelper.checkConnect(loopback); // must not throw + } + + @Test + public void testExactHostPortPermitted() { + AgentPolicy policy = policyWithEndpoint("192.168.1.100:8983"); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.100", 8983)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.100", 8984)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.101", 8983)); + } + + @Test + public void testWildcardHostPortPermitted() { + AgentPolicy policy = policyWithEndpoint("*:8983"); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "10.0.0.5", 8983)); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "some-other-host", 8983)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "10.0.0.5", 9983)); + } + + @Test + public void testPortRangePermitted() { + AgentPolicy policy = policyWithEndpoint("192.168.1.1:8000-9000"); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.1", 8983)); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.1", 8000)); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.1", 9000)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.1", 7999)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "192.168.1.1", 9001)); + } + + @Test + public void testCodebaseScopedEntryDeniedWhenCallerNotFromThatCodeBase() { + // A codeBase grant for a path where no class in the current test stack is loaded from. + // The call chain contains test-framework classes, not classes from /nonexistent/path. + PermittedEndpoint codeBasedEp = + new PermittedEndpoint( + "*", + "connect,resolve", + "file:/nonexistent/solr/modules/jwt-auth/-", + PolicySource.DEFAULT); + AgentPolicy policy = + new AgentPolicy( + List.of(), + List.of(codeBasedEp), + List.of(), + List.of(), + AgentPolicy.EnforcementMode.ENFORCE); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "external.host", 443)); + } + + @Test + public void testIsCallerFromCodeBaseMatchesTestClassCodeSource() { + // Get the actual code source URL of this test class and verify isCallerFromCodeBase accepts it. + URL loc = + SocketChannelInterceptorTest.class.getProtectionDomain().getCodeSource().getLocation(); + String codeBase = "file:" + loc.getPath(); + // Strip any trailing "/" so it acts as an exact-directory match + if (codeBase.endsWith("/")) codeBase = codeBase.substring(0, codeBase.length() - 1); + // Append "/-" to make it a recursive match (any class under the test build dir) + codeBase = codeBase + "/-"; + + Collection> chain = Set.of(SocketChannelInterceptorTest.class); + assertTrue(SocketChannelInterceptor.isCallerFromCodeBase(chain, codeBase)); + } + + @Test + public void testIsCallerFromCodeBaseNoMatchForUnrelatedPath() { + Collection> chain = Set.of(SocketChannelInterceptorTest.class); + assertFalse(SocketChannelInterceptor.isCallerFromCodeBase(chain, "file:/nonexistent/path/-")); + } + + @Test + public void testUnlistedHostPortBlocked() { + AgentPolicy policy = policyWithEndpoint("localhost:8983"); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "10.0.0.1", 443)); + } + + @Test + public void testBroadWildcardPermitsAll() { + AgentPolicy policy = policyWithEndpoint("*"); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "anything.com", 443)); + } + + @Test + public void testIPv6BracketNotationMatches() { + // Policy entries for IPv6 use bracket notation "[::1]:port"; the actual host from + // InetSocketAddress.getHostString() is without brackets. Verify stripping works. + AgentPolicy policy = policyWithEndpoint("[::1]:8983"); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "::1", 8983)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "::1", 8984)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "::2", 8983)); + } + + @Test + public void testIPv6BracketWithPortRange() { + AgentPolicy policy = policyWithEndpoint("[::1]:1-65535"); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "::1", 8983)); + assertTrue(SocketChannelInterceptor.isEndpointPermitted(policy, "::1", 1)); + assertFalse(SocketChannelInterceptor.isEndpointPermitted(policy, "::2", 8983)); + } +} diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/SolrAgentIntegrationTest.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/SolrAgentIntegrationTest.java new file mode 100644 index 000000000000..8ab455fd2c5b --- /dev/null +++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/SolrAgentIntegrationTest.java @@ -0,0 +1,223 @@ +/* + * 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.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.apache.solr.SolrTestCase; +import org.junit.After; +import org.junit.Test; + +/** + * Integration-level tests for the Solr security agent in enforce mode. + * + *

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 paths, + List endpoints, + List exitCallers, + List execCallers) { + AgentPolicy p = + new AgentPolicy( + paths, endpoints, exitCallers, execCallers, AgentPolicy.EnforcementMode.ENFORCE); + AgentPolicy.initialize(p); + return p; + } + + // --------------------------------------------------------------------------- + // File access tests + // --------------------------------------------------------------------------- + + @Test + public void testPermittedFileReadSucceeds() { + Path tmpDir = createTempDir(); + PermittedPath allowed = + new PermittedPath(tmpDir.toString(), "read", true, PolicySource.DEFAULT); + buildEnforcePolicy(List.of(allowed), List.of(), List.of(), List.of()); + + Path target = tmpDir.resolve("test.txt"); + InterceptorTestHelper.checkPath( + target, "read", SecurityViolationLogger.ViolationType.FILE_READ); + } + + @Test(expected = SecurityException.class) + public void testDeniedFileReadThrows() { + Path tmpDir = createTempDir(); + // Policy permits nothing + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + InterceptorTestHelper.checkPath( + tmpDir.resolve("secret.txt"), "read", SecurityViolationLogger.ViolationType.FILE_READ); + } + + @Test + public void testDeniedFileReadIncrementsFileCounter() { + long before = ViolationMetricsReporter.fileCount(); + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + try { + InterceptorTestHelper.checkPath( + Path.of("/etc/passwd"), "read", SecurityViolationLogger.ViolationType.FILE_READ); + } catch (SecurityException ignored) { + // expected + } + assertEquals(before + 1, ViolationMetricsReporter.fileCount()); + } + + @Test + public void testMoveSourceRequiresDeleteNotWrite() { + Path tmpDir = createTempDir(); + // Grant write on destination but NOT delete on source + PermittedPath dstAllowed = + new PermittedPath(tmpDir.toString(), "write", true, PolicySource.DEFAULT); + buildEnforcePolicy(List.of(dstAllowed), List.of(), List.of(), List.of()); + + Path src = tmpDir.resolve("src.txt"); + Path dst = tmpDir.resolve("dst.txt"); + // Source lacks delete permission — should throw with a message about "delete source" + try { + InterceptorTestHelper.checkMove(src, dst); + fail("Expected SecurityException"); + } catch (SecurityException e) { + assertTrue(e.getMessage(), e.getMessage().contains("delete source")); + } + } + + @Test + public void testMoveDestinationRequiresWrite() { + Path tmpDir = createTempDir(); + Path otherDir = createTempDir(); + // Grant delete on source dir, but NOT write on destination dir + PermittedPath srcAllowed = + new PermittedPath(tmpDir.toString(), "delete", true, PolicySource.DEFAULT); + buildEnforcePolicy(List.of(srcAllowed), List.of(), List.of(), List.of()); + + Path src = tmpDir.resolve("src.txt"); + Path dst = otherDir.resolve("dst.txt"); + try { + InterceptorTestHelper.checkMove(src, dst); + fail("Expected SecurityException"); + } catch (SecurityException e) { + assertTrue(e.getMessage(), e.getMessage().contains("write destination")); + } + } + + @Test + public void testMoveCountsEachViolationOnce() { + long before = ViolationMetricsReporter.fileCount(); + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + Path src = Path.of("/forbidden/src.txt"); + Path dst = Path.of("/forbidden/dst.txt"); + // Source (delete) violation fires first and throws in enforce mode + try { + InterceptorTestHelper.checkMove(src, dst); + } catch (SecurityException ignored) { + // expected + } + assertEquals(before + 1, ViolationMetricsReporter.fileCount()); + } + + // --------------------------------------------------------------------------- + // Network tests + // --------------------------------------------------------------------------- + + @Test + public void testPermittedEndpointNotBlocked() { + PermittedEndpoint ep = + new PermittedEndpoint("*:8983", "connect,resolve", null, PolicySource.DEFAULT); + buildEnforcePolicy(List.of(), List.of(ep), List.of(), List.of()); + assertTrue( + SocketChannelInterceptor.isEndpointPermitted(AgentPolicy.getInstance(), "10.0.0.1", 8983)); + } + + @Test(expected = SecurityException.class) + public void testDeniedNetworkConnectThrowsInEnforceMode() throws Exception { + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + InetSocketAddress addr = new InetSocketAddress(InetAddress.getByName("10.0.0.99"), 9999); + InterceptorTestHelper.checkConnect(addr); + } + + @Test + public void testDeniedNetworkIncrementsCounter() throws Exception { + long before = ViolationMetricsReporter.networkCount(); + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + InetSocketAddress addr = new InetSocketAddress(InetAddress.getByName("10.0.0.99"), 9999); + try { + InterceptorTestHelper.checkConnect(addr); + } catch (SecurityException ignored) { + // expected + } + assertEquals(before + 1, ViolationMetricsReporter.networkCount()); + } + + // --------------------------------------------------------------------------- + // System.exit tests — tested via AgentPolicy.isChainThatCanExit() + // --------------------------------------------------------------------------- + + @Test + public void testUnapprovedExitChainDenied() { + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + assertFalse(AgentPolicy.getInstance().isChainThatCanExit(Set.of(String.class))); + } + + @Test + public void testApprovedExitChainPermitted() { + List exitCallers = + List.of( + new ApprovedCallSite( + SolrAgentIntegrationTest.class.getName(), + ApprovedCallSite.Operation.EXIT, + PolicySource.DEFAULT)); + buildEnforcePolicy(List.of(), List.of(), exitCallers, List.of()); + assertTrue( + AgentPolicy.getInstance().isChainThatCanExit(Set.of(SolrAgentIntegrationTest.class))); + } + + // --------------------------------------------------------------------------- + // ProcessBuilder tests + // --------------------------------------------------------------------------- + + @Test(expected = SecurityException.class) + public void testUnapprovedProcessExecThrowsInEnforceMode() { + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + ProcessExecInterceptor.checkExec("ProcessBuilder.start()"); + } + + @Test + public void testUnapprovedProcessExecIncrementsCounter() { + long before = ViolationMetricsReporter.execCount(); + buildEnforcePolicy(List.of(), List.of(), List.of(), List.of()); + try { + ProcessExecInterceptor.checkExec("ProcessBuilder.start()"); + } catch (SecurityException ignored) { + // expected + } + assertEquals(before + 1, ViolationMetricsReporter.execCount()); + } +} diff --git a/solr/agent-sm/src/test/org/apache/solr/security/agent/SymlinkEscapeTest.java b/solr/agent-sm/src/test/org/apache/solr/security/agent/SymlinkEscapeTest.java new file mode 100644 index 000000000000..d9efad14d0c0 --- /dev/null +++ b/solr/agent-sm/src/test/org/apache/solr/security/agent/SymlinkEscapeTest.java @@ -0,0 +1,163 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.apache.solr.SolrTestCase; +import org.junit.After; +import org.junit.Assume; +import org.junit.Test; + +/** + * Tests that symlink-escape attacks are blocked by {@link FileInterceptor}. + * + *

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 exitCallers = + approved + ? List.of( + new ApprovedCallSite( + callerClass, ApprovedCallSite.Operation.EXIT, PolicySource.DEFAULT)) + : List.of(); + AgentPolicy policy = new AgentPolicy(List.of(), List.of(), exitCallers, List.of(), mode); + AgentPolicy.initialize(policy); + return policy; + } + + @Test + public void testApprovedCallerInChainPermits() { + AgentPolicy policy = + initPolicy( + true, SystemExitInterceptorTest.class.getName(), AgentPolicy.EnforcementMode.ENFORCE); + Collection> chain = Set.of(SystemExitInterceptorTest.class); + assertTrue(policy.isChainThatCanExit(chain)); + } + + @Test + public void testUnapprovedCallerNotInChainDenies() { + AgentPolicy policy = initPolicy(false, "some.other.Class", AgentPolicy.EnforcementMode.ENFORCE); + Collection> chain = Set.of(SystemExitInterceptorTest.class); + assertFalse(policy.isChainThatCanExit(chain)); + } + + @Test + public void testApprovedCallerAnywhereInChainPermits() { + AgentPolicy policy = + initPolicy( + true, SystemExitInterceptorTest.class.getName(), AgentPolicy.EnforcementMode.ENFORCE); + // Approved class is deep in the chain — should still grant permission + Collection> chain = + Set.of(String.class, Integer.class, SystemExitInterceptorTest.class); + assertTrue(policy.isChainThatCanExit(chain)); + } + + @Test + public void testWildcardPatternMatchesPackage() { + AgentPolicy.resetForTesting(); + List exitCallers = + List.of( + new ApprovedCallSite( + "org.apache.solr.security.agent.*", + ApprovedCallSite.Operation.EXIT, + PolicySource.DEFAULT)); + AgentPolicy policy = + new AgentPolicy( + List.of(), List.of(), exitCallers, List.of(), AgentPolicy.EnforcementMode.ENFORCE); + AgentPolicy.initialize(policy); + + Collection> chain = Set.of(SystemExitInterceptorTest.class); + assertTrue(policy.isChainThatCanExit(chain)); + } + + @Test + public void testEmptyChainDenies() { + AgentPolicy policy = + initPolicy( + true, SystemExitInterceptorTest.class.getName(), AgentPolicy.EnforcementMode.ENFORCE); + assertFalse(policy.isChainThatCanExit(Set.of())); + } +} diff --git a/solr/bin/solr b/solr/bin/solr index eaa417ca90d0..f62eb0c9c6d8 100755 --- a/solr/bin/solr +++ b/solr/bin/solr @@ -1166,6 +1166,18 @@ else SECURITY_MANAGER_OPTS=() fi +# Security agent: detect agent JAR and add -javaagent: if present and not skipped. +# Set SOLR_SECURITY_AGENT_SKIP=true to disable the agent (for troubleshooting only). +# Agent reads its configuration (SOLR_SECURITY_AGENT_MODE, SOLR_SECURITY_AGENT_EXTRA_POLICY, +# and policy file variables like SOLR_HOME) directly from env vars — no -D flags needed. +AGENT_SM_OPTS=() +if [ "${SOLR_SECURITY_AGENT_SKIP:-false}" != "true" ]; then + AGENT_JAR=$(ls "${SOLR_SERVER_DIR}/lib/ext/solr-agent-sm-"*.jar 2>/dev/null | head -1) + if [ -n "$AGENT_JAR" ]; then + AGENT_SM_OPTS+=("-javaagent:${AGENT_JAR}") + fi +fi + JAVA_MEM_OPTS=() if [ -z "${SOLR_HEAP:-}" ] && [ -n "${SOLR_JAVA_MEM:-}" ]; then JAVA_MEM_OPTS=($SOLR_JAVA_MEM) @@ -1315,7 +1327,7 @@ function start_solr() { # OOME is thrown. Program operation after OOME is unpredictable. "-XX:+CrashOnOutOfMemoryError" "-XX:ErrorFile=${SOLR_LOGS_DIR}/jvm_crash_%p.log" \ "-Djetty.home=$SOLR_SERVER_DIR" "-Dsolr.solr.home=$SOLR_HOME" "-Dsolr.install.dir=$SOLR_TIP" "-Dsolr.install.symDir=$SOLR_TIP_SYM" \ - "${LOG4J_CONFIG[@]}" "${SCRIPT_SOLR_OPTS[@]}" "${SECURITY_MANAGER_OPTS[@]}" "${SOLR_OPTS[@]}") + "${AGENT_SM_OPTS[@]}" "${LOG4J_CONFIG[@]}" "${SCRIPT_SOLR_OPTS[@]}" "${SECURITY_MANAGER_OPTS[@]}" "${SOLR_OPTS[@]}") mk_writable_dir "$SOLR_LOGS_DIR" "Logs" if [[ -n "${SOLR_HEAP_DUMP_DIR:-}" ]]; then diff --git a/solr/bin/solr.cmd b/solr/bin/solr.cmd index e30cc4416fa8..58d39c8c4452 100755 --- a/solr/bin/solr.cmd +++ b/solr/bin/solr.cmd @@ -1021,6 +1021,18 @@ IF "%GC_TUNE%"=="" ( -XX:+ExplicitGCInvokesConcurrent ) +REM Security agent: detect agent JAR and add -javaagent: if present and not skipped. +REM Set SOLR_SECURITY_AGENT_SKIP=true to disable the agent (for troubleshooting only). +REM Agent reads its configuration (SOLR_SECURITY_AGENT_MODE, SOLR_SECURITY_AGENT_EXTRA_POLICY, +REM and policy file variables like SOLR_HOME) directly from env vars -- no -D flags needed. +set AGENT_SM_OPTS= +IF NOT "%SOLR_SECURITY_AGENT_SKIP%"=="true" ( + IF EXIST "%SOLR_SERVER_DIR%\lib\ext\solr-agent-sm-*.jar" ( + FOR %%F IN ("%SOLR_SERVER_DIR%\lib\ext\solr-agent-sm-*.jar") DO SET "AGENT_JAR=%%F" + SET "AGENT_SM_OPTS=-javaagent:!AGENT_JAR!" + ) +) + REM Add vector optimizations module set SCRIPT_SOLR_OPTS=%SCRIPT_SOLR_OPTS% --add-modules jdk.incubator.vector @@ -1096,6 +1108,7 @@ IF NOT "%SOLR_ADDL_ARGS%"=="" set "START_OPTS=%START_OPTS% %SOLR_ADDL_ARGS%" IF NOT "%SOLR_HOST_ADVERTISE_ARG%"=="" set "START_OPTS=%START_OPTS% %SOLR_HOST_ADVERTISE_ARG%" IF NOT "%SCRIPT_SOLR_OPTS%"=="" set "START_OPTS=%START_OPTS% %SCRIPT_SOLR_OPTS%" IF NOT "%SOLR_OPTS_INTERNAL%"=="" set "START_OPTS=%START_OPTS% %SOLR_OPTS_INTERNAL%" +IF NOT "!AGENT_SM_OPTS!"=="" set "START_OPTS=!AGENT_SM_OPTS! %START_OPTS%" IF NOT "!SECURITY_MANAGER_OPTS!"=="" set "START_OPTS=%START_OPTS% !SECURITY_MANAGER_OPTS!" IF "%SOLR_SSL_ENABLED%"=="true" ( set "SSL_PORT_PROP=-Dsolr.jetty.https.port=%SOLR_PORT_LISTEN%" diff --git a/solr/bin/solr.in.cmd b/solr/bin/solr.in.cmd index 81be993aa47b..0ad8cc5ec27c 100755 --- a/solr/bin/solr.in.cmd +++ b/solr/bin/solr.in.cmd @@ -221,6 +221,21 @@ REM You can also tweak via standard JDK files such as ~\.java.policy, see https: REM This is experimental! REM set SOLR_SECURITY_MANAGER_ENABLED=true +REM --- Solr Security Agent --- +REM The Solr security agent replaces the deprecated Java Security Manager with ByteBuddy-based +REM enforcement. The agent JAR is auto-detected at server\lib\ext\solr-agent-sm-*.jar and activated +REM automatically. See the Solr Reference Guide for full documentation. + +REM Enforcement mode: "warn" (log violations, allow operation — default) or +REM "enforce" (log violations, block operation with SecurityException). +REM set SOLR_SECURITY_AGENT_MODE=warn + +REM Set to "true" to completely disable the security agent (for troubleshooting only). +REM set SOLR_SECURITY_AGENT_SKIP=false + +REM Path to the operator extension policy file (default: server\etc\agent-security-extra.policy). +REM set SOLR_SECURITY_AGENT_EXTRA_POLICY=C:\path\to\custom-agent-security-extra.policy + REM This variable provides you with the option to disable the Admin UI. if you uncomment the variable below and REM change the value to false. The option is configured as a system property as defined in SOLR_START_OPTS in the start REM scripts. diff --git a/solr/bin/solr.in.sh b/solr/bin/solr.in.sh index 569066fede45..ea7d442c7f6e 100644 --- a/solr/bin/solr.in.sh +++ b/solr/bin/solr.in.sh @@ -247,6 +247,24 @@ # This is experimental! #SOLR_SECURITY_MANAGER_ENABLED=true +# --- Solr Security Agent --- +# The Solr security agent replaces the deprecated Java Security Manager with ByteBuddy-based +# enforcement. The agent JAR is auto-detected at server/lib/ext/solr-agent-sm-*.jar and activated +# automatically. See the Solr Reference Guide for full documentation. + +# Enforcement mode: "warn" (log violations, allow operation — default) or +# "enforce" (log violations, block operation with SecurityException). +#SOLR_SECURITY_AGENT_MODE=warn + +# Set to "true" to completely disable the security agent (for troubleshooting only). +# Without the agent, no security controls are active. +#SOLR_SECURITY_AGENT_SKIP=false + +# Path to the operator extension policy file. +# Defaults to ${server.dir}/etc/agent-security-extra.policy when unset. +# Override to support read-only install trees, container images, or config-management tooling. +#SOLR_SECURITY_AGENT_EXTRA_POLICY=/path/to/custom-agent-security-extra.policy + # This variable provides you with the option to disable the Admin UI. If you uncomment the variable below and # change the value to false. The option is configured as a system property as defined in SOLR_START_OPTS in the start # scripts. diff --git a/solr/core/src/java/org/apache/solr/core/SolrPaths.java b/solr/core/src/java/org/apache/solr/core/SolrPaths.java index dc2c245eada1..4e61f6f03a32 100644 --- a/solr/core/src/java/org/apache/solr/core/SolrPaths.java +++ b/solr/core/src/java/org/apache/solr/core/SolrPaths.java @@ -61,7 +61,10 @@ public static String normalizeDir(String path) { * @param pathToAssert path to check * @param allowPaths list of paths that should be allowed prefixes for pathToAssert * @throws SolrException if path is outside allowed paths + * @deprecated Automatic enforcement via the security agent supersedes this check; do not add new + * call sites. Existing call sites are retained as defense-in-depth. */ + @Deprecated public static void assertPathAllowed(Path pathToAssert, Set allowPaths) throws SolrException { if (ALL_PATHS.equals(allowPaths)) return; // Catch-all allows all paths (*/_ALL_) diff --git a/solr/core/src/java/org/apache/solr/security/AgentViolationBridge.java b/solr/core/src/java/org/apache/solr/security/AgentViolationBridge.java new file mode 100644 index 000000000000..35434be28592 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/AgentViolationBridge.java @@ -0,0 +1,57 @@ +/* + * 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; + +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Wires the Solr security agent's violation reporter to SLF4J once Log4j2 is available. Uses + * reflection because {@code solr:agent-sm} and {@code solr:core} have no compile-time dependency on + * each other. Call {@link #wire()} from {@code CoreContainer} after Log4j2 is initialised; from + * that point violations appear in {@code solr.log} rather than {@code System.err}. Safe to call + * when the agent JAR is absent — {@link ClassNotFoundException} is silently ignored. + */ +public final class AgentViolationBridge { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private AgentViolationBridge() {} + + /** + * Wires the security agent violation reporter to SLF4J. No-op if the agent JAR is not present. + */ + public static void wire() { + try { + // null = bootstrap classloader (Boot-Class-Path); avoids delegation gaps in containerised + // envs. + Class cls = + Class.forName("org.apache.solr.security.agent.SecurityViolationLogger", false, null); + Method setter = cls.getMethod("setReporter", Consumer.class); + Consumer bridge = msg -> log.warn("SECURITY VIOLATION {}", msg); + setter.invoke(null, bridge); + log.info("Security agent violation reporter wired to SLF4J"); + } catch (ClassNotFoundException e) { + // Agent JAR not loaded — nothing to wire. + } catch (Exception e) { + log.warn("Could not wire security agent violation reporter to SLF4J", e); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/security/AgentViolationMetrics.java b/solr/core/src/java/org/apache/solr/security/AgentViolationMetrics.java new file mode 100644 index 000000000000..864ee7caeec7 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/AgentViolationMetrics.java @@ -0,0 +1,100 @@ +/* + * 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; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.ObservableLongMeasurement; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.function.Consumer; +import org.apache.solr.metrics.SolrMetricManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Registers the security-agent violation counter with {@link SolrMetricManager}. + * + *

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: + * + *

+ *   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 TYPE_KEY = AttributeKey.stringKey("type"); + + private AgentViolationMetrics() {} + + /** + * Registers the violation counter. No-op if the agent JAR is not present. + * + * @param metricManager the live {@link SolrMetricManager} + * @param registryName the target registry (e.g. {@code "solr.node"}) + */ + public static void register(SolrMetricManager metricManager, String registryName) { + try { + // ViolationMetricsReporter is in the bootstrap classloader via Boot-Class-Path. + Class reporter = + Class.forName("org.apache.solr.security.agent.ViolationMetricsReporter", false, null); + Method fileCount = reporter.getMethod("fileCount"); + Method networkCount = reporter.getMethod("networkCount"); + Method exitCount = reporter.getMethod("exitCount"); + Method execCount = reporter.getMethod("execCount"); + + Attributes fileAttrs = Attributes.of(TYPE_KEY, "file"); + Attributes networkAttrs = Attributes.of(TYPE_KEY, "network"); + Attributes exitAttrs = Attributes.of(TYPE_KEY, "exit"); + Attributes execAttrs = Attributes.of(TYPE_KEY, "exec"); + + Consumer callback = + measurement -> { + try { + measurement.record((long) fileCount.invoke(null), fileAttrs); + measurement.record((long) networkCount.invoke(null), networkAttrs); + measurement.record((long) exitCount.invoke(null), exitAttrs); + measurement.record((long) execCount.invoke(null), execAttrs); + } catch (ReflectiveOperationException ignored) { + // Should never happen — these are simple no-arg static methods. + } + }; + + metricManager.observableLongCounter( + registryName, + METRIC_NAME, + "Security agent violation count by type (file, network, exit, exec).", + callback, + null); + + log.debug("Security agent violation metrics registered under registry '{}'", registryName); + } catch (ClassNotFoundException ignored) { + // Agent JAR not loaded — nothing to register. + } catch (Exception e) { + log.warn("Failed to register security agent violation metrics", e); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/servlet/CoreContainerProvider.java b/solr/core/src/java/org/apache/solr/servlet/CoreContainerProvider.java index 19d06f392b7a..f0ad47d65946 100644 --- a/solr/core/src/java/org/apache/solr/servlet/CoreContainerProvider.java +++ b/solr/core/src/java/org/apache/solr/servlet/CoreContainerProvider.java @@ -46,6 +46,9 @@ import org.apache.solr.core.NodeConfig; import org.apache.solr.core.SolrCore; import org.apache.solr.core.SolrXmlConfig; +import org.apache.solr.metrics.SolrMetricManager; +import org.apache.solr.security.AgentViolationBridge; +import org.apache.solr.security.AgentViolationMetrics; import org.apache.solr.util.StartupLoggingUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -179,6 +182,8 @@ private void init(ServletContext servletContext) { }); coresInit = createCoreContainer(computeSolrHome(servletContext), extraProperties); + AgentViolationMetrics.register(coresInit.getMetricManager(), SolrMetricManager.NODE_REGISTRY); + AgentViolationBridge.wire(); if (log.isDebugEnabled()) { log.debug("user.dir={}", System.getProperty("user.dir")); diff --git a/solr/docker/build.gradle b/solr/docker/build.gradle index e29b892594b2..dc6163c6b742 100644 --- a/solr/docker/build.gradle +++ b/solr/docker/build.gradle @@ -253,6 +253,11 @@ task testDocker(type: TestDockerImageTask, dependsOn: tasks.dockerBuild) { group = 'Docker' description = 'Test Solr docker image built from the local Dockerfile' + // Docker containers run as uid 8983 (solr user) and create files that the Gradle runner + // (a different uid) cannot read. Disabling state-tracking avoids an AccessDeniedException + // when Gradle tries to inspect the output directory for incremental-build purposes. + doNotTrackState("Docker containers produce uid-8983 owned files unreadable by the Gradle process") + idFile = tasks.dockerBuild.outputs.files.singleFile outputDir = file("$buildDir/test-results") diff --git a/solr/docker/tests/cases/empty-varsolr-dir-solr/test.sh b/solr/docker/tests/cases/empty-varsolr-dir-solr/test.sh index 1408ad4d704d..b26f451c7696 100755 --- a/solr/docker/tests/cases/empty-varsolr-dir-solr/test.sh +++ b/solr/docker/tests/cases/empty-varsolr-dir-solr/test.sh @@ -46,8 +46,9 @@ container_cleanup "$container_name" ls -l "$myvarsolr"/ -# remove the solr-owned files from inside a container +# remove the solr-owned files from inside a container (as root so UID 8983-owned dirs are deletable) docker run --rm -e VERBOSE=yes \ + --user root \ -v "$myvarsolr:/myvarsolr" "$tag" \ bash -c "rm -fr /myvarsolr/*" diff --git a/solr/packaging/build.gradle b/solr/packaging/build.gradle index 7320f2ebe2b2..b27e23d6b19d 100644 --- a/solr/packaging/build.gradle +++ b/solr/packaging/build.gradle @@ -266,6 +266,7 @@ task downloadBats(type: NpmTask) { task integrationTests(type: BatsTask) { dependsOn installFullDist dependsOn downloadBats + dependsOn ':solr:agent-sm:testProgramsJar' def integrationTestOutput = "$buildDir/test-output" def solrHome = "$integrationTestOutput/solr-home" @@ -298,6 +299,7 @@ task integrationTests(type: BatsTask) { } environment SOLR_TIP: distDir.toString() + environment AGENT_TEST_PROGRAMS_JAR: project(':solr:agent-sm').tasks.named('testProgramsJar').get().archiveFile.get().asFile environment SOLR_HOME: solrHome environment SOLR_PID_DIR: solrHome environment SOLR_PORT_LISTEN: solrPort diff --git a/solr/packaging/test/test_security_agent.bats b/solr/packaging/test/test_security_agent.bats new file mode 100644 index 000000000000..a3f152f9a2dd --- /dev/null +++ b/solr/packaging/test/test_security_agent.bats @@ -0,0 +1,191 @@ +#!/usr/bin/env bats + +# 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. + +# Integration tests for the Solr security agent (SOLR-17767). +# +# These tests exercise the full agent lifecycle against a real running Solr +# distribution, covering: +# - ByteBuddy -javaagent: injection via startup script JAR detection +# - All three env vars (SOLR_SECURITY_AGENT_SKIP, SOLR_SECURITY_AGENT_MODE, +# SOLR_SECURITY_AGENT_EXTRA_POLICY) and their conversion to JVM system properties +# - Violation counter registration via CoreContainer → SolrMetricManager +# +# The Java Security Manager must be explicitly disabled (SOLR_SECURITY_MANAGER_ENABLED=false) +# so it does not interfere with the agent's own ByteBuddy interceptors. JSM is enabled +# by default in bin/solr for Java < 24; we disable it here to test our agent in isolation. + +load bats_helper + +setup() { + common_clean_setup + # Disable the Java Security Manager so it does not interfere with the + # ByteBuddy agent's own interception of JDK methods. + export SOLR_SECURITY_MANAGER_ENABLED=false +} + +teardown() { + save_home_on_failure + solr stop --all >/dev/null 2>&1 +} + +# Helper: path to the console log for the primary Solr port. +console_log() { + echo "${SOLR_LOGS_DIR}/solr-${SOLR_PORT}-console.log" +} + +@test "agent is active by default in warn mode and registers violation metrics" { + solr start --user-managed + solr assert --started http://localhost:${SOLR_PORT} --timeout 5000 + + # Confirms: JAR detected in lib/ext/, -javaagent: injected, premain ran + run grep "Security agent active" "$(console_log)" + assert_success + + # Confirms: default enforcement mode is WARN (no SOLR_SECURITY_AGENT_MODE set) + assert_output --partial "mode=WARN" + + # Confirms: CoreContainer reflective call to ViolationMetricsReporter registered counters. + # A single labeled counter solr.security.agent.violations appears in Prometheus format as + # solr_security_agent_violations_total{...type="file"...} etc. OTel may add extra labels + # (e.g. otel_scope_name), so we assert on the base metric name only. + run curl -sf "http://localhost:${SOLR_PORT}/solr/admin/metrics" + assert_success + assert_output --partial 'solr_security_agent_violations_total' +} + +@test "SOLR_SECURITY_AGENT_SKIP=true disables the agent" { + export SOLR_SECURITY_AGENT_SKIP=true + solr start --user-managed + solr assert --started http://localhost:${SOLR_PORT} --timeout 5000 + + # Confirms: skip-escape-hatch in bin/solr suppressed -javaagent: entirely + run cat "$(console_log)" + refute_output --partial "Security agent active" +} + +@test "SOLR_SECURITY_AGENT_MODE=enforce and SOLR_SECURITY_AGENT_EXTRA_POLICY are passed through" { + # Write a minimal extra policy with one additional FilePermission grant. + # This exercises the env-var → -Dsolr.security.agent.extra.policy sysprop path. + local extra_policy="${BATS_TEST_TMPDIR}/extra.policy" + cat > "${extra_policy}" <<'EOF' +// Operator extension policy used by BATS test_security_agent.bats +grant { + permission java.io.FilePermission "/tmp/bats-agent-test/-", "read"; +}; +EOF + + export SOLR_SECURITY_AGENT_MODE=enforce + export SOLR_SECURITY_AGENT_EXTRA_POLICY="${extra_policy}" + solr start --user-managed + solr assert --started http://localhost:${SOLR_PORT} --timeout 5000 + + # Confirms: SOLR_SECURITY_AGENT_MODE → -Dsolr.security.agent.mode → premain reads it + run grep "Security agent active" "$(console_log)" + assert_success + assert_output --partial "mode=ENFORCE" + + # Confirms: SOLR_SECURITY_AGENT_EXTRA_POLICY was passed as sysprop and the extra + # policy was loaded — permitted paths count is higher than the default-only count + # (the extra.policy file adds 1 path on top of the bundled agent-security.policy). + local with_extra + with_extra=$(grep -oE "permitted paths=[0-9]+" "$(console_log)" | grep -oE "[0-9]+" | head -1) + [ "${with_extra:-0}" -gt 0 ] +} + +@test "enforce mode blocks unauthorized file access with SecurityException" { + local agent_jar + agent_jar=$(ls "${SOLR_TIP}/server/lib/ext/solr-agent-sm-"*.jar) + + # FileViolation reads /etc/hosts — outside every path the default policy permits. + # The class is pre-compiled into AGENT_TEST_PROGRAMS_JAR by the Gradle build. + run java \ + -javaagent:"${agent_jar}" \ + -Dsolr.security.agent.mode=enforce \ + -Dsolr.install.dir="${SOLR_TIP}" \ + -Dsolr.solr.home="${SOLR_TIP}/server/solr" \ + -Dsolr.logs.dir="${BATS_TEST_TMPDIR}" \ + -Dsolr.port.listen=8983 \ + -cp "${AGENT_TEST_PROGRAMS_JAR}" \ + FileViolation + + assert_failure + assert_output --partial "SecurityException" + refute_output --partial "read succeeded" +} + +@test "enforce mode blocks System.exit with SecurityException" { + local agent_jar + agent_jar=$(ls "${SOLR_TIP}/server/lib/ext/solr-agent-sm-"*.jar) + + # ExitViolation calls System.exit(123) — no exitVM grant in the default policy. + # Exit code 123 is a sentinel: if the process exits with that exact code, the agent + # did NOT block the call and System.exit(123) ran unimpeded. + run java \ + -javaagent:"${agent_jar}" \ + -Dsolr.security.agent.mode=enforce \ + -Dsolr.install.dir="${SOLR_TIP}" \ + -Dsolr.solr.home="${SOLR_TIP}/server/solr" \ + -Dsolr.logs.dir="${BATS_TEST_TMPDIR}" \ + -Dsolr.port.listen=8983 \ + -cp "${AGENT_TEST_PROGRAMS_JAR}" \ + ExitViolation + + assert_failure + assert_output --partial "SecurityException" + [ "$status" -ne 123 ] # status 123 means System.exit(123) ran — agent did NOT block +} + +@test "enforce mode blocks unauthorized outbound connection with SecurityException" { + local agent_jar + agent_jar=$(ls "${SOLR_TIP}/server/lib/ext/solr-agent-sm-"*.jar) + + # NetworkViolation opens a SocketChannel to 192.0.2.1:443 (TEST-NET-1, RFC 5737 — + # guaranteed non-routable). The interceptor fires before any TCP I/O, so this is instant. + run java \ + -javaagent:"${agent_jar}" \ + -Dsolr.security.agent.mode=enforce \ + -Dsolr.install.dir="${SOLR_TIP}" \ + -Dsolr.solr.home="${SOLR_TIP}/server/solr" \ + -Dsolr.logs.dir="${BATS_TEST_TMPDIR}" \ + -Dsolr.port.listen=8983 \ + -cp "${AGENT_TEST_PROGRAMS_JAR}" \ + NetworkViolation + + assert_failure + assert_output --partial "SecurityException" + refute_output --partial "connect succeeded" +} + +@test "enforce mode blocks process exec with SecurityException" { + local agent_jar + agent_jar=$(ls "${SOLR_TIP}/server/lib/ext/solr-agent-sm-"*.jar) + + # ExecViolation spawns a child process via ProcessBuilder — no exec grant in default policy. + run java \ + -javaagent:"${agent_jar}" \ + -Dsolr.security.agent.mode=enforce \ + -Dsolr.install.dir="${SOLR_TIP}" \ + -Dsolr.solr.home="${SOLR_TIP}/server/solr" \ + -Dsolr.logs.dir="${BATS_TEST_TMPDIR}" \ + -Dsolr.port.listen=8983 \ + -cp "${AGENT_TEST_PROGRAMS_JAR}" \ + ExecViolation + + assert_failure + assert_output --partial "SecurityException" + refute_output --partial "exec succeeded" +} diff --git a/solr/server/build.gradle b/solr/server/build.gradle index a22c084d94b0..771939f8b1ec 100644 --- a/solr/server/build.gradle +++ b/solr/server/build.gradle @@ -62,6 +62,12 @@ dependencies { serverLib libs.eclipse.jetty.http2.hpack serverLib libs.jakarta.servlet.api + // Security agent JAR — auto-detected by bin/solr and loaded via -javaagent:. + // transitive=false because ByteBuddy and SLF4J are already bundled inside the fat JAR. + libExt(project(path: ':solr:agent-sm'), { + transitive false + }) + libExt libs.lmax.disruptor libExt libs.slf4j.jcloverslf4j libExt libs.slf4j.jultoslf4j diff --git a/solr/server/etc/agent-security-extra.policy b/solr/server/etc/agent-security-extra.policy new file mode 100644 index 000000000000..ba512e144139 --- /dev/null +++ b/solr/server/etc/agent-security-extra.policy @@ -0,0 +1,56 @@ +/* + * 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 — operator extension policy +// +// This file is loaded in addition to the bundled default policy (agent-security.policy). +// Add entries here to permit additional file paths or network endpoints required by your +// specific deployment, plugins, or custom components. +// +// This file's location can be overridden via: +// - Environment variable: SOLR_SECURITY_AGENT_EXTRA_POLICY=/path/to/custom.policy +// - System property: -Dsolr.security.agent.extra.policy=/path/to/custom.policy +// +// An absent file is silently skipped — it is not a startup error. +// +// Syntax: JDK-style .policy files (same as agent-security.policy). +// Supported variables: ${solr.solr.home}, ${solr.data.home}, ${solr.logs.dir}, +// ${solr.install.dir}, ${solr.install.symDir}, +// ${java.io.tmpdir}, ${java.home}, +// ${solr.port.listen}, ${solr.zk.port} +// +// For documentation and examples, see the Solr Reference Guide: Security Agent. +// +// --------------------------------------------------------------------------- +// EXAMPLES (uncomment and adapt as needed) +// --------------------------------------------------------------------------- + +// Example: permit a plugin that reads from an NFS mount +// grant { +// permission java.io.FilePermission "/mnt/nfs-data/-", "read"; +// }; + +// Example: permit a plugin that connects to an internal analytics service +// grant { +// permission java.net.SocketPermission "analytics.internal:443", "connect,resolve"; +// }; + +// Example: extraction module — remote Tika Server (see NOTICE in the extraction module docs) +// WARNING: Use a specific hostname:port, not a wildcard, to prevent SSRF attacks. +// grant { +// permission java.net.SocketPermission "tika-server.internal:9998", "connect,resolve"; +// }; diff --git a/solr/server/etc/agent-security.policy b/solr/server/etc/agent-security.policy new file mode 100644 index 000000000000..28f28386c102 --- /dev/null +++ b/solr/server/etc/agent-security.policy @@ -0,0 +1,135 @@ +/* + * 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 — default production policy +// +// This file is the bundled default policy for the Solr security agent. It should not be edited +// by operators. To grant additional permissions for your deployment, use the operator extension +// file at: ${server.dir}/etc/agent-security-extra.policy (or the path set by +// SOLR_SECURITY_AGENT_EXTRA_POLICY / -Dsolr.security.agent.extra.policy). +// +// Syntax: JDK-style .policy files with Solr variable substitution. +// Supported variables: ${solr.solr.home}, ${solr.data.home}, ${solr.logs.dir}, ${solr.install.dir}, +// ${solr.install.symDir}, ${java.io.tmpdir}, ${java.home}, +// ${solr.port.listen}, ${solr.zk.port} +// +// For documentation see the Solr Reference Guide: Security Agent. + +// --------------------------------------------------------------------------- +// Global grant — applies to all code running in the Solr JVM +// --------------------------------------------------------------------------- +grant { + + // --- File system access --- + + // Solr installation directory: read-only (JARs, modules, Jetty config, etc.) + // Both the real path and the symlinked path are permitted so that installations where + // /opt/solr -> /opt/solr-X.Y.Z/ work correctly regardless of which form is presented to + // FileInterceptor. When the Old Java SecurityManager is active, toRealPath() may fall back + // to toAbsolutePath().normalize(), producing the symlinked form; both entries are needed. + permission java.io.FilePermission "${solr.install.dir}/-", "read"; + permission java.io.FilePermission "${solr.install.symDir}/-", "read"; + + // Solr home: read-only (config files, schema, solrconfig.xml, etc.) + permission java.io.FilePermission "${solr.solr.home}/-", "read"; + + // Solr data and index directories: full read/write/delete + permission java.io.FilePermission "${solr.data.home}/-", "read,write,delete"; + + // Log directory: write access for log files + permission java.io.FilePermission "${solr.logs.dir}/-", "read,write,delete"; + + // Temporary files: full access (sort buffers, merged segments, etc.) + permission java.io.FilePermission "${java.io.tmpdir}/-", "read,write,delete"; + + // JDK runtime libraries: read-only (class loading, native libs, etc.) + permission java.io.FilePermission "${java.home}/-", "read"; + + // Linux pseudo-filesystems: read-only access for JVM internals (e.g. memory-mapping checks, + // network configuration, kernel parameters). These paths do not exist on macOS/Windows and + // their presence in a cross-platform policy is harmless. + permission java.io.FilePermission "/proc/-", "read"; + permission java.io.FilePermission "/sys/-", "read"; + + // --- RuntimePermission: exitVM / exec --- + // + // No exitVM grants are present in this default policy. This is intentional: + // - The Solr CLI tools (bin/solr foo bar) are not launched with -javaagent and therefore + // do not need an exitVM grant. + // - The running Solr backend (Jetty) is expected to shut down via graceful thread completion + // (ShutdownHook, server.stop()), not via System.exit(). Calling System.exit() from a plugin + // is the threat model, not the server itself. + // - In WARN mode (the default) any unapproved System.exit() call is logged but permitted. + // In ENFORCE mode it is blocked. Operators who need to allow a specific JAR or class to call + // System.exit() should add a codeBase-scoped RuntimePermission "exitVM" grant in + // agent-security-extra.policy. + // + // Similarly, no exec grants are present because the Solr server does not spawn child processes + // in its normal operation. Plugin code that needs process spawning must be explicitly granted. + + // --- Outbound network access --- + + // Loopback addresses: unconditionally permitted (inter-thread, localhost HTTP, embedded ZK) + permission java.net.SocketPermission "localhost:1-65535", "connect,resolve"; + permission java.net.SocketPermission "127.0.0.1:1-65535", "connect,resolve"; + permission java.net.SocketPermission "[::1]:1-65535", "connect,resolve"; + + // Intra-cluster connectivity + // Any host on the Solr HTTP port is permitted, covering all current and future cluster nodes + // without requiring a static node list at startup. Similarly for the ZooKeeper port. + permission java.net.SocketPermission "*:${solr.port.listen}", "connect,resolve"; + permission java.net.SocketPermission "*:${solr.zk.port}", "connect,resolve"; + +}; + +// --------------------------------------------------------------------------- +// Per-module codeBase grants for bundled modules that make outbound network calls +// +// Each of the five pre-permitted modules below has its external endpoint controlled exclusively +// by a node or cluster administrator (not by collection-level config), making a wildcard grant +// safe from SSRF abuse. +// +// NOTE: The 'extraction' module is intentionally excluded because its Tika Server URL is +// configurable at CONFIG_EDIT privilege level (collection admin). Operators using remote Tika +// must add an explicit host-locked entry in agent-security-extra.policy. See the extraction +// module documentation for a ready-to-paste example. +// --------------------------------------------------------------------------- + +// jwt-auth: OIDC/JWKS endpoint configured via security.json (requires Solr SECURITY_EDIT admin) +grant codeBase "file:${solr.install.dir}/modules/jwt-auth/-" { + permission java.net.SocketPermission "*", "connect,resolve"; +}; + +// opentelemetry: OTLP collector endpoint configured via env var or solr.xml (node admin only) +grant codeBase "file:${solr.install.dir}/modules/opentelemetry/-" { + permission java.net.SocketPermission "*", "connect,resolve"; +}; + +// s3-repository: Amazon S3 / S3-compatible endpoint configured via solr.xml backup handler (node admin) +grant codeBase "file:${solr.install.dir}/modules/s3-repository/-" { + permission java.net.SocketPermission "*", "connect,resolve"; +}; + +// gcs-repository: Google Cloud Storage endpoint configured via solr.xml backup handler (node admin) +grant codeBase "file:${solr.install.dir}/modules/gcs-repository/-" { + permission java.net.SocketPermission "*", "connect,resolve"; +}; + +// cross-dc-manager: Apache Kafka brokers configured via env var / system property / ZooKeeper (admin) +grant codeBase "file:${solr.install.dir}/modules/cross-dc-manager/-" { + permission java.net.SocketPermission "*", "connect,resolve"; +}; diff --git a/solr/solr-ref-guide/gradle.lockfile b/solr/solr-ref-guide/gradle.lockfile index 3554c306848a..a59ce7a77aba 100644 --- a/solr/solr-ref-guide/gradle.lockfile +++ b/solr/solr-ref-guide/gradle.lockfile @@ -176,4 +176,4 @@ org.semver4j:semver4j:6.0.0=testRuntimeClasspath org.slf4j:jcl-over-slf4j:2.0.17=testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=testCompileClasspath,testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.8=testRuntimeClasspath -empty=apiHelper,apiHelperTest,compileClasspath,compileOnlyHelper,compileOnlyHelperTest,jarValidation,localPlaybook,missingdoclet,officialPlaybook,permitAggregatorUse,permitTestAggregatorUse,permitTestUnusedDeclared,permitTestUsedUndeclared,permitUnusedDeclared,permitUsedUndeclared,refGuide,runtimeClasspath +empty=apiHelper,apiHelperTest,compileClasspath,compileClasspathCopy,compileOnlyHelper,compileOnlyHelperTest,jarValidation,localPlaybook,missingdoclet,officialPlaybook,permitAggregatorUse,permitTestAggregatorUse,permitTestUnusedDeclared,permitTestUsedUndeclared,permitUnusedDeclared,permitUsedUndeclared,refGuide,runtimeClasspath,runtimeClasspathCopy,testCompileClasspathCopy,testRuntimeClasspathCopy diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/solr-properties.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/solr-properties.adoc index b9ecaddb1002..0dd02cea779f 100644 --- a/solr/solr-ref-guide/modules/configuration-guide/pages/solr-properties.adoc +++ b/solr/solr-ref-guide/modules/configuration-guide/pages/solr-properties.adoc @@ -92,6 +92,12 @@ NOTE: Properties marked with "!" indicate inverted meaning between pre Solr 10 a |solr.responses.stacktrace.enabled|!solr.hideStackTrace|false|Controls whether stack traces are included in responses. When set to `true`, stack traces are included in responses. +|solr.security.agent.extra.policy||`${server.dir}/etc/agent-security-extra.policy`|Path to the operator extension policy file for the Solr security agent. Overrides the default location. An absent file is silently skipped. + +|solr.security.agent.mode||`warn`|Enforcement mode for the Solr security agent: `warn` (log violations, continue) or `enforce` (log violations, block the operation with `SecurityException`). + +|solr.security.agent.skip||`false`|If set to `true`, omits the `-javaagent:` flag from the JVM command line, disabling all Solr security agent controls. Intended for temporary troubleshooting only. + |solr.security.auth.basicauth.credentials|basicauth||Defines basic authentication credentials. |solr.security.allow.paths|solr.allowPaths||A comma seperated list of paths for reading from. diff --git a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc index 04dfeb766436..52acaa4044e6 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc @@ -73,6 +73,7 @@ *** xref:jwt-authentication-plugin.adoc[] *** xref:cert-authentication-plugin.adoc[] *** xref:rule-based-authorization-plugin.adoc[] +** xref:security-agent.adoc[] ** xref:audit-logging.adoc[] ** xref:enabling-ssl.adoc[] ** xref:zookeeper-access-control.adoc[] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/securing-solr.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/securing-solr.adoc index 7844f073737e..799a9d8697d6 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/securing-solr.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/securing-solr.adoc @@ -1,5 +1,6 @@ = Securing Solr :page-children: authentication-and-authorization-plugins, \ + security-agent, \ audit-logging, \ enabling-ssl, \ zookeeper-access-control, \ @@ -90,6 +91,13 @@ SOLR_IP_ALLOWLIST="127.0.0.1, [::1], 192.168.0.0/24, [2000:123:4:5::]/64" SOLR_IP_DENYLIST="192.168.0.3, 192.168.0.4" ---- +== Security Agent + +Solr includes a Java agent that enforces runtime security controls — file access, outbound network connections, `System.exit()`, and process spawning — at the JVM level. +The agent activates automatically in "warn-only" mode to allow safe observation before enabling blocking enforcement. + +See xref:security-agent.adoc[] for details on enforcement modes, policy configuration, and diagnosing violations. + == Securing ZooKeeper Traffic ZooKeeper is a central and important part of a SolrCloud cluster and understanding how to secure diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/security-agent.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/security-agent.adoc new file mode 100644 index 000000000000..bbf2cf49d6d4 --- /dev/null +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/security-agent.adoc @@ -0,0 +1,188 @@ += Security Agent +// 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 includes a Java agent (`solr-agent-sm`) that aims to fill the gaps left by the removal of Java Security Manager (JSM) in JDK 24. +The agent intercepts file access, network connections, `System.exit()`, and process spawning at the bytecode level, enforcing a configurable policy. + +== Protections Active by Default + +When the agent JAR is present, the startup scripts automatically activate it. +The following protections apply to all code running inside the Solr JVM: + +* **File access** — reads and writes are restricted to permitted directories (Solr home, data dir, log dir, temp dir). +* **Outbound network** — connections are restricted to loopback addresses and intra-cluster ports. +* **`System.exit()` / `Runtime.halt()`** — only approved caller classes may shut down the JVM. +* **Process spawning** — `ProcessBuilder.start()` and `Runtime.exec()` are blocked by default. + +== Enforcement Modes + +The agent operates in two modes, controlled by the `SOLR_SECURITY_AGENT_MODE` environment variable: + +`warn` (default):: +Violations are logged at `WARN` level and counted in `/admin/metrics`, but the operation is allowed to continue. +This is the safe default for existing deployments — operators can observe violations without risk. + +`enforce`:: +Violations throw a `SecurityException`, blocking the operation. +Enable this mode once you have verified that your deployment produces no unexpected violations in `warn` mode. + +Set the mode in `solr.in.sh` or `solr.in.cmd`: + +[source,bash] +---- +SOLR_SECURITY_AGENT_MODE=enforce +---- + +== Policy File Format + +Solr uses JDK-style `.policy` files with Solr-specific variable substitution. +Two files are loaded: + +`server/etc/agent-security.policy` (default):: +The bundled production policy. +Do not edit this file — it will be overwritten on upgrade. + +`server/etc/agent-security-extra.policy` (operator extension):: +Add your custom rules here. +This file is silently skipped if absent. +Its location can be overridden via `SOLR_SECURITY_AGENT_EXTRA_POLICY`. + +=== Variable Substitution + +Any system property will be expanded in policy entries. Each property is also looked up as an environment variable by upper-casing and replacing `.` with `_` (e.g. `solr.solr.home` → `SOLR_SOLR_HOME`). The most common variables are: + +[cols="1,2",options="header"] +|=== +|Variable|Resolved Value +|`${solr.solr.home}`|Solr home directory +|`${solr.data.home}`|Solr data directory (defaults to `solr.solr.home` if unset) +|`${solr.logs.dir}`|Solr log directory +|`${solr.install.dir}`|Solr installation root (parent of `server/`) +|`${solr.install.symDir}`|Symlink-resolved installation root (falls back to `solr.install.dir`) +|`${java.io.tmpdir}`|JVM temporary directory +|`${java.home}`|JDK installation directory +|`${solr.port.listen}`|Solr HTTP port +|`${solr.zk.port}`|ZooKeeper port (defaults to `solr.port.listen + 1000` for embedded ZK) +|=== + +=== Default Intra-Cluster Network Policy + +The bundled policy permits connections on `*:${solr.port.listen}` and `*:${solr.zk.port}` from any host, so all cluster nodes can communicate without any operator action. +If your ZooKeeper runs on a non-standard port, set `-Dsolr.zk.port=` at startup or configure it explicitly in the policy. + +=== Adding Custom Policy Entries + +To permit additional paths or endpoints, add entries to `agent-security-extra.policy`: + +[source,text] +---- +grant { + // permit a plugin that reads from an NFS mount + permission java.io.FilePermission "/mnt/nfs-data/-", "read"; +}; + +grant { + // permit outbound connection to an internal analytics service + permission java.net.SocketPermission "analytics.internal:443", "connect,resolve"; +}; +---- + +To override the extra-policy file location (useful for read-only installations or containers): + +[source,bash] +---- +SOLR_SECURITY_AGENT_EXTRA_POLICY=/etc/solr/custom-security.policy +---- + +== Diagnosing Violations + +Violations appear in the Solr log in this format: + +[source,text] +---- +SECURITY VIOLATION [FILE] target=/etc/passwd caller=com.example.Plugin mode=warn source=DEFAULT +---- + +Fields: + +* `[TYPE]` — `FILE`, `NETWORK`, `EXIT`, or `EXEC` +* `target` — the resource that was accessed or attempted +* `caller` — the class that triggered the access +* `mode` — `warn` or `enforce` +* `source` — `DEFAULT` (bundled policy) or `OPERATOR` (extra policy) + +Violation counts are also available in `/admin/metrics` as a single labeled counter: + +* Metric name: `solr.security.agent.violations` +* Label `type`: `file`, `network`, `exit`, or `exec` + +In Prometheus format: +[source,text] +---- +solr_security_agent_violations_total{type="file"} 3.0 +solr_security_agent_violations_total{type="network"} 0.0 +solr_security_agent_violations_total{type="exit"} 0.0 +solr_security_agent_violations_total{type="exec"} 0.0 +---- + +== Disabling the Agent + +To temporarily disable the agent for troubleshooting, set in `solr.in.sh` or `solr.in.cmd`: + +[source,bash] +---- +SOLR_SECURITY_AGENT_SKIP=true +---- + +[WARNING] +==== +Disabling the agent removes all runtime security controls. +Use this only for temporary troubleshooting or if you intend to provide similar protection on the operating system level. +==== + +== Modules with Pre-Permitted Network Access + +The following Solr modules have wildcard outbound network access pre-permitted in the bundled policy: + +* `jwt-auth` +* `opentelemetry` +* `s3-repository` +* `gcs-repository` +* `cross-dc-manager` + +Other modules (such as `extraction`) require explicit policy entries when connecting to external services. +See the module's documentation for details. + +== Known Limitations + +The agent intercepts the standard NIO file APIs (`FileSystemProvider`, `java.nio.file.Files`, `FileChannel`). +The following Java APIs are *not* instrumented and will bypass policy enforcement: + +* `java.io.FileInputStream`, `java.io.FileOutputStream`, `java.io.RandomAccessFile` — legacy stream I/O uses native `open0()` and does not go through `FileSystemProvider`. +* `java.nio.channels.AsynchronousFileChannel` — async file I/O is not instrumented. +* `java.net.ServerSocket` — inbound TCP socket creation is not checked; only outbound `connect()` calls are intercepted. +* `java.nio.channels.DatagramChannel` — UDP send/receive is not checked. +* `java.nio.channels.AsynchronousSocketChannel` — async socket operations are not checked. + +These gaps reflect the current scope of the agent, which targets the primary I/O paths used by Solr and the JVM internals. +Operators requiring tighter isolation should supplement the agent with OS-level controls (e.g., Linux seccomp, SELinux, or container security profiles). + +== See Also + +* xref:configuration-guide:solr-properties.adoc[] — `solr.security.agent.*` property reference +* xref:indexing-guide:indexing-with-tika.adoc[] — extraction module network policy requirements diff --git a/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc b/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc index 1a50f8f14c6e..f7d29965b4b9 100644 --- a/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc +++ b/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc @@ -72,6 +72,25 @@ Example handler configuration: ---- +[WARNING] +==== +*Security Agent Policy Required for Remote Tika Server* + +When Solr runs with the security agent in `enforce` mode, outbound network connections are blocked by default unless explicitly permitted. Because Solr connects to an external Tika Server over the network, you must add a policy entry to `agent-security-extra.policy` to permit that connection: + +[source,text] +---- +grant { + // extraction module: permit outbound connection to remote Tika Server + permission java.net.SocketPermission "tika-server.internal:9998", "connect,resolve"; +}; +---- + +Replace `tika-server.internal:9998` with the actual hostname and port of your Tika Server. Use a specific `hostname:port`, not a wildcard, to prevent SSRF attacks. + +In the default `warn` mode, the connection will succeed but a `SECURITY VIOLATION` entry will appear in the Solr log. See xref:deployment-guide:security-agent.adoc[Security Agent] for details. +==== + ==== Starting Tika Server with Docker The quickest way to run Tika Server for development is using Docker. The examples below expose Tika on port 9998 on localhost for convenience, matching the handler configuration above. diff --git a/solr/solrj/gradle.lockfile b/solr/solrj/gradle.lockfile index 2517603e61db..5a68e02160e0 100644 --- a/solr/solrj/gradle.lockfile +++ b/solr/solrj/gradle.lockfile @@ -17,13 +17,19 @@ com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,compileOnlyHelp 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.code.findbugs:jsr305:3.0.2=spotless865458226 com.google.errorprone:error_prone_annotation:2.41.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.18.0=spotless865458226 com.google.errorprone:error_prone_annotations:2.41.0=jarValidation,testCompileClasspath,testRuntimeClasspath 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.18.1=spotless865458226 com.google.googlejavaformat:google-java-format:1.27.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.guava:failureaccess:1.0.1=spotless865458226 com.google.guava:failureaccess:1.0.3=annotationProcessor,errorprone,jarValidation,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava-parent:32.1.1-jre=spotless865458226 +com.google.guava:guava:32.1.1-jre=spotless865458226 com.google.guava:guava:33.5.0-jre=annotationProcessor,errorprone,jarValidation,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,errorprone,jarValidation,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,errorprone,jarValidation,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath @@ -115,6 +121,7 @@ org.apache.lucene:lucene-test-framework:10.4.0=jarValidation,testCompileClasspat org.apache.zookeeper:zookeeper-jute:3.9.4=jarValidation,testCompileClasspath,testRuntimeClasspath org.apache.zookeeper:zookeeper:3.9.4=jarValidation,testCompileClasspath,testRuntimeClasspath org.apiguardian:apiguardian-api:1.1.2=jarValidation,testRuntimeClasspath +org.checkerframework:checker-qual:3.33.0=spotless865458226 org.codehaus.woodstox:stax2-api:4.2.2=jarValidation,testRuntimeClasspath org.eclipse.jetty.ee10:jetty-ee10-servlet:12.0.34=jarValidation,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty.ee10:jetty-ee10-webapp:12.0.34=jarValidation,testCompileClasspath,testRuntimeClasspath