Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions .config/rollup.dist.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,15 @@ async function copyInitGradle() {
}

// Copy the build-tool resolution assets (Gradle init script, sbt plugin,
// Maven extension jar, dotnet tool) into dist/manifest-scripts, where run.mts resolves them
// Maven extension and archive loader jars, dotnet tool) into dist/manifest-scripts, where run.mts resolves them
// at runtime. The Maven jar is compiled by maven-extension/build-jar.sh (run in
// CI / local dev) and is absent from a fresh checkout — copy it only if present;
// run.mts surfaces a build hint when it's missing.
async function copyManifestScripts() {
const srcDir = path.join(constants.srcPath, 'commands/manifest/scripts')
const destDir = path.join(constants.distPath, 'manifest-scripts')
await fs.mkdir(path.join(destDir, 'maven-extension'), { recursive: true })
await fs.mkdir(path.join(destDir, 'archive-loader'), { recursive: true })
await Promise.all([
fs.copyFile(
path.join(srcDir, 'socket-facts.init.gradle'),
Expand Down Expand Up @@ -145,25 +146,34 @@ async function copyManifestScripts() {
' for a published build. Build it first: pnpm run build:dotnet-tool',
)
}
const jarPath = path.join(
srcDir,
'maven-extension',
'coana-maven-extension.jar',
await copyBuiltJar(
'Archive loader jar',
path.join('archive-loader', 'archive-loader.jar'),
'build:archive-loader',
)
if (existsSync(jarPath)) {
await copyBuiltJar(
'Maven manifest extension jar',
path.join('maven-extension', 'coana-maven-extension.jar'),
'build:maven-extension',
)
}

// Absent from a fresh checkout: a published build fails closed rather than ship
// a feature that silently produces nothing; run.mts hints for a dev build.
async function copyBuiltJar(description, relativePath, buildScript) {
const srcPath = path.join(
constants.srcPath,
'commands/manifest/scripts',
relativePath,
)
if (existsSync(srcPath)) {
await fs.copyFile(
jarPath,
path.join(destDir, 'maven-extension', 'coana-maven-extension.jar'),
srcPath,
path.join(constants.distPath, 'manifest-scripts', relativePath),
)
} else if (constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]) {
// Fail closed: a published build without the jar would ship a package whose
// `socket manifest maven` / Maven reachability silently produces an empty
// SBOM. Run `pnpm run build:maven-extension` before `build:dist`. (A local
// dev build tolerates a missing jar; run.mts surfaces a hint at runtime.)
throw new Error(
'Maven manifest extension jar not found at ' +
jarPath +
' for a published build. Build it first: pnpm run build:maven-extension',
`${description} not found at ${srcPath} for a published build. Build it first: pnpm run ${buildScript}`,
)
}
}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -379,12 +379,13 @@ jobs:
# download outside the shimmed process tree. The org action allowlist forbids
# actions/setup-java, so use a JDK pre-installed on the runner image
# (JAVA_HOME_17_X64), falling back to the runner's default `java`.
- name: Build Maven manifest extension jar
- name: Build Maven manifest extension and archive loader jars
run: |
if [ -n "${JAVA_HOME_17_X64:-}" ]; then
export JAVA_HOME="$JAVA_HOME_17_X64"
fi
bash src/commands/manifest/scripts/maven-extension/build-jar.sh
bash src/commands/manifest/scripts/archive-loader/build-jar.sh

# Same for the dotnet manifest tool: built here, bundled into
# dist/manifest-scripts, never committed. The runner image's preinstalled
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- `socket scan create` now scans a `.jar`, `.war` or `.ear` you pass as a target, reporting the Maven libraries it ships as identified by their bundled `pom.properties` (requires Java 8 or newer).

## [1.14.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.14.0) - 2026-11-05

### Added
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"scripts": {
"build": "pnpm build:dist",
"build:archive-loader": "bash src/commands/manifest/scripts/archive-loader/build-jar.sh",
"build:dist": "pnpm build:dist:src && pnpm build:dist:types",
"build:dist:src": "run-p -c clean:dist clean:external && dotenvx -q run -f .env.local -- rollup -c .config/rollup.dist.config.mjs",
"build:dist:types": "pnpm clean:dist:types && tsgo --project tsconfig.dts.json",
Expand Down
1 change: 1 addition & 0 deletions src/commands/manifest/scripts/archive-loader/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
archive-loader.jar
11 changes: 11 additions & 0 deletions src/commands/manifest/scripts/archive-loader/build-jar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Compile the archive loader to a Java 8 jar at the path the TS runner resolves:
# manifest-scripts/archive-loader/archive-loader.jar. Needs a JDK 9+ for --release 8.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
out="$(mktemp -d)"
trap 'rm -rf "$out"' EXIT
javac --release 8 -Xlint:-options -d "$out" "$here/src/ArchiveLoader.java"
jar --create --file "$here/archive-loader.jar" --main-class ArchiveLoader -C "$out" .
echo "Archive loader jar: $here/archive-loader.jar"
171 changes: 171 additions & 0 deletions src/commands/manifest/scripts/archive-loader/src/ArchiveLoader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import java.io.BufferedInputStream;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;

/**
* Walks a Java archive and every archive nested in it, writing what it finds as records for
* generate-archive-facts.mts, which decides what they mean.
*
* <p>Records, one per line, tab-separated, fields escaped like records.mts ({@code \\ \t \n \r}).
* An archive path is the chain of entry names from the scanned archive, joined by {@code !/};
* the scanned archive itself is the empty path.
*
* <pre>
* archive path
* pom archivePath entry groupId artifactId version (absent keys are empty)
* malformed archivePath entry detail
* unreadable archivePath detail
* </pre>
*/
public final class ArchiveLoader {
private final Set<String> archiveExtensions;
private final Writer out;

private ArchiveLoader(Set<String> archiveExtensions, Writer out) {
this.archiveExtensions = archiveExtensions;
this.out = out;
}

public static void main(String[] args) throws IOException {
if (args.length != 3) {
System.err.println("Usage: java -jar archive-loader.jar <archive> <recordsFile> <ext,ext,...>");
System.exit(64);
}
Set<String> extensions = new HashSet<>(Arrays.asList(args[2].split(",")));
ZipFile zip;
try {
zip = new ZipFile(new File(args[0]));
} catch (ZipException e) {
// generate-archive-facts.mts reports this exit as "not a zip archive".
System.err.println(e.getMessage());
System.exit(2);
return;
}
try (ZipFile root = zip;
Writer out =
new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(args[1]), StandardCharsets.UTF_8))) {
ArchiveLoader loader = new ArchiveLoader(extensions, out);
Enumeration<? extends ZipEntry> entries = root.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.isDirectory() && loader.wants(entry.getName())) {
try (InputStream in = root.getInputStream(entry)) {
loader.visit("", entry.getName(), in);
}
}
}
}
}

// The same escaping as SocketSupport.escapeField in the Maven extension.
private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r");
}

