From 3fc7cc39e86f893f7ee758b41b4263ccc72b2ea8 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 5 Nov 2026 23:35:19 +0100 Subject: [PATCH] feat(scan): scan jar, war and ear targets by their bundled Maven metadata `socket scan create` now accepts a .jar, .war or .ear as a target (not with --reach). A small Java 8 loader walks the archive and its nested archives with java.util.zip and reads each META-INF/maven/*/*/pom.properties with java.util.Properties; the CLI then writes `.socket.facts.json` next to the archive and uploads it with the scan. - The archive is the project; every library it ships is a direct dependency. - A nested archive is identified by the pom.properties its file name spells out (classifiers and timestamped snapshots included). - Libraries bundled inside a dependency jar are not reported until the SBOM schema can mark bundled libraries. - --exclude-paths applies; .gitignore and projectIgnorePaths do not hide an archive the user named. Co-Authored-By: Claude Opus 5.5 --- .config/rollup.dist.config.mjs | 40 +- .github/workflows/publish-npm.yml | 3 +- CHANGELOG.md | 5 + package.json | 1 + .../scripts/archive-loader/.gitignore | 1 + .../scripts/archive-loader/build-jar.sh | 11 + .../archive-loader/src/ArchiveLoader.java | 171 +++++++ src/commands/manifest/scripts/records.mts | 20 +- src/commands/manifest/scripts/run.mts | 4 +- src/commands/scan/cmd-scan-create.mts | 14 + src/commands/scan/cmd-scan-create.test.mts | 3 + .../scan/generate-archive-facts.e2e.test.mts | 131 +++++ src/commands/scan/generate-archive-facts.mts | 448 ++++++++++++++++++ .../scan/generate-archive-facts.test.mts | 314 ++++++++++++ src/commands/scan/handle-create-new-scan.mts | 40 +- .../scan/handle-create-new-scan.test.mts | 28 ++ src/utils/path-resolve.mts | 16 +- src/utils/path-resolve.test.mts | 16 +- 18 files changed, 1221 insertions(+), 45 deletions(-) create mode 100644 src/commands/manifest/scripts/archive-loader/.gitignore create mode 100755 src/commands/manifest/scripts/archive-loader/build-jar.sh create mode 100644 src/commands/manifest/scripts/archive-loader/src/ArchiveLoader.java create mode 100644 src/commands/scan/generate-archive-facts.e2e.test.mts create mode 100644 src/commands/scan/generate-archive-facts.mts create mode 100644 src/commands/scan/generate-archive-facts.test.mts diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index 1c41116912..8f609f78d4 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -105,7 +105,7 @@ 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. @@ -113,6 +113,7 @@ 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'), @@ -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}`, ) } } diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 246ab618f3..177bd60bbe 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c0eced2e26..273b868d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index fe3379c14a..4e22152d52 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/manifest/scripts/archive-loader/.gitignore b/src/commands/manifest/scripts/archive-loader/.gitignore new file mode 100644 index 0000000000..55c0af83d3 --- /dev/null +++ b/src/commands/manifest/scripts/archive-loader/.gitignore @@ -0,0 +1 @@ +archive-loader.jar diff --git a/src/commands/manifest/scripts/archive-loader/build-jar.sh b/src/commands/manifest/scripts/archive-loader/build-jar.sh new file mode 100755 index 0000000000..b392c6fa80 --- /dev/null +++ b/src/commands/manifest/scripts/archive-loader/build-jar.sh @@ -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" diff --git a/src/commands/manifest/scripts/archive-loader/src/ArchiveLoader.java b/src/commands/manifest/scripts/archive-loader/src/ArchiveLoader.java new file mode 100644 index 0000000000..0e92ca3ea4 --- /dev/null +++ b/src/commands/manifest/scripts/archive-loader/src/ArchiveLoader.java @@ -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. + * + *

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

