Skip to content
Open
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
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,14 @@ updates:
- "/test/providers/provider_manifests/yarn-berry/without_lock_file"
- "/test/providers/provider_manifests/yarn-classic/with_lock_file"
- "/test/providers/provider_manifests/yarn-classic/without_lock_file"
- "/test/providers/tst_manifests/bun/alias"
- "/test/providers/tst_manifests/bun/nested"
- "/test/providers/tst_manifests/bun/optional"
- "/test/providers/tst_manifests/bun/package_json_deps_with_exhortignore_object"
- "/test/providers/tst_manifests/bun/package_json_deps_with_mixed_dep_types"
- "/test/providers/tst_manifests/bun/package_json_deps_without_exhortignore_object"
- "/test/providers/tst_manifests/bun/workspace"
- "/test/providers/tst_manifests/bun/workspace/packages/member"
- "/test/providers/tst_manifests/bun/workspace_member"
- "/test/providers/tst_manifests/bun/workspace_member/packages/member-a"
- "/test/providers/tst_manifests/npm/package_json_deps_with_exhortignore_object"
Expand Down
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ $ trustify-da remediate /path/to/pom.xml --exclude 'pkg:maven/com.example/*' --e
<li><a href="https://www.javascript.com/">JavaScript</a> - <a href="https://www.npmjs.com/">Npm</a></li>
<li><a href="https://www.javascript.com/">JavaScript</a> - <a href="https://pnpm.io/">pnpm</a></li>
<li><a href="https://www.javascript.com/">JavaScript</a> - <a href="https://classic.yarnpkg.com/">Yarn Classic</a> / <a href="https://yarnpkg.com/">Yarn Berry</a></li>
<li><a href="https://www.javascript.com/">JavaScript</a> - <a href="https://bun.com/">Bun</a></li>
<li><a href="https://go.dev/">Golang</a> - <a href="https://go.dev/blog/using-go-modules/">Go Modules</a></li>
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a> (<code>requirements.txt</code>)</li>
<li><a href="https://www.python.org/">Python</a> - <a href="https://python-poetry.org/">Poetry</a> / <a href="https://docs.astral.sh/uv/">uv</a> (<code>pyproject.toml</code>)</li>
Expand Down Expand Up @@ -522,7 +523,7 @@ let options = {
'TRUSTIFY_DA_POETRY_PATH' : '/path/to/poetry',
'TRUSTIFY_DA_GRADLE_PATH' : '/path/to/gradle',
'TRUSTIFY_DA_CARGO_PATH' : '/path/to/cargo',
// Workspace root for monorepos (Cargo, npm/pnpm/yarn); lock file expected here
// Workspace root for monorepos (Cargo, npm/pnpm/yarn/Bun); lock file expected here
'workspaceDir': '/path/to/workspace-root',
// Configure proxy for all requests
'TRUSTIFY_DA_PROXY_URL': 'http://proxy.example.com:8080'
Expand All @@ -548,31 +549,34 @@ let imageAnalysisWithArch = await client.imageAnalysis(['httpd:2.4.49^^amd64'],

<h4>Monorepo / Workspace Support</h4>
<p>
For monorepos (Cargo workspaces, npm/pnpm/yarn workspaces) where the lock file lives at the workspace root rather than next to the manifest, pass the workspace root via <code>workspaceDir</code> or <code>TRUSTIFY_DA_WORKSPACE_DIR</code>:
For monorepos (Cargo workspaces, npm/pnpm/yarn/Bun workspaces) where the lock file lives at the workspace root rather than next to the manifest, pass the workspace root via <code>workspaceDir</code> or <code>TRUSTIFY_DA_WORKSPACE_DIR</code>:
</p>
<ul>
<li><strong>Cargo:</strong> When set, the client checks only the given directory for <code>Cargo.lock</code> instead of walking up from the manifest.</li>
<li><strong>JavaScript (npm, pnpm, yarn):</strong> When set, the client looks for the lock file (<code>package-lock.json</code>, <code>pnpm-lock.yaml</code>, <code>yarn.lock</code>) at the workspace root.</li>
<li><strong>JavaScript (npm, pnpm, yarn, Bun):</strong> When set, the client looks for the lock file (<code>package-lock.json</code>, <code>pnpm-lock.yaml</code>, <code>yarn.lock</code>, <code>bun.lock</code>) at the workspace root.</li>
</ul>
<p>
Use <code>stackAnalysisBatch(workspaceRoot, html, opts)</code> to analyze all packages/crates in a workspace in one request. Supports Cargo workspaces and JS/TS workspaces (pnpm, npm, yarn). Optional <code>batchConcurrency</code> (or <code>TRUSTIFY_DA_BATCH_CONCURRENCY</code>) limits parallel SBOM generation (default 10). For JS/TS, each <code>package.json</code> must have non-empty <code>name</code> and <code>version</code>; invalid manifests are skipped (warnings). Per-manifest SBOM failures are skipped if at least one SBOM succeeds (unless <code>continueOnError: false</code>). Set <code>batchMetadata: true</code> (or <code>TRUSTIFY_DA_BATCH_METADATA</code>) to receive <code>{ analysis, metadata }</code> with <code>errors[]</code>. CLI: <code>stack-batch --metadata</code>, <code>--fail-fast</code>. See <a href="./docs/monorepo-implementation-plan.md">monorepo implementation plan</a> §2.3 and §3.5.
Use <code>stackAnalysisBatch(workspaceRoot, html, opts)</code> to analyze all packages/crates in a workspace in one request. Supports Cargo workspaces and JS/TS workspaces (pnpm, npm, yarn, Bun). Optional <code>batchConcurrency</code> (or <code>TRUSTIFY_DA_BATCH_CONCURRENCY</code>) limits parallel SBOM generation (default 10). For JS/TS, each <code>package.json</code> must have non-empty <code>name</code> and <code>version</code>; invalid manifests are skipped (warnings). Per-manifest SBOM failures are skipped if at least one SBOM succeeds (unless <code>continueOnError: false</code>). Set <code>batchMetadata: true</code> (or <code>TRUSTIFY_DA_BATCH_METADATA</code>) to receive <code>{ analysis, metadata }</code> with <code>errors[]</code>. CLI: <code>stack-batch --metadata</code>, <code>--fail-fast</code>. See <a href="./docs/monorepo-implementation-plan.md">monorepo implementation plan</a> §2.3 and §3.5.
</p>
<p>
See <a href="./docs/vscode-extension-integration-requirements.md">VS Code Extension Integration Requirements</a> for integration details.
</p>

<h4>JavaScript Lockfile Analysis</h4>
<p>
npm and Yarn component/stack analysis reads package manifests and lockfiles directly. It does not run npm, Yarn, or Corepack, install dependencies, access package registries, or generate project files. Installed <code>node_modules</code> and Yarn installation state are not required.
npm, Yarn, and Bun component/stack analysis reads package manifests and lockfiles directly. It does not run npm, Yarn, Bun, or Corepack, install dependencies, access package registries, or generate or update project files. These package managers do not need to be installed for scanning. Installed <code>node_modules</code> and Yarn installation state are not required.
</p>
<p>
npm supports lockfile versions 2 and 3, including <code>npm-shrinkwrap.json</code> (which takes precedence over <code>package-lock.json</code>). Yarn Classic and Berry are selected from the lockfile format, so a <code>packageManager</code> declaration is not required. Missing, stale, unsupported, or incomplete required dependency metadata produces an error; synchronize or regenerate the lockfile with the project's package manager before scanning. npm version 1 lockfiles must be upgraded first. Peer and optional dependencies that have no locked resolution retain the existing manifest-only SBOM entries.
</p>
<p>
Bun requires a pre-existing text <code>bun.lock</code> with numeric <code>lockfileVersion</code> 0, 1, or 2. Binary <code>bun.lockb</code> files are unsupported; migrate them before scanning using Bun 1.2 or later: <code>bun install --save-text-lockfile --frozen-lockfile --lockfile-only</code>.
</p>
<p>
Workspaces and package-aware local dependencies use their manifests and recorded dependency metadata. Unsupported custom resolution protocols, package-bearing <code>link:</code> dependencies, and Classic dependency cycles with path-dependent <code>resolutions</code> produce explicit errors. Yarn plugins are not executed. Validation covers declarations and resolutions recorded in these files; it does not replace package-manager installation or validate package contents.
</p>
<p>
<code>TRUSTIFY_DA_NPM_PATH</code> and <code>TRUSTIFY_DA_YARN_PATH</code> are retained for compatibility but are not used for dependency analysis. pnpm, Bun, and other ecosystems retain their existing tool requirements. Lockfile hashes are reported when available; analysis does not download archives to verify them.
<code>TRUSTIFY_DA_NPM_PATH</code>, <code>TRUSTIFY_DA_YARN_PATH</code>, and <code>TRUSTIFY_DA_BUN_PATH</code> are retained for compatibility but are not used for dependency analysis. pnpm and other ecosystems retain their existing tool requirements. Lockfile hashes are reported when available; analysis does not download archives to verify them.
</p>

<h4>Proxy Configuration</h4>
Expand Down Expand Up @@ -605,9 +609,7 @@ The client can resolve the <strong>project license</strong> from the manifest (e

<h4>Customizing Executables</h4>
<p>
This project uses each ecosystem's executable for creating dependency trees. These executables are expected to be
present on the system's PATH environment. If they are not, or perhaps you want to use custom ones. Use can use the
following keys for setting custom paths for the said executables.
For ecosystems that require external tools, executables must be available on the system's <code>PATH</code> or configured using the keys below.
</p>

<table>
Expand Down Expand Up @@ -642,6 +644,11 @@ following keys for setting custom paths for the said executables.
<td>TRUSTIFY_DA_YARN_PATH (retained for compatibility; dependency analysis does not invoke Yarn)</td>
</tr>
<tr>
<td><a href="https://bun.com/">Bun</a></td>
<td><em>bun</em></td>
<td>TRUSTIFY_DA_BUN_PATH (retained for compatibility; dependency analysis does not invoke Bun)</td>
</tr>
<tr>
<td><a href="https://go.dev/blog/using-go-modules/">Go Modules</a></td>
<td><em>go</em></td>
<td>TRUSTIFY_DA_GO_PATH</td>
Expand Down
26 changes: 22 additions & 4 deletions src/providers/base_javascript.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@
return [this._lockFileName()];
}

/**
* Returns the name of a binary lock file the provider cannot analyze but
* should still recognize during matching so it can surface an actionable
* migrate diagnostic (e.g. Bun's `bun.lockb`). Null means none.
* @returns {string|null} The binary lock file name, or null
* @protected
*/
_binaryLockFileName() {
return null;
}

/**
* Returns the command name to use for the specific JS package manager
* @returns {string} The command name
Expand Down Expand Up @@ -266,11 +277,11 @@
return false
}

_findLockFileDir(manifestDir, opts = {}) {
_findLockFileDir(manifestDir, opts = {}, names = this._lockFileNames()) {
const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts)
if (workspaceDir) {
const dir = path.resolve(workspaceDir)
return this._lockFileNames().some(name => fs.existsSync(path.join(dir, name))) ? dir : null
return names.some(name => fs.existsSync(path.join(dir, name))) ? dir : null
}

let dir = path.resolve(manifestDir)
Expand All @@ -279,7 +290,7 @@
do {
dir = parent

if (this._lockFileNames().some(name => fs.existsSync(path.join(dir, name)))) {
if (names.some(name => fs.existsSync(path.join(dir, name)))) {
return dir
}

Expand All @@ -299,7 +310,14 @@
* @returns {boolean} True if the lock file exists
*/
validateLockFile(manifestDir, opts = {}) {
return this._findLockFileDir(manifestDir, opts) !== null
if (this._findLockFileDir(manifestDir, opts) !== null) {

Check warning on line 313 in src/providers/base_javascript.js

View workflow job for this annotation

GitHub Actions / Lint and test project (24)

Expected '!=' and instead saw '!=='

Check warning on line 313 in src/providers/base_javascript.js

View workflow job for this annotation

GitHub Actions / Lint and test project (22)

Expected '!=' and instead saw '!=='
return true
}
// Also match on a binary lock file (e.g. bun.lockb) so the provider is
// selected and can raise its own actionable migrate diagnostic instead of
// the generic "requires a lock file" error from match().
const binary = this._binaryLockFileName()
return binary !== null && this._findLockFileDir(manifestDir, opts, [binary]) !== null

Check warning on line 320 in src/providers/base_javascript.js

View workflow job for this annotation

GitHub Actions / Lint and test project (24)

Expected '!=' and instead saw '!=='

Check warning on line 320 in src/providers/base_javascript.js

View workflow job for this annotation

GitHub Actions / Lint and test project (24)

Expected '!=' and instead saw '!=='

Check warning on line 320 in src/providers/base_javascript.js

View workflow job for this annotation

GitHub Actions / Lint and test project (22)

Expected '!=' and instead saw '!=='

Check warning on line 320 in src/providers/base_javascript.js

View workflow job for this annotation

GitHub Actions / Lint and test project (22)

Expected '!=' and instead saw '!=='
}

/**
Expand Down
175 changes: 56 additions & 119 deletions src/providers/javascript_bun.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import fs from 'node:fs'
import path from 'node:path'

import { parse } from 'jsonc-parser';

import Base_javascript, { sriToHash } from './base_javascript.js';
import { parseBunEntry, readBunGraph, readBunLock } from './javascript_lockfile.js';

export default class Javascript_bun extends Base_javascript {

_lockFileName() {
return "bun.lock";
}

/** The binary lock file Bun cannot analyze but recognizes to drive a migrate diagnostic. */
_binaryLockFileName() {
return "bun.lockb";
}

_cmdName() {
return "bun";
}

/** Initializes the provider without resolving the bun executable. */
_setUp(manifestPath) {
this._setUpManifest(manifestPath);
}

/**
* Parses `bun.lock` into a hash map keyed by `name@version`. Each entry in
* the `packages` section is an array whose first element is the resolved id
* (`name@version`) and whose fourth element is the SRI integrity string.
* Parses `bun.lock` into a hash map keyed by `name@version`. Each entry in the
* `packages` section is a tuple whose resolved id (`name@version`) and trailing
* integrity string are extracted by {@link parseBunEntry}, so npm, hosted/git
* and empty-registry entries are all handled.
* @param {string} lockDir - Directory containing bun.lock
* @returns {Map<string, Array<{alg: string, content: string}>>} Hash map
* @protected
Expand All @@ -27,137 +41,60 @@ export default class Javascript_bun extends Base_javascript {
}
let lockData;
try {
lockData = parse(fs.readFileSync(lockPath, 'utf-8'));
lockData = readBunLock(lockPath);
} catch (_) {
return map;
}
const packages = lockData?.packages || {};
for (const entry of Object.values(packages)) {
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || typeof entry[3] !== 'string') {
if (!Array.isArray(entry) || typeof entry[0] !== 'string') {
continue;
}
const id = entry[0];
const at = id.lastIndexOf('@');
if (at <= 0) {
// Skip a malformed/unreachable entry (e.g. an id without `@`, which
// makes parseBunEntry throw) rather than aborting the whole scan and
// dropping valid direct-dependency hashes. Entries reachable from the
// manifest are still validated by readBunGraph (the stack path).
let parsed;
try {
parsed = parseBunEntry(entry);
} catch (_) {
continue;
}
const name = id.slice(0, at);
const version = id.slice(at + 1);
const hash = sriToHash(entry[3]);
const { name, version, integrity } = parsed;
const hash = integrity ? sriToHash(integrity) : null;
if (name && version && hash) {
map.set(`${name}@${version}`, [hash]);
}
}
return map;
}

_cmdName() {
return "bun";
}

_listCmdArgs() {
throw new Error("not supported by Bun");
}

_updateLockFileCmdArgs() {
return ['install', '--lockfile-only'];
}

_buildDependencyTree(includeTransitive, opts = {}) {
this._version();
const manifestDir = path.dirname(this._getManifest().manifestPath);
const lockDir = this._findLockFileDir(manifestDir, opts) || manifestDir;
this._createLockFile(lockDir);
this._loadHashes(lockDir);

const lockContent = fs.readFileSync(path.join(lockDir, 'bun.lock'), 'utf-8');
const lockData = parse(lockContent);

const packages = lockData.packages || {};
const memberName = this._getManifest().name;
const workspaceEntry = this.#findWorkspaceEntry(lockData, lockDir, manifestDir, memberName);

const directDeps = workspaceEntry?.dependencies || {};
const tree = { name: memberName, version: this._getManifest().version, dependencies: {} };

const visited = new Set();
for (const depName of Object.keys(directDeps)) {
const resolved = this.#resolvePackage(depName, '', packages);
if (resolved) {
tree.dependencies[depName] = this.#buildNode(depName, resolved, packages, includeTransitive, visited);
}
}

return tree;
}

#findWorkspaceEntry(lockData, lockDir, manifestDir, memberName) {
const workspaces = lockData.workspaces || {};
const relPath = path.relative(lockDir, path.resolve(manifestDir));
if (!relPath || relPath === '.') {
return workspaces[''] || {};
}
const normalised = relPath.split(path.sep).join('/');
if (workspaces[normalised]) {
return workspaces[normalised];
}
for (const [wsPath, entry] of Object.entries(workspaces)) {
if (entry.name === memberName && wsPath !== '') {
return entry;
}
}
return workspaces[''] || {};
}

#resolvePackage(depName, parentKey, packages) {
if (parentKey) {
const scopedKey = `${parentKey}/${depName}`;
if (packages[scopedKey]) {
return packages[scopedKey];
}
}
return packages[depName] || null;
}

#buildNode(depName, resolved, packages, includeTransitive, visited) {
const resolvedId = Array.isArray(resolved) ? resolved[0] : resolved;
const version = this.#extractVersion(resolvedId);
const node = { version };

if (!includeTransitive) {
return node;
}

const metadata = Array.isArray(resolved) ? (resolved[2] || {}) : {};
const subDeps = metadata.dependencies || {};

if (Object.keys(subDeps).length > 0) {
const visitKey = `${depName}@${version}`;
if (visited.has(visitKey)) {
return node;
}
visited.add(visitKey);

node.dependencies = {};
for (const subName of Object.keys(subDeps)) {
const subResolved = this.#resolvePackage(subName, depName, packages);
if (subResolved) {
node.dependencies[subName] = this.#buildNode(subName, subResolved, packages, true, visited);
}
/**
* Builds the dependency graph directly from `bun.lock`, requiring a
* pre-existing text lockfile and invoking no package manager. A binary
* `bun.lockb` without a text `bun.lock` is rejected with a migrate diagnostic.
* @param {boolean} _includeTransitive - Unused; the full graph is always read
* @param {Object} [opts={}] - Configuration options (may set the workspace dir)
* @returns {import('./javascript_lockfile.js').LockGraph} The dependency graph
* @protected
*/
_buildDependencyTree(_includeTransitive, opts = {}) {
const manifest = this._getManifest();
const manifestDir = path.dirname(manifest.manifestPath);
const lockDir = this._findLockFileDir(manifestDir, opts);
if (!lockDir) {
if (this._findLockFileDir(manifestDir, opts, [this._binaryLockFileName()])) {
throw new Error(
"Bun analysis found a binary 'bun.lockb' but no text 'bun.lock'. " +
"Migrate it with 'bun install --save-text-lockfile --frozen-lockfile " +
"--lockfile-only' (Bun >= 1.2) before scanning."
);
}
throw new Error('Bun analysis requires a lockfile. Generate bun.lock before scanning.');
}

return node;
}

#extractVersion(resolvedId) {
if (typeof resolvedId !== 'string') {
return '0.0.0';
}
const atIdx = resolvedId.lastIndexOf('@');
if (atIdx > 0) {
return resolvedId.substring(atIdx + 1);
}
return '0.0.0';
const lock = readBunLock(path.join(lockDir, this._lockFileName()));
const graph = readBunGraph(lockDir, manifest.manifestPath, manifest.content, lock);
this._loadHashes(lockDir);
return graph;
}
}
Loading
Loading