// ZipInputStream reads a non-zip as an empty archive. A zip starts with a local file header, or
// when empty with its (zip64) end of central directory record (APPNOTE.TXT 4.3.6).
private static boolean startsLikeZip(BufferedInputStream in) throws IOException {
byte[] signature = new byte[4];
in.mark(signature.length);
int read = 0;
while (read < signature.length) {
int n = in.read(signature, read, signature.length - read);
if (n < 0) {
break;
}
read += n;
}
in.reset();
return read == signature.length
&& signature[0] == 'P'
&& signature[1] == 'K'
&& ((signature[2] == 3 && signature[3] == 4)
|| (signature[2] == 5 && signature[3] == 6)
|| (signature[2] == 6 && signature[3] == 6));
}

// maven-archiver writes META-INF/maven/<groupId>/<artifactId>/pom.properties.
private static boolean isPomProperties(String entryName) {
String[] segments = entryName.split("/", -1);
return segments.length == 5
&& segments[0].equals("META-INF")
&& segments[1].equals("maven")
&& segments[4].equals("pom.properties");
}

private boolean isArchive(String entryName) {
int dot = entryName.lastIndexOf('.');
return dot > entryName.lastIndexOf('/')
&& archiveExtensions.contains(entryName.substring(dot + 1));
}

