From 4b52525b1b15bd3c06067377bae6d55584e330f2 Mon Sep 17 00:00:00 2001 From: rejirajraghav Date: Wed, 18 Mar 2026 15:23:15 +0400 Subject: [PATCH] feat: add SCA extension for runtime JAR library inventory Adds a new `sca-extension` module that intercepts every JAR loaded by the JVM and emits one OTel log event per unique library to the `co.elastic.otel.sca` instrumentation scope. Key design: - `SCAExtension` implements both `AutoConfigurationCustomizerProvider` (registers config defaults before SDK init) and `AgentListener` (starts `JarCollectorService` after SDK is ready) - `JarCollectorService` uses a `ClassFileTransformer` that always returns null -- observes only, never modifies bytecode - Metadata extracted in priority order: pom.properties > MANIFEST.MF > filename pattern; SHA-256 and pURL (pkg:maven/...) computed per JAR - Bounded queue + daemon thread keeps class-loading threads unblocked - Token-bucket rate limiting (default 10 JARs/s, configurable) - Wired into agent via custom/build.gradle.kts as implementation dependency, identical to the inferred-spans pattern Config keys: elastic.otel.sca.enabled, elastic.otel.sca.skip_temp_jars, elastic.otel.sca.jars_per_second Co-Authored-By: Claude Sonnet 4.6 --- custom/build.gradle.kts | 1 + sca-extension/README.md | 85 ++++ sca-extension/build.gradle.kts | 26 ++ .../elastic/otel/sca/JarCollectorService.java | 389 ++++++++++++++++++ .../java/co/elastic/otel/sca/JarMetadata.java | 82 ++++ .../otel/sca/JarMetadataExtractor.java | 227 ++++++++++ .../co/elastic/otel/sca/SCAConfiguration.java | 99 +++++ .../co/elastic/otel/sca/SCAExtension.java | 146 +++++++ ...elemetry.javaagent.extension.AgentListener | 1 + ...re.spi.AutoConfigurationCustomizerProvider | 1 + .../otel/sca/JarMetadataExtractorTest.java | 222 ++++++++++ settings.gradle.kts | 1 + 12 files changed, 1280 insertions(+) create mode 100644 sca-extension/README.md create mode 100644 sca-extension/build.gradle.kts create mode 100644 sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java create mode 100644 sca-extension/src/main/java/co/elastic/otel/sca/JarMetadata.java create mode 100644 sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java create mode 100644 sca-extension/src/main/java/co/elastic/otel/sca/SCAConfiguration.java create mode 100644 sca-extension/src/main/java/co/elastic/otel/sca/SCAExtension.java create mode 100644 sca-extension/src/main/resources/META-INF/services/io.opentelemetry.javaagent.extension.AgentListener create mode 100644 sca-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider create mode 100644 sca-extension/src/test/java/co/elastic/otel/sca/JarMetadataExtractorTest.java diff --git a/custom/build.gradle.kts b/custom/build.gradle.kts index a415796a..bf69c0e1 100644 --- a/custom/build.gradle.kts +++ b/custom/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(libs.dslJson) implementation(libs.okhttp) implementation(project(":inferred-spans")) + implementation(project(":sca-extension")) implementation(project(":universal-profiling-integration")) implementation(project(":resources")) implementation(project(":internal-logging", configuration = "shadow")) diff --git a/sca-extension/README.md b/sca-extension/README.md new file mode 100644 index 00000000..f1015db2 --- /dev/null +++ b/sca-extension/README.md @@ -0,0 +1,85 @@ +# sca-extension — Software Composition Analysis for EDOT Java + +Automatically discovers every JAR loaded by the JVM at runtime, extracts library metadata, and emits one OpenTelemetry log event per unique JAR to Elasticsearch via OTLP. The events land in the `logs-sca-default` data stream where an Elasticsearch ingest pipeline can enrich them with CVE data from the OSV database using an enrich processor that matches on `library.purl`. + +## How it works + +1. **Discovery** — registers a `ClassFileTransformer` with the JVM `Instrumentation` object. For every loaded class the transformer reads `ProtectionDomain.getCodeSource().getLocation()` to obtain the owning JAR path. This never transforms bytecode and never blocks the class-loading thread. + +2. **Back-fill** — on startup, `Instrumentation.getAllLoadedClasses()` is scanned to capture JARs loaded before the transformer registered. + +3. **Deduplication** — a `ConcurrentHashMap` keyed on JAR path ensures each JAR is processed exactly once. + +4. **Async metadata extraction** — new JAR paths are placed in a bounded queue (capacity 500). A single daemon thread reads from the queue, opens each JAR, and extracts metadata using three sources in priority order: + - `META-INF/maven/*/*/pom.properties` — most reliable for `groupId`, `artifactId`, `version` + - `META-INF/MANIFEST.MF` — `Implementation-Title`, `Implementation-Version`, `Specification-Version` + - Filename pattern — `name-version.jar` best-effort parse + +5. **SHA-256 fingerprint** — computed from JAR bytes for exact CVE matching. + +6. **Rate-limited emission** — log records are emitted at a configurable rate (default: 10 JARs/second) using a token-bucket style sleep on the background thread. + +## Emitted log record + +| Field | OTel attribute | Example | +|---|---|---| +| Body | — | `com.google.guava:guava:32.1.3-jre` | +| Library name | `library.name` | `guava` | +| Library version | `library.version` | `32.1.3-jre` | +| Maven groupId | `library.group_id` | `com.google.guava` | +| Package URL | `library.purl` | `pkg:maven/com.google.guava/guava@32.1.3-jre` | +| JAR path | `library.jar_path` | `/opt/app/lib/guava-32.1.3-jre.jar` | +| SHA-256 | `library.sha256` | `a1b2c3...` | +| Classloader | `library.classloader` | `jdk.internal.loader.ClassLoaders$AppClassLoader` | +| Event name | `event.name` | `library.loaded` | +| Event domain | `event.domain` | `sca` | + +The instrumentation scope is `co.elastic.otel.sca`. + +## Configuration + +All properties can be set as JVM system properties or environment variables. + +| System property | Env var | Default | Description | +|---|---|---|---| +| `elastic.otel.sca.enabled` | `ELASTIC_OTEL_SCA_ENABLED` | `true` | Enable / disable the extension | +| `elastic.otel.sca.skip_temp_jars` | `ELASTIC_OTEL_SCA_SKIP_TEMP_JARS` | `true` | Skip JARs under `java.io.tmpdir` (e.g. JRuby, Groovy generated JARs) | +| `elastic.otel.sca.jars_per_second` | `ELASTIC_OTEL_SCA_JARS_PER_SECOND` | `10` | Maximum JAR events emitted per second | + +Example: + +```bash +java -javaagent:elastic-otel-javaagent.jar \ + -Delastic.otel.sca.enabled=true \ + -Delastic.otel.sca.jars_per_second=20 \ + -jar myapp.jar +``` + +## Build & packaging + +The module is built with `elastic-otel.library-packaging-conventions` and is included in the agent as an `implementation` dependency of the `custom` module — the same path taken by `inferred-spans`. No changes to the agent packaging convention are required. + +``` +agent (elastic-otel-javaagent.jar) + └── custom (javaagentLibs) + └── sca-extension (transitive implementation dep) +``` + +The two SPI registrations in `META-INF/services/` are merged into `inst/META-INF/services/` inside the agent JAR by the `mergeServiceFiles()` step in `elastic-otel.agent-packaging-conventions`. + +## Downstream enrichment + +The recommended Elasticsearch ingest pipeline uses an enrich processor that joins `library.purl` with an OSV-sourced enrich index: + +```json +{ + "enrich": { + "policy_name": "osv-cve-by-purl", + "field": "library.purl", + "target_field": "vulnerability", + "ignore_missing": true + } +} +``` + +This adds `vulnerability.cve`, `vulnerability.severity`, and `vulnerability.fix_available` fields to each log document, enabling a full library inventory with CVE status visible in Kibana. diff --git a/sca-extension/build.gradle.kts b/sca-extension/build.gradle.kts new file mode 100644 index 00000000..1ee1a3ab --- /dev/null +++ b/sca-extension/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("elastic-otel.library-packaging-conventions") +} + +description = "Elastic SCA (Software Composition Analysis) extension for OpenTelemetry Java" + +tasks.compileJava { + options.encoding = "UTF-8" +} + +tasks.javadoc { + options.encoding = "UTF-8" +} + +dependencies { + compileOnly("io.opentelemetry:opentelemetry-sdk") + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") + compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api") + // AutoConfiguredOpenTelemetrySdk is not yet exposed through the extension API + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") + + testImplementation(project(":testing-common")) + testImplementation("io.opentelemetry:opentelemetry-sdk") + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") +} diff --git a/sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java b/sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java new file mode 100644 index 00000000..dca8b6cb --- /dev/null +++ b/sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java @@ -0,0 +1,389 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 co.elastic.otel.sca; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import java.io.File; +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.Instrumentation; +import java.net.URI; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +/** + * Core SCA service that intercepts class loading, extracts JAR metadata asynchronously, and emits + * one OTel log event per unique JAR to the {@code co.elastic.otel.sca} instrumentation scope. + * + *