+ *   archive     path
+ *   pom         archivePath  entry  groupId  artifactId  version   (absent keys are empty)
+ *   malformed   archivePath  entry  detail
+ *   unreadable  archivePath  detail
+ * 
+ */ +public final class ArchiveLoader { + private final Set archiveExtensions; + private final Writer out; + + private ArchiveLoader(Set 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 "); + System.exit(64); + } + Set 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 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///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)); + } + } + } +} diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 48cb3b6a7d..8d4caba39e 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -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 @@ -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] ?? '' diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index ea09c83a43..f97620ce31 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -88,7 +88,7 @@ export type WorkspaceEnumerationResult = { } // 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) } @@ -185,7 +185,7 @@ function assembleRecords( // 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. diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 5a2390b11f..8593458faa 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -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, @@ -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' @@ -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 ".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. @@ -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 diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 940d9e9628..f85e744766 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -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 ".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. diff --git a/src/commands/scan/generate-archive-facts.e2e.test.mts b/src/commands/scan/generate-archive-facts.e2e.test.mts new file mode 100644 index 0000000000..adda13dcbb --- /dev/null +++ b/src/commands/scan/generate-archive-facts.e2e.test.mts @@ -0,0 +1,131 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { generateArchiveFacts } from './generate-archive-facts.mts' + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + return c >>> 0 +}) + +function crc32(data: Buffer): number { + let crc = 0xffffffff + for (const byte of data) { + crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +// A zip of stored entries (APPNOTE.TXT 4.3), enough for jar/war/ear fixtures. +function zip(entries: Record): Buffer { + const locals: Buffer[] = [] + const centrals: Buffer[] = [] + let offset = 0 + for (const { 0: name, 1: content } of Object.entries(entries)) { + const nameBytes = Buffer.from(name, 'utf8') + const data = Buffer.isBuffer(content) + ? content + : Buffer.from(content, 'latin1') + const crc = crc32(data) + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(0x21, 12) + local.writeUInt32LE(crc, 14) + local.writeUInt32LE(data.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(nameBytes.length, 26) + const central = Buffer.alloc(46) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(20, 4) + central.writeUInt16LE(20, 6) + central.writeUInt16LE(0x21, 14) + central.writeUInt32LE(crc, 16) + central.writeUInt32LE(data.length, 20) + central.writeUInt32LE(data.length, 24) + central.writeUInt16LE(nameBytes.length, 28) + central.writeUInt32LE(offset, 42) + locals.push(local, nameBytes, data) + centrals.push(central, nameBytes) + offset += local.length + nameBytes.length + data.length + } + const centralDir = Buffer.concat(centrals) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50, 0) + const count = Object.keys(entries).length + end.writeUInt16LE(count, 8) + end.writeUInt16LE(count, 10) + end.writeUInt32LE(centralDir.length, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...locals, centralDir, end]) +} + +function pom(groupId: string, artifactId: string, version: string) { + return { + [`META-INF/maven/${groupId}/${artifactId}/pom.properties`]: `#Created by Apache Maven 3.9.9\nartifactId=${artifactId}\ngroupId=${groupId}\nversion=${version}\n`, + } +} + +// Runs the bundled archive loader: needs `pnpm run build:archive-loader`, a +// dist build and Java 8+. +describe('generateArchiveFacts with the archive loader', () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), 'socket-archive-e2e-')) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + async function generate(fileName: string, archive: Buffer) { + const archivePath = path.join(dir, fileName) + await fs.writeFile(archivePath, archive) + const result = await generateArchiveFacts(archivePath) + const facts = JSON.parse(await fs.readFile(result.factsPath, 'utf8')) + return { facts, result } + } + + it('reads nested archives and pom.properties through java.util', async () => { + const { facts, result } = await generate( + 'suite-1.0.ear', + zip({ + ...pom('com.example', 'suite', '1.0'), + 'web-1.0.war': zip({ + ...pom('com.example', 'web', '1.0'), + 'WEB-INF/lib/lib-a-2.0.jar': zip({ + 'META-INF/maven/org.a/lib-a/pom.properties': + '#comment\ngroupId = org.a\nartifactId:lib-\\\n a\nversion 2.0\n', + }), + 'WEB-INF/lib/broken-1.0.jar': 'not a zip', + }), + }), + ) + expect(result.factsPath).toBe( + path.join(dir, 'suite-1.0.ear.socket.facts.json'), + ) + expect(facts.projects[0]).toMatchObject({ name: 'suite', version: '1.0' }) + expect(facts.components.map((c: { id: string }) => c.id)).toEqual([ + 'com.example:web:war:1.0', + 'org.a:lib-a:jar:2.0', + ]) + expect(result.unreadableArchives).toEqual([ + expect.stringMatching( + /^suite-1\.0\.ear!\/web-1\.0\.war!\/WEB-INF\/lib\/broken-1\.0\.jar: /, + ), + ]) + }) + + it('throws for a file that is not a zip archive', async () => { + await expect( + generate('broken.jar', Buffer.from('not a zip')), + ).rejects.toThrow(/Could not read .*broken\.jar as a zip archive/) + }) +}) diff --git a/src/commands/scan/generate-archive-facts.mts b/src/commands/scan/generate-archive-facts.mts new file mode 100644 index 0000000000..4055198f48 --- /dev/null +++ b/src/commands/scan/generate-archive-facts.mts @@ -0,0 +1,448 @@ +import { existsSync, promises as fs, statSync } from 'node:fs' +import path from 'node:path' + +import micromatch from 'micromatch' + +import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { normalizePath } from '@socketsecurity/registry/lib/path' +import { spawn } from '@socketsecurity/registry/lib/spawn' +import { pluralize } from '@socketsecurity/registry/lib/words' + +import constants from '../../constants.mts' +import { InputError, getErrorMessage } from '../../utils/errors.mts' +import { withTmpDir } from '../../utils/fs.mts' +import { splitRecords } from '../manifest/scripts/records.mts' +import { + assertBundledAssetBuilt, + manifestScriptsPath, +} from '../manifest/scripts/run.mts' + +import type { + SocketFactsSbom, + SocketFactsSbomComponent, + SocketFactsSbomProject, +} from '../manifest/scripts/facts.mts' + +const ARCHIVE_EXTENSIONS = new Set(['ear', 'jar', 'war']) + +const CONTAINER_EXTENSIONS = new Set(['ear', 'war']) + +// ArchiveLoader.java's exit code when the scanned file is no zip archive. +const LOADER_EXIT_NOT_A_ZIP = 2 + +const NESTED_SEPARATOR = '!/' + +const SNAPSHOT = 'SNAPSHOT' + +// maven-resolver's AbstractArtifact.SNAPSHOT_TIMESTAMP without its prefix +// group: the timestamp and build number that replace `SNAPSHOT`. +const SNAPSHOT_TIMESTAMP = /^[0-9]{8}\.[0-9]{6}-[0-9]+/ + +type LoadedArchive = { + malformed: Array<{ detail: string; entry: string }> + poms: PomProperties[] + unreadable: string | undefined +} + +type MavenArtifact = PomProperties & { + // Both undefined for a pom.properties shaded into the scanned archive, which + // records neither. + classifier: string | undefined + extension: string | undefined +} + +type PomProperties = { + artifactId: string + entry: string + groupId: string + version: string +} + +export type ArchiveReport = { + malformedPomProperties: string[] + skippedBundled: string[] + unidentifiedArchives: string[] + unreadableArchives: string[] +} + +export type ArchiveFactsResult = ArchiveReport & { + componentCount: number + factsPath: string +} + +function addComponent( + components: Map, + artifact: MavenArtifact, +): void { + const qualifiers: Record = { + __proto__: null, + } as unknown as Record + if (artifact.classifier) { + qualifiers['classifier'] = artifact.classifier + } + if (artifact.extension) { + qualifiers['ext'] = artifact.extension + } + const id = [ + artifact.groupId, + artifact.artifactId, + artifact.extension, + artifact.classifier, + artifact.version, + ] + .filter(Boolean) + .join(':') + if (components.has(id)) { + return + } + components.set(id, { + type: 'maven', + namespace: artifact.groupId, + name: artifact.artifactId, + version: artifact.version, + ...(Object.keys(qualifiers).length ? { qualifiers } : {}), + id, + direct: true, + }) +} + +// `-[-]` (maven-war-plugin default naming), +// or a timestamped snapshot version (maven-resolver AbstractArtifact). +function classifierInFileName( + stem: string, + pom: PomProperties, +): string | undefined { + const prefix = `${pom.artifactId}-` + if (!stem.startsWith(prefix)) { + return undefined + } + const rest = stem.slice(prefix.length) + const afterVersion = (end: number): string | undefined => { + if (end === rest.length) { + return '' + } + return rest[end] === '-' && end + 1 < rest.length + ? rest.slice(end + 1) + : undefined + } + if (rest.startsWith(pom.version)) { + const classifier = afterVersion(pom.version.length) + if (classifier !== undefined) { + return classifier + } + } + if (pom.version.endsWith(SNAPSHOT)) { + const base = pom.version.slice(0, -SNAPSHOT.length) + if ((!base.length || base.endsWith('-')) && rest.startsWith(base)) { + const timestamp = SNAPSHOT_TIMESTAMP.exec(rest.slice(base.length)) + if (timestamp) { + return afterVersion(base.length + timestamp[0].length) + } + } + } + return undefined +} + +function coordinateOf(component: SocketFactsSbomComponent): string { + return `${component.namespace}:${component.name}:${component.version}` +} + +function extensionOf(fileName: string): string | undefined { + const ext = path.extname(fileName).slice(1) + return ARCHIVE_EXTENSIONS.has(ext) ? ext : undefined +} + +function javaBin(): string { + const executable = process.platform === 'win32' ? 'java.exe' : 'java' + const javaHome = process.env['JAVA_HOME'] + return javaHome ? path.join(javaHome, 'bin', executable) : executable +} + +function nestedFileName(archivePath: string): string { + return path.posix.basename(archivePath.split(NESTED_SEPARATOR).at(-1)!) +} + +// Trivy's own-artifact rule (jar/parse.go: file name spells out the coordinate), +// extended to classifiers, timestamped snapshots, wars and ears. +function ownArtifact( + fileName: string, + poms: PomProperties[], +): MavenArtifact | undefined { + const extension = extensionOf(fileName) + if (!extension) { + return undefined + } + const stem = fileName.slice(0, -(extension.length + 1)) + const matches: MavenArtifact[] = [] + for (const pom of poms) { + const classifier = classifierInFileName(stem, pom) + if (classifier !== undefined) { + matches.push({ ...pom, classifier: classifier || undefined, extension }) + } + } + // Two coordinates spelling the same file name cannot be told apart. + return matches.length === 1 ? matches[0] : undefined +} + +function parentOf(archivePath: string): string { + const i = archivePath.lastIndexOf(NESTED_SEPARATOR) + return i === -1 ? '' : archivePath.slice(0, i) +} + +// Record format: ArchiveLoader.java. The scanned archive has the path ''. +function parseLoaderRecords(text: string): Map { + const emptyArchive = (): LoadedArchive => ({ + malformed: [], + poms: [], + unreadable: undefined, + }) + const archives = new Map([['', emptyArchive()]]) + for (const fields of splitRecords(text)) { + const { 0: tag, 1: archivePath = '' } = fields + if (tag === 'archive') { + archives.set(archivePath, emptyArchive()) + continue + } + const archive = archives.get(archivePath)! + if (tag === 'pom') { + const { 2: entry = '', 3: groupId, 4: artifactId, 5: version } = fields + if (groupId && artifactId && version) { + archive.poms.push({ artifactId, entry, groupId, version }) + } else { + archive.malformed.push({ + detail: 'missing groupId, artifactId or version', + entry, + }) + } + } else if (tag === 'malformed') { + archive.malformed.push({ + detail: fields[3] ?? '', + entry: fields[2] ?? '', + }) + } else if (tag === 'unreadable') { + archive.unreadable = fields[2] ?? '' + } + } + return archives +} + +async function runArchiveLoader(archivePath: string): Promise { + const jarPath = manifestScriptsPath('archive-loader', 'archive-loader.jar') + assertBundledAssetBuilt(jarPath, 'Archive loader jar', 'build:archive-loader') + return await withTmpDir('socket-archive-', async tmpDir => { + const recordsFile = path.join(tmpDir, 'records.tsv') + const bin = javaBin() + try { + await spawn( + bin, + [ + '-jar', + jarPath, + archivePath, + recordsFile, + [...ARCHIVE_EXTENSIONS].join(','), + ], + { stdio: 'pipe' }, + ) + } catch (e) { + const { code, stderr } = e as { code?: unknown; stderr?: unknown } + if (typeof code !== 'number') { + throw new InputError( + `Could not run ${bin} to read ${archivePath}: ${getErrorMessage(e)}. Reading a Java archive needs Java 8 or newer on PATH or in JAVA_HOME.`, + ) + } + throw new InputError( + code === LOADER_EXIT_NOT_A_ZIP + ? `Could not read ${archivePath} as a zip archive: ${String(stderr).trim()}` + : `The archive loader failed on ${archivePath} (exit ${code}): ${String(stderr).trim()}`, + ) + } + return await fs.readFile(recordsFile, 'utf8') + }) +} + +function warnList(header: string, items: string[]): void { + if (items.length) { + logger.warn(`${header}\n${items.map(m => `- ${m}`).join('\n')}`) + } +} + +// The archive is the project and everything identified in it a direct +// dependency: an archive records containment, not which library needs which. +export function buildArchiveFacts( + fileName: string, + recordsText: string, +): { facts: SocketFactsSbom; report: ArchiveReport } { + const displayPath = (archivePath: string) => + archivePath ? `${fileName}${NESTED_SEPARATOR}${archivePath}` : fileName + const archives = parseLoaderRecords(recordsText) + const report: ArchiveReport = { + malformedPomProperties: [], + skippedBundled: [], + unidentifiedArchives: [], + unreadableArchives: [], + } + const reportMalformed = (archivePath: string, archive: LoadedArchive) => { + for (const { detail, entry } of archive.malformed) { + report.malformedPomProperties.push( + `${displayPath(archivePath)}${NESTED_SEPARATOR}${entry}: ${detail}`, + ) + } + } + // Only the scanned archive, wars and ears ship libraries; anything bundled + // in other archives is inside a dependency, unreported until REA-890. + const shipsLibraries = (archivePath: string): boolean => + !archivePath || + (CONTAINER_EXTENSIONS.has(extensionOf(nestedFileName(archivePath)) ?? '') && + shipsLibraries(parentOf(archivePath))) + + const root = archives.get('')! + const own = ownArtifact(fileName, root.poms) + reportMalformed('', root) + const components = new Map() + for (const { 0: archivePath, 1: archive } of archives) { + if (!archivePath) { + continue + } + const display = displayPath(archivePath) + if (!shipsLibraries(parentOf(archivePath))) { + report.skippedBundled.push(display) + continue + } + reportMalformed(archivePath, archive) + if (archive.unreadable !== undefined) { + report.unreadableArchives.push(`${display}: ${archive.unreadable}`) + continue + } + const nestedOwn = ownArtifact(nestedFileName(archivePath), archive.poms) + if (nestedOwn) { + addComponent(components, nestedOwn) + } else { + report.unidentifiedArchives.push(display) + } + for (const pom of archive.poms) { + if (pom.entry !== nestedOwn?.entry) { + report.skippedBundled.push(`${display}${NESTED_SEPARATOR}${pom.entry}`) + } + } + } + + // Shaded into the scanned archive by its own build; one also shipped as its + // own jar is the same library. + const shipped = new Set([...components.values()].map(coordinateOf)) + for (const pom of root.poms) { + if ( + pom.entry !== own?.entry && + !shipped.has(`${pom.groupId}:${pom.artifactId}:${pom.version}`) + ) { + addComponent(components, { + ...pom, + classifier: undefined, + extension: undefined, + }) + } + } + const sorted = [...components.values()].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0, + ) + const project: SocketFactsSbomProject = { + // An archive carrying no metadata of its own is named only by its file. + ...(own + ? { + type: 'maven', + namespace: own.groupId, + name: own.artifactId, + version: own.version, + } + : { type: 'generic', name: fileName }), + id: fileName, + subprojectDir: '.', + manifestFiles: [{ file: fileName }], + children: sorted.map((_, i) => i), + } + return { + facts: { + projects: [project], + components: sorted, + dependencies: sorted.map((_, i) => ({ component: i })), + }, + report, + } +} + +export function findArchiveTargets(targets: string[], cwd: string): string[] { + const archives = new Set() + for (const target of targets) { + const resolved = path.resolve(cwd, target) + if ( + extensionOf(path.basename(resolved)) && + existsSync(resolved) && + statSync(resolved).isFile() + ) { + archives.add(resolved) + } + } + return [...archives] +} + +export async function generateArchiveFacts( + archivePath: string, +): Promise { + const recordsText = await runArchiveLoader(archivePath) + const { facts, report } = buildArchiveFacts( + path.basename(archivePath), + recordsText, + ) + const factsPath = `${archivePath}${constants.DOT_SOCKET_DOT_FACTS_JSON}` + await fs.writeFile(factsPath, JSON.stringify(facts), 'utf8') + return { ...report, componentCount: facts.components.length, factsPath } +} + +export async function generateArchiveTargetFacts( + targets: string[], + { cwd, excludeGlobs }: { cwd: string; excludeGlobs: string[] }, +): Promise { + const factsPaths: string[] = [] + for (const archivePath of findArchiveTargets(targets, cwd)) { + const relative = path.relative(cwd, archivePath) + // excludeGlobs are --exclude-paths anchored at cwd, as discovery uses them. + if ( + micromatch.isMatch(normalizePath(relative), excludeGlobs, { dot: true }) + ) { + debugFn( + 'notice', + `Skipping archive target excluded by --exclude-paths: ${relative}`, + ) + continue + } + // eslint-disable-next-line no-await-in-loop + const result = await generateArchiveFacts(archivePath) + // Discovery yields forward slashes; matching lets the scan dedupe a facts + // file it also discovered. + factsPaths.push(normalizePath(result.factsPath)) + logger.info( + `Generated ${path.relative(cwd, result.factsPath)} from ${relative}: ${result.componentCount} ${pluralize('component', result.componentCount)} identified by bundled Maven metadata.`, + ) + if (result.unidentifiedArchives.length) { + logger.warn( + `${result.unidentifiedArchives.length} ${pluralize('archive', result.unidentifiedArchives.length)} inside ${relative} carry no pom.properties matching their file name and are not reported.`, + ) + } + warnList( + `Could not read these archives inside ${relative}; their libraries are not reported:`, + result.unreadableArchives, + ) + warnList( + `Skipped unreadable pom.properties in ${relative}:`, + result.malformedPomProperties, + ) + debugDir('inspect', { + archive: relative, + skippedBundled: result.skippedBundled, + unidentifiedArchives: result.unidentifiedArchives, + unreadableArchives: result.unreadableArchives, + }) + } + return factsPaths +} diff --git a/src/commands/scan/generate-archive-facts.test.mts b/src/commands/scan/generate-archive-facts.test.mts new file mode 100644 index 0000000000..eeda7cd65b --- /dev/null +++ b/src/commands/scan/generate-archive-facts.test.mts @@ -0,0 +1,314 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + buildArchiveFacts, + findArchiveTargets, + generateArchiveTargetFacts, +} from './generate-archive-facts.mts' + +function records(...lines: string[][]): string { + return lines.map(fields => `${fields.join('\t')}\n`).join('') +} + +function nested(archivePath: string): string[] { + return ['archive', archivePath] +} + +function pom( + archivePath: string, + groupId: string, + artifactId: string, + version: string, +): string[] { + return [ + 'pom', + archivePath, + `META-INF/maven/${groupId}/${artifactId}/pom.properties`, + groupId, + artifactId, + version, + ] +} + +function ids(facts: { components: Array<{ id: string }> }): string[] { + return facts.components.map(c => c.id) +} + +describe('buildArchiveFacts', () => { + it('reports each bundled library by its own pom.properties, all direct', () => { + const { facts, report } = buildArchiveFacts( + 'app-1.0.war', + records( + nested('WEB-INF/lib/lib-a-2.0.jar'), + pom('WEB-INF/lib/lib-a-2.0.jar', 'org.a', 'lib-a', '2.0'), + nested('WEB-INF/lib/lib-b-3.1-tests.jar'), + pom('WEB-INF/lib/lib-b-3.1-tests.jar', 'org.b', 'lib-b', '3.1'), + nested('WEB-INF/lib/gradle-built-4.0.jar'), + pom('', 'com.example', 'app', '1.0'), + ), + ) + expect(facts).toEqual({ + projects: [ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + id: 'app-1.0.war', + subprojectDir: '.', + manifestFiles: [{ file: 'app-1.0.war' }], + children: [0, 1], + }, + ], + components: [ + { + type: 'maven', + namespace: 'org.a', + name: 'lib-a', + version: '2.0', + qualifiers: { ext: 'jar' }, + id: 'org.a:lib-a:jar:2.0', + direct: true, + }, + { + type: 'maven', + namespace: 'org.b', + name: 'lib-b', + version: '3.1', + qualifiers: { classifier: 'tests', ext: 'jar' }, + id: 'org.b:lib-b:jar:tests:3.1', + direct: true, + }, + ], + dependencies: [{ component: 0 }, { component: 1 }], + }) + expect(report.unidentifiedArchives).toEqual([ + 'app-1.0.war!/WEB-INF/lib/gradle-built-4.0.jar', + ]) + }) + + it('skips libraries shaded into a nested jar', () => { + const lib = 'WEB-INF/lib/shaded-client-3.3.jar' + const { facts, report } = buildArchiveFacts( + 'app-1.0.war', + records( + nested(lib), + pom(lib, 'org.h', 'shaded-client', '3.3'), + pom(lib, 'com.google.guava', 'guava', '32.0'), + ), + ) + expect(ids(facts)).toEqual(['org.h:shaded-client:jar:3.3']) + expect(report.skippedBundled).toEqual([ + `app-1.0.war!/${lib}!/META-INF/maven/com.google.guava/guava/pom.properties`, + ]) + }) + + it('skips archives nested inside a dependency jar', () => { + const tool = 'WEB-INF/lib/tool-1.0.jar' + const { facts, report } = buildArchiveFacts( + 'app-1.0.war', + records( + nested(tool), + pom(tool, 'org.t', 'tool', '1.0'), + nested(`${tool}!/lib/guava-31.0.jar`), + pom(`${tool}!/lib/guava-31.0.jar`, 'com.google.guava', 'guava', '31.0'), + ), + ) + expect(ids(facts)).toEqual(['org.t:tool:jar:1.0']) + expect(report.skippedBundled).toEqual([ + `app-1.0.war!/${tool}!/lib/guava-31.0.jar`, + ]) + }) + + it('walks a war inside an ear', () => { + const { facts } = buildArchiveFacts( + 'suite-1.0.ear', + records( + pom('', 'com.example', 'suite', '1.0'), + nested('web-1.0.war'), + pom('web-1.0.war', 'com.example', 'web', '1.0'), + nested('web-1.0.war!/WEB-INF/lib/lib-a-2.0.jar'), + pom('web-1.0.war!/WEB-INF/lib/lib-a-2.0.jar', 'org.a', 'lib-a', '2.0'), + nested('lib/lib-b-3.0.jar'), + pom('lib/lib-b-3.0.jar', 'org.b', 'lib-b', '3.0'), + ), + ) + expect(ids(facts)).toEqual([ + 'com.example:web:war:1.0', + 'org.a:lib-a:jar:2.0', + 'org.b:lib-b:jar:3.0', + ]) + }) + + it('reports libraries shaded into the scanned archive itself', () => { + const { facts } = buildArchiveFacts( + 'app-1.0.jar', + records( + pom('', 'com.example', 'app', '1.0'), + pom('', 'com.google.guava', 'guava', '32.0'), + ), + ) + expect(facts.projects![0]).toMatchObject({ name: 'app', version: '1.0' }) + expect(facts.components).toEqual([ + { + type: 'maven', + namespace: 'com.google.guava', + name: 'guava', + version: '32.0', + id: 'com.google.guava:guava:32.0', + direct: true, + }, + ]) + }) + + it('reports a library both shaded in and shipped as a jar once', () => { + const { facts } = buildArchiveFacts( + 'app-1.0.jar', + records( + pom('', 'com.example', 'app', '1.0'), + pom('', 'org.a', 'lib-a', '2.0'), + nested('BOOT-INF/lib/lib-a-2.0.jar'), + pom('BOOT-INF/lib/lib-a-2.0.jar', 'org.a', 'lib-a', '2.0'), + ), + ) + expect(ids(facts)).toEqual(['org.a:lib-a:jar:2.0']) + }) + + it('names an archive without its own pom.properties by its file', () => { + const { facts } = buildArchiveFacts( + 'ROOT.war', + records( + nested('WEB-INF/lib/lib-a-2.0.jar'), + pom('WEB-INF/lib/lib-a-2.0.jar', 'org.a', 'lib-a', '2.0'), + ), + ) + expect(facts.projects![0]).toMatchObject({ + type: 'generic', + name: 'ROOT.war', + }) + expect(ids(facts)).toEqual(['org.a:lib-a:jar:2.0']) + }) + + it('keeps unreadable archives and pom.properties apart from unidentified ones', () => { + const { report } = buildArchiveFacts( + 'app-1.0.war', + records( + [ + 'malformed', + '', + 'META-INF/maven/org.a/a/pom.properties', + 'Malformed \\\\uxxxx encoding.', + ], + ['pom', '', 'META-INF/maven/org.b/b/pom.properties', '', 'b', ''], + nested('WEB-INF/lib/broken-1.0.jar'), + [ + 'unreadable', + 'WEB-INF/lib/broken-1.0.jar', + 'java.util.zip.ZipException: invalid', + ], + ), + ) + expect(report).toEqual({ + malformedPomProperties: [ + 'app-1.0.war!/META-INF/maven/org.a/a/pom.properties: Malformed \\uxxxx encoding.', + 'app-1.0.war!/META-INF/maven/org.b/b/pom.properties: missing groupId, artifactId or version', + ], + skippedBundled: [], + unidentifiedArchives: [], + unreadableArchives: [ + 'app-1.0.war!/WEB-INF/lib/broken-1.0.jar: java.util.zip.ZipException: invalid', + ], + }) + }) + + it.for([ + ['plain', 'lib-1.0.jar', '1.0', 'org.x:lib:jar:1.0'], + ['classifier', 'lib-1.0-sources.jar', '1.0', 'org.x:lib:jar:sources:1.0'], + [ + 'base snapshot', + 'lib-1.0-SNAPSHOT.jar', + '1.0-SNAPSHOT', + 'org.x:lib:jar:1.0-SNAPSHOT', + ], + [ + 'timestamped snapshot', + 'lib-1.0-20240102.030405-7.jar', + '1.0-SNAPSHOT', + 'org.x:lib:jar:1.0-SNAPSHOT', + ], + [ + 'timestamped snapshot with classifier', + 'lib-1.0-20240102.030405-7-tests.jar', + '1.0-SNAPSHOT', + 'org.x:lib:jar:tests:1.0-SNAPSHOT', + ], + ['renamed', 'renamed.jar', '1.0', undefined], + ['other version', 'lib-1.1.jar', '1.0', undefined], + ['version prefix only', 'lib-1.0x.jar', '1.0', undefined], + ] as const)( + 'identifies a nested jar by its file name: %s', + ([, fileName, version, expectedId]) => { + const lib = `WEB-INF/lib/${fileName}` + const { facts } = buildArchiveFacts( + 'ROOT.war', + records(nested(lib), pom(lib, 'org.x', 'lib', version)), + ) + expect(ids(facts)).toEqual(expectedId ? [expectedId] : []) + }, + ) +}) + +describe('findArchiveTargets', () => { + it('keeps existing archive files and nothing else', async () => { + const dir = await fs.mkdtemp(path.join(tmpdir(), 'socket-archive-targets-')) + try { + await fs.writeFile(path.join(dir, 'app.war'), '') + await fs.writeFile(path.join(dir, 'APP.WAR'), '') + await fs.writeFile(path.join(dir, 'pom.xml'), '') + await fs.mkdir(path.join(dir, 'dir.jar')) + expect( + findArchiveTargets( + [ + 'app.war', + './app.war', + 'APP.WAR', + 'pom.xml', + 'dir.jar', + 'missing.jar', + '.', + ], + dir, + ), + ).toEqual([path.join(dir, 'app.war')]) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('generateArchiveTargetFacts', () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), 'socket-archive-cwd-')) + await fs.mkdir(path.join(dir, 'build')) + await fs.writeFile(path.join(dir, 'build/app-1.0.jar'), '') + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('skips an archive matched by --exclude-paths', async () => { + await expect( + generateArchiveTargetFacts(['build/app-1.0.jar'], { + cwd: dir, + excludeGlobs: ['build', 'build/**'], + }), + ).resolves.toEqual([]) + }) +}) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index a60548033a..139e7212d0 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -11,6 +11,7 @@ import { applyFullExcludePaths } from './exclude-paths.mts' import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { finalizeTier1Scan } from './finalize-tier1-scan.mts' +import { generateArchiveTargetFacts } from './generate-archive-facts.mts' import { handleScanReport } from './handle-scan-report.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' @@ -235,6 +236,13 @@ async function createNewScan( logger.info('Auto-generation finished. Proceeding with Scan creation.') } + const { additionalScaIgnores, mergedReachabilityOptions } = + applyFullExcludePaths({ + cwd, + reachabilityOptions: reach, + target: targets[0]!, + }) + const { spinner } = constants const supportedFilesCResult = await fetchSupportedScanFileNames({ @@ -255,6 +263,11 @@ async function createNewScan( `Fetched ${supportedFilesCResult.data['size']} supported file types`, ) + const archiveFactsPaths = await generateArchiveTargetFacts(targets, { + cwd, + excludeGlobs: additionalScaIgnores, + }) + spinner.start('Searching for local files to include in scan...') const supportedFiles = supportedFilesCResult.data @@ -265,20 +278,19 @@ async function createNewScan( ? socketYmlResult.data?.parsed : undefined - const { additionalScaIgnores, mergedReachabilityOptions } = - applyFullExcludePaths({ - cwd, - reachabilityOptions: reach, - target: targets[0]!, - }) - - const packagePaths = - scanFiles ?? - (await getPackageFilesForScan(scanTargets, supportedFiles, { - additionalIgnores: additionalScaIgnores, - config: socketConfig, - cwd, - })) + // An archive the user named is scanned even where .gitignore or + // projectIgnorePaths would hide it, so its facts bypass discovery. + const packagePaths = Array.from( + new Set([ + ...(scanFiles ?? + (await getPackageFilesForScan(scanTargets, supportedFiles, { + additionalIgnores: additionalScaIgnores, + config: socketConfig, + cwd, + }))), + ...archiveFactsPaths, + ]), + ) spinner.successAndStop( `Found ${packagePaths.length} ${pluralize('file', packagePaths.length)} to include in scan.`, diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 628711d004..aa3eae76b8 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -23,6 +23,7 @@ const { mockFetchCreateOrgFullScan, mockFetchSupportedScanFileNames, mockFindSocketYmlSync, + mockGenerateArchiveTargetFacts, mockGenerateAutoManifest, mockGenerateRecursiveManifests, mockGetPackageFilesForScan, @@ -32,6 +33,7 @@ const { mockFetchCreateOrgFullScan: vi.fn(), mockFetchSupportedScanFileNames: vi.fn(), mockFindSocketYmlSync: vi.fn(), + mockGenerateArchiveTargetFacts: vi.fn(), mockGenerateAutoManifest: vi.fn(), mockGenerateRecursiveManifests: vi.fn(), mockGetPackageFilesForScan: vi.fn(), @@ -51,6 +53,10 @@ vi.mock('./finalize-tier1-scan.mts', () => ({ finalizeTier1Scan: vi.fn(), })) +vi.mock('./generate-archive-facts.mts', () => ({ + generateArchiveTargetFacts: mockGenerateArchiveTargetFacts, +})) + vi.mock('./handle-scan-report.mts', () => ({ handleScanReport: vi.fn(), })) @@ -134,6 +140,7 @@ describe('handleCreateNewScan excludePaths', () => { data: { parsed: { projectIgnorePaths: ['fixtures/**'] } }, ok: true, }) + mockGenerateArchiveTargetFacts.mockResolvedValue([]) mockGenerateAutoManifest.mockResolvedValue({ generatedFiles: [] }) mockGenerateRecursiveManifests.mockResolvedValue([]) mockGetPackageFilesForScan.mockResolvedValue(['package.json']) @@ -334,6 +341,27 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockFetchCreateOrgFullScan).toHaveBeenCalled() }) + it('uploads facts for an archive target even when discovery would ignore them', async () => { + mockGenerateArchiveTargetFacts.mockResolvedValueOnce([ + '/repo/target/app.war.socket.facts.json', + ]) + + await handleCreateNewScan( + createConfig({ targets: ['/repo', '/repo/target/app.war'] }), + ) + + expect(mockGenerateArchiveTargetFacts).toHaveBeenCalledWith( + ['/repo', '/repo/target/app.war'], + { cwd: '/repo', excludeGlobs: [] }, + ) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + ['package.json', '/repo/target/app.war.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.anything(), + ) + }) + it('generates nothing beyond Gradle/sbt/Maven when --dynamic-sbom-inference is used without --auto-manifest', async () => { mockGenerateRecursiveManifests.mockResolvedValueOnce([ { diff --git a/src/utils/path-resolve.mts b/src/utils/path-resolve.mts index 12dd96ad3f..458491113d 100644 --- a/src/utils/path-resolve.mts +++ b/src/utils/path-resolve.mts @@ -120,13 +120,10 @@ function normalizeScanInputPath(pathToNormalize: string, cwd: string): string { if (!path.isAbsolute(pathToNormalize)) { return pathToNormalize } - const relativePath = path.relative(cwd, pathToNormalize) - const isInsideCwd = - relativePath === '' || - (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) - if (!isInsideCwd) { + if (!isPathInside(pathToNormalize, cwd)) { return pathToNormalize } + const relativePath = path.relative(cwd, pathToNormalize) return stripTrailingSlash(relativePath.replaceAll('\\', '/')) || '.' } @@ -163,3 +160,12 @@ export async function getPackageFilesForScan( }, ) } + +export function isPathInside(target: string, dir: string): boolean { + const relativePath = path.relative(dir, target) + return ( + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ) +} diff --git a/src/utils/path-resolve.test.mts b/src/utils/path-resolve.test.mts index 2bbbb6d9a1..f957ad411a 100644 --- a/src/utils/path-resolve.test.mts +++ b/src/utils/path-resolve.test.mts @@ -14,7 +14,7 @@ import { PNPM_LOCK_YAML, YARN_LOCK, } from '../constants.mjs' -import { getPackageFilesForScan } from './path-resolve.mts' +import { getPackageFilesForScan, isPathInside } from './path-resolve.mts' import type FileSystem from 'mock-fs/lib/filesystem' @@ -442,3 +442,17 @@ describe('Path Resolve', () => { }) }) }) + +describe('isPathInside', () => { + const dir = path.resolve('/repo') + + it.for([ + ['the dir itself', '/repo', true], + ['a child', '/repo/build/app.jar', true], + ['a child named like a parent reference', '/repo/..cache.jar', true], + ['the parent', '/', false], + ['a sibling', '/other/app.jar', false], + ] as const)('%s', ([, target, expected]) => { + expect(isPathInside(path.resolve(target), dir)).toBe(expected) + }) +})