private boolean wants(String entryName) {
return isPomProperties(entryName) || isArchive(entryName);
}

private void record(String... fields) throws IOException {
for (int i = 0; i < fields.length; i++) {
if (i > 0) {
out.write('\t');
}
out.write(escape(fields[i]));
}
out.write('\n');
}

// `in` is positioned at the entry's data and is not closed here.
private void visit(String archivePath, String entryName, InputStream in) throws IOException {
if (isPomProperties(entryName)) {
Properties properties = new Properties();
try {
properties.load(in);
} catch (IllegalArgumentException | IOException e) {
record("malformed", archivePath, entryName, String.valueOf(e.getMessage()));
return;
}
record(
"pom",
archivePath,
entryName,
properties.getProperty("groupId", ""),
properties.getProperty("artifactId", ""),
properties.getProperty("version", ""));
} else if (isArchive(entryName)) {
String nestedPath = archivePath.isEmpty() ? entryName : archivePath + "!/" + entryName;
record("archive", nestedPath);
BufferedInputStream buffered = new BufferedInputStream(in);
if (!startsLikeZip(buffered)) {
record("unreadable", nestedPath, "not a zip archive");
return;
}
// Not closed: closing a ZipInputStream closes the stream it reads from.
ZipInputStream nested = new ZipInputStream(buffered, StandardCharsets.UTF_8);
try {
ZipEntry entry;
while ((entry = nested.getNextEntry()) != null) {
if (!entry.isDirectory()) {
visit(nestedPath, entry.getName(), nested);
}
}
} catch (IOException | IllegalArgumentException e) {
record("unreadable", nestedPath, String.valueOf(e));
}
}
}
}
20 changes: 13 additions & 7 deletions src/commands/manifest/scripts/records.mts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,18 @@ export type ParsedRecords = {
unscannable: Unscannable[]
}

// One field array per non-empty line; tolerates an emitter that writes CRLF.
export function splitRecords(text: string): string[][] {
const records: string[][] = []
for (const line of text.split('\n')) {
const rawLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (rawLine) {
records.push(rawLine.split('\t').map(unescapeField))
}
}
return records
}