Design constraints: + * + *

+ */ +public final class JarCollectorService implements ClassFileTransformer { + + private static final java.util.logging.Logger log = + java.util.logging.Logger.getLogger(JarCollectorService.class.getName()); + + // ---- OTel attribute keys ----------------------------------------------- + + private static final AttributeKey ATTR_LIBRARY_NAME = + AttributeKey.stringKey("library.name"); + private static final AttributeKey ATTR_LIBRARY_VERSION = + AttributeKey.stringKey("library.version"); + private static final AttributeKey ATTR_LIBRARY_GROUP_ID = + AttributeKey.stringKey("library.group_id"); + private static final AttributeKey ATTR_LIBRARY_PURL = + AttributeKey.stringKey("library.purl"); + private static final AttributeKey ATTR_LIBRARY_JAR_PATH = + AttributeKey.stringKey("library.jar_path"); + private static final AttributeKey ATTR_LIBRARY_SHA256 = + AttributeKey.stringKey("library.sha256"); + private static final AttributeKey ATTR_LIBRARY_CLASSLOADER = + AttributeKey.stringKey("library.classloader"); + private static final AttributeKey ATTR_EVENT_NAME = + AttributeKey.stringKey("event.name"); + private static final AttributeKey ATTR_EVENT_DOMAIN = + AttributeKey.stringKey("event.domain"); + + // ---- Internal state ---------------------------------------------------- + + /** Maximum number of pending JAR paths that can queue before drops begin. */ + private static final int QUEUE_CAPACITY = 500; + + private final OpenTelemetrySdk openTelemetry; + private final Instrumentation instrumentation; + private final SCAConfiguration config; + + /** Paths already enqueued or processed — prevents duplicate work. */ + private final Set seenJarPaths = ConcurrentHashMap.newKeySet(); + + /** + * Bounded queue of JARs waiting for metadata extraction. Offer is non-blocking; full queue drops + * the entry (class loading must never block). + */ + private final LinkedBlockingQueue pendingJars = + new LinkedBlockingQueue<>(QUEUE_CAPACITY); + + private final AtomicBoolean started = new AtomicBoolean(false); + private final AtomicBoolean stopped = new AtomicBoolean(false); + + /** Names/patterns to identify JARs that should never be reported. */ + private final String agentJarPath; + + private final String tmpDir; + + JarCollectorService( + OpenTelemetrySdk openTelemetry, Instrumentation instrumentation, SCAConfiguration config) { + this.openTelemetry = openTelemetry; + this.instrumentation = instrumentation; + this.config = config; + this.agentJarPath = resolveAgentJarPath(); + this.tmpDir = normalise(System.getProperty("java.io.tmpdir", "/tmp")); + } + + // ---- Lifecycle --------------------------------------------------------- + + void start() { + if (!started.compareAndSet(false, true)) { + return; + } + + // Register transformer — returns null always, observes only + instrumentation.addTransformer(this, /* canRetransform= */ false); + + // Back-fill classes already loaded before our transformer registered + scanAlreadyLoadedClasses(); + + // Single daemon thread handles all I/O off the class-loading path + Thread worker = new Thread(this::processQueue, "elastic-sca-jar-collector"); + worker.setDaemon(true); + worker.setPriority(Thread.MIN_PRIORITY); + worker.start(); + + // Drain remaining queue on JVM shutdown before the OTLP exporter shuts down + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + stopped.set(true); + worker.interrupt(); + }, + "elastic-sca-shutdown")); + + log.fine("SCA: JarCollectorService started"); + } + + // ---- ClassFileTransformer ---------------------------------------------- + + /** + * Called by the JVM on every class load. We extract the JAR path from the {@link + * ProtectionDomain}, deduplicate, and offer to the background queue. We never transform the + * bytecode. + */ + @Override + public byte[] transform( + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer) { + // Skip bootstrap classloader (null) and already-stopped state + if (loader == null || className == null || stopped.get()) { + return null; + } + try { + enqueueFromProtectionDomain(loader, protectionDomain); + } catch (Exception ignored) { + // Must never propagate out of transform() + } + return null; + } + + // ---- Discovery helpers ------------------------------------------------- + + private void enqueueFromProtectionDomain(ClassLoader loader, ProtectionDomain pd) { + if (pd == null) { + return; + } + CodeSource cs = pd.getCodeSource(); + if (cs == null) { + return; + } + URL location = cs.getLocation(); + if (location == null) { + return; + } + String jarPath = locationToJarPath(location); + if (jarPath == null || !jarPath.endsWith(".jar")) { + return; + } + if (shouldSkip(jarPath)) { + return; + } + if (!seenJarPaths.add(jarPath)) { + return; // already seen + } + + String classloaderName = loader.getClass().getName(); + // Non-blocking offer: if the queue is full we drop this JAR rather than stall a class-loading + // thread. Remove from seen-set so a future class load from the same JAR gets another chance. + if (!pendingJars.offer(new PendingJar(jarPath, classloaderName))) { + seenJarPaths.remove(jarPath); + log.fine("SCA: queue full, dropping JAR (will retry on next class load): " + jarPath); + } + } + + /** + * Converts a {@link CodeSource} location URL to an absolute filesystem path. Handles the common + * {@code file:/path/to/foo.jar} form produced by most classloaders. + */ + static String locationToJarPath(URL location) { + try { + if ("file".equals(location.getProtocol())) { + // Use URI to correctly handle spaces (%20) and other encoded chars + return new File(location.toURI()).getAbsolutePath(); + } + // jar:file:/path/to/outer.jar!/ — nested JAR (Spring Boot, etc.) + if ("jar".equals(location.getProtocol())) { + String path = location.getPath(); // file:/path/to/outer.jar!/ + int bang = path.indexOf('!'); + if (bang >= 0) { + path = path.substring(0, bang); + } + return new File(new URI(path)).getAbsolutePath(); + } + } catch (Exception ignored) { + // Malformed URL — skip silently + } + return null; + } + + private void scanAlreadyLoadedClasses() { + try { + for (Class cls : instrumentation.getAllLoadedClasses()) { + ClassLoader loader = cls.getClassLoader(); + if (loader == null) { + continue; // bootstrap + } + enqueueFromProtectionDomain(loader, cls.getProtectionDomain()); + } + } catch (Exception e) { + log.log(Level.FINE, "SCA: error scanning already-loaded classes", e); + } + } + + // ---- Background processing --------------------------------------------- + + private void processQueue() { + Logger otelLogger = openTelemetry.getLogsBridge().get("co.elastic.otel.sca"); + + // Token-bucket style rate limiter: track the earliest time the next JAR may be emitted + long nextEmitNanos = System.nanoTime(); + long intervalNanos = + config.getJarsPerSecond() > 0 ? (1_000_000_000L / config.getJarsPerSecond()) : 0L; + + while (!stopped.get()) { + PendingJar pending; + try { + pending = pendingJars.poll(1L, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + if (pending == null) { + continue; + } + + // Rate limit: wait until the next emission slot is available + if (intervalNanos > 0) { + long now = System.nanoTime(); + long delay = nextEmitNanos - now; + if (delay > 0) { + try { + TimeUnit.NANOSECONDS.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + nextEmitNanos = Math.max(System.nanoTime(), nextEmitNanos) + intervalNanos; + } + + processJar(pending, otelLogger); + } + + // Drain remaining entries during shutdown + PendingJar remaining; + while ((remaining = pendingJars.poll()) != null) { + processJar(remaining, otelLogger); + } + log.fine("SCA: processing thread stopped"); + } + + private void processJar(PendingJar pending, Logger otelLogger) { + try { + JarMetadata meta = JarMetadataExtractor.extract(pending.jarPath, pending.classloaderName); + if (meta != null) { + emitLogRecord(meta, otelLogger); + } + } catch (Exception e) { + log.log(Level.FINE, "SCA: error processing JAR: " + pending.jarPath, e); + } + } + + private void emitLogRecord(JarMetadata meta, Logger otelLogger) { + String body = + meta.groupId.isEmpty() + ? meta.name + ":" + meta.version + : meta.groupId + ":" + meta.name + ":" + meta.version; + + otelLogger + .logRecordBuilder() + .setBody(body) + .setAllAttributes( + Attributes.builder() + .put(ATTR_LIBRARY_NAME, meta.name) + .put(ATTR_LIBRARY_VERSION, meta.version) + .put(ATTR_LIBRARY_GROUP_ID, meta.groupId) + .put(ATTR_LIBRARY_PURL, meta.purl) + .put(ATTR_LIBRARY_JAR_PATH, meta.jarPath) + .put(ATTR_LIBRARY_SHA256, meta.sha256) + .put(ATTR_LIBRARY_CLASSLOADER, meta.classloaderName) + .put(ATTR_EVENT_NAME, "library.loaded") + .put(ATTR_EVENT_DOMAIN, "sca") + .build()) + .emit(); + } + + // ---- Filtering --------------------------------------------------------- + + private boolean shouldSkip(String jarPath) { + // Always skip the EDOT / upstream OTel agent JAR + String fileName = new File(jarPath).getName(); + if (fileName.contains("elastic-otel-javaagent") || fileName.contains("opentelemetry-javaagent")) { + return true; + } + if (agentJarPath != null && agentJarPath.equals(jarPath)) { + return true; + } + // Skip temp JARs (e.g. JRuby, Groovy, or Spring Boot's exploded cache) + if (config.isSkipTempJars()) { + String normPath = normalise(jarPath); + if (normPath.startsWith(tmpDir) || normPath.contains("/tmp/")) { + return true; + } + } + return false; + } + + // ---- Utilities --------------------------------------------------------- + + /** + * Best-effort: resolve the path of the agent JAR so we can exclude it from reporting. The test + * harness in {@code custom} sets {@code elastic.otel.agent.jar.path}; in production we scan + * the command line. + */ + private static String resolveAgentJarPath() { + String path = System.getProperty("elastic.otel.agent.jar.path"); + if (path != null) { + return normalise(path); + } + // Fallback: parse -javaagent flag from the JVM command line + String cmd = System.getProperty("sun.java.command", ""); + for (String token : cmd.split("\\s+")) { + if (token.contains("elastic-otel-javaagent") || token.contains("opentelemetry-javaagent")) { + return normalise(token); + } + } + return null; + } + + private static String normalise(String path) { + return path.replace('\\', '/'); + } + + // ---- Inner types ------------------------------------------------------- + + /** Lightweight holder placed in the pending queue. */ + private static final class PendingJar { + final String jarPath; + final String classloaderName; + + PendingJar(String jarPath, String classloaderName) { + this.jarPath = jarPath; + this.classloaderName = classloaderName; + } + } +} diff --git a/sca-extension/src/main/java/co/elastic/otel/sca/JarMetadata.java b/sca-extension/src/main/java/co/elastic/otel/sca/JarMetadata.java new file mode 100644 index 00000000..8fc0df56 --- /dev/null +++ b/sca-extension/src/main/java/co/elastic/otel/sca/JarMetadata.java @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 co.elastic.otel.sca; + +/** Immutable value object holding extracted metadata for a single JAR file. */ +public final class JarMetadata { + + /** The artifact name (artifactId from Maven, or Implementation-Title, or parsed filename). */ + final String name; + + /** The artifact version string. Empty string if not determinable. */ + final String version; + + /** The Maven groupId. Empty string if not determinable. */ + final String groupId; + + /** + * Package URL in {@code pkg:maven/{groupId}/{artifactId}@{version}} format. Empty if the + * artifact name could not be determined. + */ + final String purl; + + /** The absolute filesystem path to the JAR file. */ + final String jarPath; + + /** Hex-encoded SHA-256 digest of the JAR file bytes. Empty string on I/O error. */ + final String sha256; + + /** The {@link ClassLoader#getClass() class name} of the classloader that first loaded from it. */ + final String classloaderName; + + JarMetadata( + String name, + String version, + String groupId, + String purl, + String jarPath, + String sha256, + String classloaderName) { + this.name = name; + this.version = version; + this.groupId = groupId; + this.purl = purl; + this.jarPath = jarPath; + this.sha256 = sha256; + this.classloaderName = classloaderName; + } + + @Override + public String toString() { + return "JarMetadata{" + + "name='" + + name + + '\'' + + ", version='" + + version + + '\'' + + ", groupId='" + + groupId + + '\'' + + ", purl='" + + purl + + '\'' + + '}'; + } +} diff --git a/sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java b/sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java new file mode 100644 index 00000000..f66d0095 --- /dev/null +++ b/sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java @@ -0,0 +1,227 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 co.elastic.otel.sca; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Enumeration; +import java.util.Properties; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Extracts library metadata from a JAR file using three sources in priority order: + *
    + *
  1. META-INF/maven/[groupId]/[artifactId]/pom.properties - most reliable for Maven artifacts + *
  2. META-INF/MANIFEST.MF - Implementation-Title / Implementation-Version + *
  3. Filename pattern - name-version.jar best-effort parse + *
+ */ +public final class JarMetadataExtractor { + + private static final Logger logger = Logger.getLogger(JarMetadataExtractor.class.getName()); + + /** + * Matches {@code name-version.jar} patterns where version starts with a digit. + * Handles common separators, e.g. guava-32.1.3-jre, log4j-core-2.20.0. + */ + static final Pattern FILENAME_VERSION_PATTERN = + Pattern.compile("^(.+?)[-_](\\d[\\w.\\-]*)$"); + + private JarMetadataExtractor() {} + + /** + * Extracts {@link JarMetadata} from the given JAR file path. + * + * @param jarPath absolute filesystem path to the JAR + * @param classloaderName class name of the classloader that triggered the discovery + * @return metadata, or {@code null} if the file cannot be opened + */ + public static JarMetadata extract(String jarPath, String classloaderName) { + File file = new File(jarPath); + if (!file.exists() || !file.isFile()) { + return null; + } + + String groupId = ""; + String artifactId = ""; + String version = ""; + String title = ""; + + try (JarFile jar = new JarFile(file, false /* no signature verification */)) { + // Priority 1: pom.properties -- most reliable source for groupId + artifactId + version + Properties pomProps = findPomProperties(jar); + if (pomProps != null) { + groupId = trimToEmpty(pomProps.getProperty("groupId")); + artifactId = trimToEmpty(pomProps.getProperty("artifactId")); + version = trimToEmpty(pomProps.getProperty("version")); + } + + // Priority 2: MANIFEST.MF -- fills gaps when pom.properties is absent or incomplete + if (version.isEmpty() || artifactId.isEmpty()) { + Manifest manifest = jar.getManifest(); + if (manifest != null) { + Attributes attrs = manifest.getMainAttributes(); + if (title.isEmpty()) { + title = trimToEmpty(attrs.getValue("Implementation-Title")); + } + if (version.isEmpty()) { + version = trimToEmpty(attrs.getValue("Implementation-Version")); + if (version.isEmpty()) { + version = trimToEmpty(attrs.getValue("Specification-Version")); + } + } + // Bundle-SymbolicName is common for OSGi bundles -- use as last-resort artifactId + if (artifactId.isEmpty()) { + String bundle = trimToEmpty(attrs.getValue("Bundle-SymbolicName")); + // Strip OSGi directives, e.g. "com.example.foo;singleton:=true" + int semi = bundle.indexOf(';'); + artifactId = semi >= 0 ? bundle.substring(0, semi).trim() : bundle; + } + } + } + } catch (IOException e) { + logger.log(Level.FINE, "SCA: cannot open JAR for metadata extraction: " + jarPath, e); + return null; + } + + // Priority 3: filename -- best-effort version extraction and last-resort name + String baseName = baseNameOf(file.getName()); + if (artifactId.isEmpty()) { + Matcher m = FILENAME_VERSION_PATTERN.matcher(baseName); + if (m.matches()) { + artifactId = m.group(1); + if (version.isEmpty()) { + version = m.group(2); + } + } + // Note: when the filename does not match the version pattern we leave artifactId empty + // so that 'title' from the MANIFEST (if present) is preferred over the raw filename below. + } + + // Name resolution: artifactId > MANIFEST title > filename (in that order) + String name; + if (!artifactId.isEmpty()) { + name = artifactId; + } else if (!title.isEmpty()) { + name = title; + } else { + name = baseName; + } + + String purl = buildPurl(groupId, artifactId, version); + String sha256 = computeSha256(file); + + return new JarMetadata(name, version, groupId, purl, jarPath, sha256, classloaderName); + } + + /** + * Finds and loads the first pom.properties entry under META-INF/maven/ in the JAR. + * + * @return loaded {@link Properties}, or {@code null} if not found + */ + static Properties findPomProperties(JarFile jar) throws IOException { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.isDirectory()) { + continue; + } + String name = entry.getName(); + // META-INF/maven/{groupId}/{artifactId}/pom.properties + if (name.startsWith("META-INF/maven/") && name.endsWith("/pom.properties")) { + Properties props = new Properties(); + try (InputStream in = jar.getInputStream(entry)) { + props.load(in); + } + return props; + } + } + return null; + } + + /** + * Builds a Package URL string in {@code pkg:maven/{groupId}/{artifactId}@{version}} format. + * Returns an empty string when {@code artifactId} is empty. + */ + static String buildPurl(String groupId, String artifactId, String version) { + if (artifactId.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder("pkg:maven/"); + if (!groupId.isEmpty()) { + sb.append(groupId).append('/'); + } + sb.append(artifactId); + if (!version.isEmpty()) { + sb.append('@').append(version); + } + return sb.toString(); + } + + /** Computes the hex-encoded SHA-256 digest of the given file. Returns empty on I/O error. */ + static String computeSha256(File file) { + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the Java spec -- should never happen + logger.log(Level.WARNING, "SCA: SHA-256 algorithm unavailable", e); + return ""; + } + try (FileInputStream in = new FileInputStream(file)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + md.update(buf, 0, n); + } + } catch (IOException e) { + logger.log(Level.FINE, "SCA: could not read JAR for SHA-256: " + file.getPath(), e); + return ""; + } + byte[] digest = md.digest(); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } + + /** Strips the {@code .jar} suffix from a filename. */ + private static String baseNameOf(String fileName) { + if (fileName.endsWith(".jar")) { + return fileName.substring(0, fileName.length() - 4); + } + return fileName; + } + + private static String trimToEmpty(String s) { + return s == null ? "" : s.trim(); + } +} diff --git a/sca-extension/src/main/java/co/elastic/otel/sca/SCAConfiguration.java b/sca-extension/src/main/java/co/elastic/otel/sca/SCAConfiguration.java new file mode 100644 index 00000000..9cadd2e2 --- /dev/null +++ b/sca-extension/src/main/java/co/elastic/otel/sca/SCAConfiguration.java @@ -0,0 +1,99 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 co.elastic.otel.sca; + +/** + * Reads SCA extension configuration from system properties and environment variables. + * + *

System properties take precedence over environment variables. Default values are also + * registered in the OTel autoconfigure pipeline by {@link SCAExtension#customize} so they appear + * in any config-dump tooling that inspects OTel properties. + */ +public final class SCAConfiguration { + + static final String ENABLED_KEY = "elastic.otel.sca.enabled"; + static final String ENABLED_ENV = "ELASTIC_OTEL_SCA_ENABLED"; + + static final String SKIP_TEMP_JARS_KEY = "elastic.otel.sca.skip_temp_jars"; + static final String SKIP_TEMP_JARS_ENV = "ELASTIC_OTEL_SCA_SKIP_TEMP_JARS"; + + static final String JARS_PER_SECOND_KEY = "elastic.otel.sca.jars_per_second"; + static final String JARS_PER_SECOND_ENV = "ELASTIC_OTEL_SCA_JARS_PER_SECOND"; + + static final boolean DEFAULT_ENABLED = true; + static final boolean DEFAULT_SKIP_TEMP_JARS = true; + static final int DEFAULT_JARS_PER_SECOND = 10; + + private final boolean enabled; + private final boolean skipTempJars; + private final int jarsPerSecond; + + private SCAConfiguration(boolean enabled, boolean skipTempJars, int jarsPerSecond) { + this.enabled = enabled; + this.skipTempJars = skipTempJars; + this.jarsPerSecond = jarsPerSecond; + } + + /** Reads current configuration from system properties and environment variables. */ + static SCAConfiguration get() { + return new SCAConfiguration( + readBoolean(ENABLED_KEY, ENABLED_ENV, DEFAULT_ENABLED), + readBoolean(SKIP_TEMP_JARS_KEY, SKIP_TEMP_JARS_ENV, DEFAULT_SKIP_TEMP_JARS), + readInt(JARS_PER_SECOND_KEY, JARS_PER_SECOND_ENV, DEFAULT_JARS_PER_SECOND)); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isSkipTempJars() { + return skipTempJars; + } + + public int getJarsPerSecond() { + return jarsPerSecond; + } + + private static boolean readBoolean(String sysProp, String envVar, boolean defaultValue) { + String value = System.getProperty(sysProp); + if (value == null) { + value = System.getenv(envVar); + } + if (value == null) { + return defaultValue; + } + return "true".equalsIgnoreCase(value.trim()); + } + + private static int readInt(String sysProp, String envVar, int defaultValue) { + String value = System.getProperty(sysProp); + if (value == null) { + value = System.getenv(envVar); + } + if (value == null) { + return defaultValue; + } + try { + int parsed = Integer.parseInt(value.trim()); + return parsed > 0 ? parsed : defaultValue; + } catch (NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/sca-extension/src/main/java/co/elastic/otel/sca/SCAExtension.java b/sca-extension/src/main/java/co/elastic/otel/sca/SCAExtension.java new file mode 100644 index 00000000..28a391d7 --- /dev/null +++ b/sca-extension/src/main/java/co/elastic/otel/sca/SCAExtension.java @@ -0,0 +1,146 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 co.elastic.otel.sca; + +import io.opentelemetry.javaagent.extension.AgentListener; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; +import java.lang.instrument.Instrumentation; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Entry point for the Elastic SCA extension. + * + *

Registered as both an {@link AutoConfigurationCustomizerProvider} and an {@link + * AgentListener} via two {@code META-INF/services/} files so that it participates in the OTel + * autoconfigure lifecycle at the correct phases: + * + *

    + *
  1. {@link #customize} — called before the SDK is built; registers default values for + * all {@code elastic.otel.sca.*} config keys so they are visible to the OTel config pipeline. + *
  2. {@link #afterAgent} — called after the SDK is fully initialised; reads the + * resolved configuration, obtains the JVM {@link Instrumentation} object, and starts the + * {@link JarCollectorService}. + *
+ * + *

Following the pattern of {@code inferred-spans} / {@code ElasticAutoConfigurationCustomizerProvider} + * + {@code ConfigLoggingAgentListener} in the {@code custom} module. + */ +public class SCAExtension implements AutoConfigurationCustomizerProvider, AgentListener { + + private static final Logger logger = Logger.getLogger(SCAExtension.class.getName()); + + // ---- AutoConfigurationCustomizerProvider -------------------------------- + + /** + * Registers default values for {@code elastic.otel.sca.*} properties so that OTel's config + * pipeline (system properties, env vars, SDK config file) can override them consistently. + * + *

Only sets a default when the user has not already supplied an explicit value, matching the + * pattern used by {@code InferredSpansBackwardsCompatibilityConfig}. + */ + @Override + public void customize(AutoConfigurationCustomizer config) { + config.addPropertiesCustomizer( + props -> { + Map defaults = new HashMap<>(); + setDefault(props, defaults, SCAConfiguration.ENABLED_KEY, + Boolean.toString(SCAConfiguration.DEFAULT_ENABLED)); + setDefault(props, defaults, SCAConfiguration.SKIP_TEMP_JARS_KEY, + Boolean.toString(SCAConfiguration.DEFAULT_SKIP_TEMP_JARS)); + setDefault(props, defaults, SCAConfiguration.JARS_PER_SECOND_KEY, + Integer.toString(SCAConfiguration.DEFAULT_JARS_PER_SECOND)); + return defaults; + }); + } + + private static void setDefault( + io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties props, + Map defaults, + String key, + String defaultValue) { + if (props.getString(key) == null) { + defaults.put(key, defaultValue); + } + } + + // ---- AgentListener ------------------------------------------------------ + + /** + * Starts the {@link JarCollectorService} once the OTel SDK is fully initialised. + * + *

The JVM {@link Instrumentation} object is obtained via reflection from {@code + * io.opentelemetry.javaagent.bootstrap.InstrumentationHolder}, which is the same internal holder + * used by the upstream OTel Java agent. This class lives in the bootstrap classloader and is + * accessible from the agent classloader at runtime without a compile-time dependency. + */ + @Override + public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetrySdk) { + SCAConfiguration config = SCAConfiguration.get(); + + if (!config.isEnabled()) { + logger.fine("SCA extension is disabled via " + SCAConfiguration.ENABLED_KEY); + return; + } + + Instrumentation instrumentation = findInstrumentation(); + if (instrumentation == null) { + logger.warning( + "SCA: could not obtain JVM Instrumentation object — JAR scanning will not run"); + return; + } + + JarCollectorService service = + new JarCollectorService( + autoConfiguredOpenTelemetrySdk.getOpenTelemetrySdk(), instrumentation, config); + service.start(); + } + + /** + * Retrieves the JVM {@link Instrumentation} instance from {@code + * io.opentelemetry.javaagent.bootstrap.InstrumentationHolder}. + * + *

The OTel Java agent stores the {@code Instrumentation} it receives in {@code premain()} in + * this bootstrap-classloader class. Because our code runs in the agent classloader (which + * delegates to bootstrap), we can reach it via {@link Class#forName} without importing it at + * compile time — and without using {@code ByteBuddyAgent.getInstrumentation()}. + * + *

This is the same mechanism used internally by the OTel contrib libraries (e.g. + * {@code opentelemetry-javaagent-inferred-spans}) to access the {@link Instrumentation}. + */ + static Instrumentation findInstrumentation() { + try { + Class holder = + Class.forName("io.opentelemetry.javaagent.bootstrap.InstrumentationHolder"); + Method getter = holder.getMethod("getInstrumentation"); + return (Instrumentation) getter.invoke(null); + } catch (Exception e) { + logger.log( + Level.WARNING, + "SCA: failed to obtain Instrumentation via InstrumentationHolder", + e); + return null; + } + } +} diff --git a/sca-extension/src/main/resources/META-INF/services/io.opentelemetry.javaagent.extension.AgentListener b/sca-extension/src/main/resources/META-INF/services/io.opentelemetry.javaagent.extension.AgentListener new file mode 100644 index 00000000..5de17566 --- /dev/null +++ b/sca-extension/src/main/resources/META-INF/services/io.opentelemetry.javaagent.extension.AgentListener @@ -0,0 +1 @@ +co.elastic.otel.sca.SCAExtension diff --git a/sca-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider b/sca-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider new file mode 100644 index 00000000..5de17566 --- /dev/null +++ b/sca-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider @@ -0,0 +1 @@ +co.elastic.otel.sca.SCAExtension diff --git a/sca-extension/src/test/java/co/elastic/otel/sca/JarMetadataExtractorTest.java b/sca-extension/src/test/java/co/elastic/otel/sca/JarMetadataExtractorTest.java new file mode 100644 index 00000000..a65b99c9 --- /dev/null +++ b/sca-extension/src/test/java/co/elastic/otel/sca/JarMetadataExtractorTest.java @@ -0,0 +1,222 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 co.elastic.otel.sca; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Properties; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JarMetadataExtractorTest { + + @TempDir File tempDir; + + // ---- pom.properties ------------------------------------------------------- + + @Test + void extractFromPomProperties() throws IOException { + File jar = buildJar("my-artifact-1.2.3.jar", jarOut -> { + addPomProperties(jarOut, "com.example", "my-artifact", "1.2.3"); + }); + + JarMetadata meta = JarMetadataExtractor.extract(jar.getAbsolutePath(), "testloader"); + + assertThat(meta).isNotNull(); + assertThat(meta.groupId).isEqualTo("com.example"); + assertThat(meta.name).isEqualTo("my-artifact"); + assertThat(meta.version).isEqualTo("1.2.3"); + assertThat(meta.purl).isEqualTo("pkg:maven/com.example/my-artifact@1.2.3"); + assertThat(meta.classloaderName).isEqualTo("testloader"); + } + + @Test + void pomPropertiesTakesPrecedenceOverManifest() throws IOException { + File jar = buildJar("artifact.jar", jarOut -> { + addPomProperties(jarOut, "com.pom", "pom-artifact", "2.0"); + addManifest(jarOut, "Manifest-Artifact", "9.9.9", null); + }); + + JarMetadata meta = JarMetadataExtractor.extract(jar.getAbsolutePath(), ""); + + assertThat(meta.groupId).isEqualTo("com.pom"); + assertThat(meta.name).isEqualTo("pom-artifact"); + assertThat(meta.version).isEqualTo("2.0"); + } + + // ---- MANIFEST.MF ---------------------------------------------------------- + + @Test + void extractFromManifest() throws IOException { + File jar = buildJar("manifest-only.jar", jarOut -> { + addManifest(jarOut, "My Library", "3.1.0", null); + }); + + JarMetadata meta = JarMetadataExtractor.extract(jar.getAbsolutePath(), ""); + + assertThat(meta).isNotNull(); + assertThat(meta.version).isEqualTo("3.1.0"); + // name falls back to Implementation-Title when no artifactId + assertThat(meta.name).isEqualTo("My Library"); + } + + @Test + void manifestSpecificationVersionUsedWhenImplementationVersionAbsent() throws IOException { + File jar = buildJar("spec-version.jar", jarOut -> { + addManifest(jarOut, "SpecLib", null, "4.0"); + }); + + JarMetadata meta = JarMetadataExtractor.extract(jar.getAbsolutePath(), ""); + + assertThat(meta).isNotNull(); + assertThat(meta.version).isEqualTo("4.0"); + } + + // ---- Filename fallback ---------------------------------------------------- + + @Test + void extractVersionFromFilename() throws IOException { + File jar = buildJar("guava-32.1.3-jre.jar", jarOut -> { /* no metadata */ }); + + JarMetadata meta = JarMetadataExtractor.extract(jar.getAbsolutePath(), ""); + + assertThat(meta).isNotNull(); + assertThat(meta.name).isEqualTo("guava"); + assertThat(meta.version).isEqualTo("32.1.3-jre"); + assertThat(meta.purl).isEqualTo("pkg:maven/guava@32.1.3-jre"); + } + + @Test + void noVersionInFilename() throws IOException { + File jar = buildJar("tools.jar", jarOut -> { /* no metadata */ }); + + JarMetadata meta = JarMetadataExtractor.extract(jar.getAbsolutePath(), ""); + + assertThat(meta).isNotNull(); + assertThat(meta.name).isEqualTo("tools"); + assertThat(meta.version).isEmpty(); + } + + // ---- pURL construction ---------------------------------------------------- + + @Test + void purlWithoutGroupId() { + assertThat(JarMetadataExtractor.buildPurl("", "my-lib", "1.0")) + .isEqualTo("pkg:maven/my-lib@1.0"); + } + + @Test + void purlWithoutVersion() { + assertThat(JarMetadataExtractor.buildPurl("org.example", "lib", "")) + .isEqualTo("pkg:maven/org.example/lib"); + } + + @Test + void purlEmptyWhenNoArtifactId() { + assertThat(JarMetadataExtractor.buildPurl("org.example", "", "1.0")).isEmpty(); + } + + // ---- SHA-256 -------------------------------------------------------------- + + @Test + void sha256IsDeterministic() throws IOException { + File jar = buildJar("stable.jar", jarOut -> { + addPomProperties(jarOut, "org.stable", "stable", "1.0"); + }); + + String first = JarMetadataExtractor.computeSha256(jar); + String second = JarMetadataExtractor.computeSha256(jar); + + assertThat(first).isNotEmpty().hasSize(64).isEqualTo(second); + } + + @Test + void sha256DiffersForDifferentContent() throws IOException { + File jar1 = buildJar("a.jar", jarOut -> addPomProperties(jarOut, "g", "a", "1")); + File jar2 = buildJar("b.jar", jarOut -> addPomProperties(jarOut, "g", "b", "2")); + + assertThat(JarMetadataExtractor.computeSha256(jar1)) + .isNotEqualTo(JarMetadataExtractor.computeSha256(jar2)); + } + + // ---- Missing file --------------------------------------------------------- + + @Test + void returnsNullForNonExistentFile() { + JarMetadata meta = JarMetadataExtractor.extract("/does/not/exist.jar", ""); + assertThat(meta).isNull(); + } + + // ---- Helpers -------------------------------------------------------------- + + @FunctionalInterface + interface JarPopulator { + void populate(JarOutputStream jarOut) throws IOException; + } + + private File buildJar(String name, JarPopulator populator) throws IOException { + File jar = new File(tempDir, name); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar))) { + populator.populate(jos); + } + return jar; + } + + private static void addPomProperties( + JarOutputStream jos, String groupId, String artifactId, String version) throws IOException { + jos.putNextEntry( + new JarEntry("META-INF/maven/" + groupId + "/" + artifactId + "/pom.properties")); + Properties props = new Properties(); + props.setProperty("groupId", groupId); + props.setProperty("artifactId", artifactId); + props.setProperty("version", version); + props.store(jos, null); + jos.closeEntry(); + } + + private static void addManifest( + JarOutputStream jos, + String implTitle, + String implVersion, + String specVersion) throws IOException { + Manifest mf = new Manifest(); + Attributes attrs = mf.getMainAttributes(); + attrs.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + if (implTitle != null) { + attrs.put(Attributes.Name.IMPLEMENTATION_TITLE, implTitle); + } + if (implVersion != null) { + attrs.put(Attributes.Name.IMPLEMENTATION_VERSION, implVersion); + } + if (specVersion != null) { + attrs.put(Attributes.Name.SPECIFICATION_VERSION, specVersion); + } + jos.putNextEntry(new JarEntry(JarFile.MANIFEST_NAME)); + mf.write(jos); + jos.closeEntry(); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 02d8cc39..6a793563 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ include("custom") include("instrumentation") include("instrumentation:openai-client-instrumentation:instrumentation-1.1") include("inferred-spans") +include("sca-extension") include("internal-logging") include("resources") include("runtime-attach")