export function unescapeField(s: string): string {
if (!s.includes('\\')) {
return s
Expand Down Expand Up @@ -233,13 +245,7 @@ export function parseRecords(text: string): ParsedRecords {
return b
}

for (const line of text.split('\n')) {
// Tolerates an emitter that writes CRLF line endings.
const rawLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!rawLine) {
continue
}
const f = rawLine.split('\t').map(unescapeField)
for (const f of splitRecords(text)) {
switch (f[0]) {
case 'meta':
result.tool = f[1] ?? ''
Expand Down
4 changes: 2 additions & 2 deletions src/commands/manifest/scripts/run.mts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
}

// Bundled emitter assets, copied into dist by the rollup build.
function manifestScriptsPath(...parts: string[]): string {
export function manifestScriptsPath(...parts: string[]): string {
return path.join(constants.distPath, 'manifest-scripts', ...parts)
}

Expand Down Expand Up @@ -185,7 +185,7 @@

// Missing only in an unbuilt local checkout. Fail loudly: without the
// extension, Maven silently emits an empty SBOM.
function assertBundledAssetBuilt(
export function assertBundledAssetBuilt(
assetPath: string,
description: string,
// The package.json script that builds it, as the dist build names it too.
Expand All @@ -194,7 +194,7 @@
if (existsSync(assetPath)) {
return
}
throw new Error(

Check failure on line 197 in src/commands/manifest/scripts/run.mts

View workflow job for this annotation

GitHub Actions / e2e-tests (24, ubuntu-latest)

src/commands/scan/generate-archive-facts.e2e.test.mts > generateArchiveFacts with the archive loader > reads nested archives and pom.properties through java.util

Error: Archive loader jar not found at /home/runner/work/socket-cli/socket-cli/dist/manifest-scripts/archive-loader/archive-loader.jar. It is bundled in the published CLI; for local dev build it with: pnpm run build:archive-loader ❯ assertBundledAssetBuilt src/commands/manifest/scripts/run.mts:197:9 ❯ runArchiveLoader src/commands/scan/generate-archive-facts.mts:232:3 ❯ generateArchiveFacts src/commands/scan/generate-archive-facts.mts:392:29 ❯ generate src/commands/scan/generate-archive-facts.e2e.test.mts:91:26 ❯ src/commands/scan/generate-archive-facts.e2e.test.mts:97:31

Check failure on line 197 in src/commands/manifest/scripts/run.mts

View workflow job for this annotation

GitHub Actions / e2e-tests (22, ubuntu-latest)

src/commands/scan/generate-archive-facts.e2e.test.mts > generateArchiveFacts with the archive loader > reads nested archives and pom.properties through java.util

Error: Archive loader jar not found at /home/runner/work/socket-cli/socket-cli/dist/manifest-scripts/archive-loader/archive-loader.jar. It is bundled in the published CLI; for local dev build it with: pnpm run build:archive-loader ❯ assertBundledAssetBuilt src/commands/manifest/scripts/run.mts:197:9 ❯ runArchiveLoader src/commands/scan/generate-archive-facts.mts:232:3 ❯ generateArchiveFacts src/commands/scan/generate-archive-facts.mts:392:29 ❯ generate src/commands/scan/generate-archive-facts.e2e.test.mts:91:26 ❯ src/commands/scan/generate-archive-facts.e2e.test.mts:97:31
`${description} not found at ${assetPath}. It is bundled in the published CLI; for local dev build it with: pnpm run ${buildScript}`,
)
}
Expand Down
14 changes: 14 additions & 0 deletions src/commands/scan/cmd-scan-create.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path'
import { logger } from '@socketsecurity/registry/lib/logger'

import { assertValidExcludePaths } from './exclude-paths.mts'
import { findArchiveTargets } from './generate-archive-facts.mts'
import {
generateUvMemberSboms,
resolveUvMemberDirs,
Expand Down Expand Up @@ -44,6 +45,7 @@ import {
getFlagApiRequirementsOutput,
getFlagListOutput,
} from '../../utils/output-formatting.mts'
import { isPathInside } from '../../utils/path-resolve.mts'
import { hasDefaultApiToken } from '../../utils/sdk.mts'
import { readOrDefaultSocketJsonUp } from '../../utils/socket-json.mts'
import { socketDashboardLink } from '../../utils/terminal-link.mts'
Expand Down Expand Up @@ -240,6 +242,9 @@ async function run(
- Defaults to the current dir (cwd) if none given
- Multiple targets can be specified
- If a target is a file, only that file is checked
- If it is a .jar, .war or .ear, the Maven libraries identified by the
pom.properties bundled in it are written to "<archive>.socket.facts.json"
next to it and scanned (needs Java 8+; not with --reach)
- If it is a dir, the dir is scanned for any supported manifest files
- Dirs MUST be within the current dir (cwd), you can use --cwd to change it
- Supports globbing such as "**/package.json", "**/${REQUIREMENTS_TXT}", etc.
Expand Down Expand Up @@ -665,6 +670,15 @@ async function run(
'Target directory must be inside the current working directory when --reach is enabled',
fail: 'provide a path inside the working directory',
},
{
nook: true,
test: findArchiveTargets(targets, cwd).every(archive =>
isPathInside(archive, cwd),
),
message:
'A .jar, .war or .ear target must be inside the current working directory',
fail: 'provide a path inside the working directory, or use --cwd',
},
)
if (!wasValidInput) {
return
Expand Down
3 changes: 3 additions & 0 deletions src/commands/scan/cmd-scan-create.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ describe('socket scan create', async () => {
- Defaults to the current dir (cwd) if none given
- Multiple targets can be specified
- If a target is a file, only that file is checked
- If it is a .jar, .war or .ear, the Maven libraries identified by the
pom.properties bundled in it are written to "<archive>.socket.facts.json"
next to it and scanned (needs Java 8+; not with --reach)
- If it is a dir, the dir is scanned for any supported manifest files
- Dirs MUST be within the current dir (cwd), you can use --cwd to change it
- Supports globbing such as "**/package.json", "**/requirements.txt", etc.
Expand Down
Loading
Loading