Skip to content

fix(cli): stop socket cdxgen from silently shipping empty-components SBOMs#1266

Open
John-David Dalton (jdalton) wants to merge 2 commits intomainfrom
fix/cdxgen-empty-components
Open

fix(cli): stop socket cdxgen from silently shipping empty-components SBOMs#1266
John-David Dalton (jdalton) wants to merge 2 commits intomainfrom
fix/cdxgen-empty-components

Conversation

@jdalton
Copy link
Copy Markdown
Contributor

@jdalton John-David Dalton (jdalton) commented Apr 23, 2026

What this fixes

socket cdxgen can silently produce a CycloneDX SBOM with zero components — a structurally valid document that the Socket dashboard ingests cleanly but has no dependency data to render. The Alerts tab then shows "no alerts," which is indistinguishable from a genuinely clean repo.

Here's how it happens:

When you run socket cdxgen without passing --lifecycle, we default it to --lifecycle pre-build and --no-install-deps. That's intentional — it's a safety setting so that generating an SBOM doesn't run arbitrary postinstall scripts from the user's dependencies.

But for a Node.js project, that mode needs one of two things to find any dependencies:

  1. A lockfile (pnpm-lock.yaml, package-lock.json, or yarn.lock), OR
  2. An already-installed node_modules/ folder.

If the user has neither, cdxgen happily writes out a CycloneDX file with zero components. No error. Our CLI then prints socket-cdx.json created! and exits cleanly. The user uploads it, and the dashboard has nothing to show. Nothing in the CLI output tells them anything is wrong.

What I changed

Two new checks in socket cdxgen:

1. Hard gate (stops the run before it generates a bad SBOM)

In cmd-manifest-cdxgen.mts: right where we apply the default lifecycle, I added a check. If ALL of these are true:

  • The user didn't pass --lifecycle (so we're using the default),
  • The project type is Node.js (the cdxgen default),
  • The user didn't pass --filter or --only (those can intentionally produce empty SBOMs),
  • AND there's no lockfile OR node_modules/ anywhere from cwd upward,

…then we exit with code 2 and a clear error message telling the user exactly how to fix it:

socket cdxgen found no lockfile (pnpm-lock.yaml / package-lock.json / yarn.lock)
or node_modules/ at or above <cwd>.
  The default --lifecycle pre-build with --no-install-deps needs one of them
  to resolve components; otherwise the SBOM ships with "components": [].
  Fix: install dependencies first (e.g. `npm install`, `pnpm install`,
  `yarn install`), or re-run with `--lifecycle build` to let cdxgen resolve
  during the build.

We walk up from cwd (not just check cwd) because a lot of users run this in packages/foo inside a monorepo, where the lockfile is at the repo root.

2. Soft gate (catches anything the hard gate missed)

In run-cdxgen.mts: after cdxgen finishes and writes its output file, we read the JSON back, check if components is empty, and if so print a warning. This catches edge cases the hard gate doesn't cover:

  • User passed --lifecycle build but something went wrong and it still produced zero components.
  • Somebody used --filter or --only too aggressively and every component got filtered out.
  • A future cdxgen version changes behavior in a way we didn't predict.

The warning tells them what's wrong and suggests the same fix. Better than them finding out from a blank dashboard.

How I made the code testable

run-cdxgen.mts already had a function that ran cdxgen as a subprocess. I pulled out two small helper functions:

  • detectNodejsCdxgenSources(cwd) — returns { hasLockfile, hasNodeModules } by walking up from cwd
  • isNodejsCdxgenType(argvType) — returns true if the --type flag is a Node.js platform

Both are exported so the test file can import them. detectNodejsCdxgenSources takes an optional cwd argument so tests can point it at a specific directory without calling process.chdir(), which vitest's worker threads don't allow.

Tests

Two test files in packages/cli/test/unit/commands/manifest/:

cmd-manifest-cdxgen.test.mts (existing file, added a new describe block):

  • Fails when there's no lockfile AND no node_modules/
  • Passes when only a lockfile is present
  • Passes when only node_modules/ is present
  • Skips the gate when the user passes --lifecycle explicitly
  • Skips the gate for non-Node.js project types (python, java, etc.)

I also had to update the existing beforeEach and some existing tests to mock the new helpers so they don't actually hit the filesystem — the mock returns "sources present" by default so the pre-existing tests behave the same as before.

run-cdxgen.test.mts (new file):

  • isNodejsCdxgenType across the Node.js types we care about (js, javascript, typescript, nodejs, npm, pnpm, ts)
  • isNodejsCdxgenType rejects non-Node types (python, java, go, rust)
  • isNodejsCdxgenType on arrays (if any entry is Node, it matches)
  • detectNodejsCdxgenSources with various combinations of lockfiles/node_modules present/absent (via mocked findUp)

Test plan

  • pnpm --filter @socketsecurity/cli run type — clean
  • pnpm --filter @socketsecurity/cli run lint — clean
  • pnpm --filter @socketsecurity/cli run test:unit test/unit/commands/manifest/cmd-manifest-cdxgen.test.mts — 38/38 pass (5 new tests for the hard gate)
  • pnpm --filter @socketsecurity/cli run test:unit test/unit/commands/manifest/run-cdxgen.test.mts — 18/18 pass (new test file)
  • pnpm --filter @socketsecurity/cli run test:unit test/unit/commands/manifest/ — 211/211 pass across the whole manifest folder, no regressions
  • Manual verification in an empty dir: socket cdxgen exits 2 with the expected message; with a lockfile, runs normally; with --lifecycle build, bypasses the gate.

What I added to CHANGELOG.md

One entry under [Unreleased] > Fixed, explaining the behavior change in user-facing terms.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Async finally callback races with process.exit, warning never displays
    • Reassigned the .finally() chain back to cdxgenResult.spawnPromise so the caller awaits the full chain including the async warnIfEmptyComponents call, preventing process.exit() from terminating before the warning is displayed.

Create PR

Or push these changes by commenting:

@cursor push 61777b7392
Preview (61777b7392)
diff --git a/packages/cli/src/commands/manifest/run-cdxgen.mts b/packages/cli/src/commands/manifest/run-cdxgen.mts
--- a/packages/cli/src/commands/manifest/run-cdxgen.mts
+++ b/packages/cli/src/commands/manifest/run-cdxgen.mts
@@ -172,7 +172,8 @@
   })
 
   // Use finally handler for cleanup instead of process.on('exit').
-  cdxgenResult.spawnPromise.finally(async () => {
+  // Reassign so the caller awaits the full chain, including the async warning.
+  cdxgenResult.spawnPromise = cdxgenResult.spawnPromise.finally(async () => {
     if (cleanupPackageLock) {
       try {
         // This removes the temporary package-lock.json we created for cdxgen.
@@ -203,7 +204,7 @@
         await warnIfEmptyComponents(fullOutputPath, argvMutable)
       }
     }
-  })
+  }) as typeof cdxgenResult.spawnPromise
 
   return cdxgenResult
 }

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fb64b27. Configure here.

Comment thread packages/cli/src/commands/manifest/run-cdxgen.mts
…SBOMs

When `socket cdxgen` runs with its safe defaults (`--lifecycle pre-build` +
`--no-install-deps`) against a Node.js project that has neither a lockfile
nor an installed `node_modules/`, cdxgen produces a structurally-valid
CycloneDX document with `"components": []`. The Socket dashboard ingests
the SBOM cleanly but has no dependency data to render, so the Alerts tab
shows nothing — indistinguishable from a genuinely clean repo.

Add two gates to the command:

* Hard gate in cmd-manifest-cdxgen.mts: when the default lifecycle path
  kicks in for a Node.js type and neither a lockfile nor node_modules/
  is findable upward from cwd, refuse with exit code 2 and an actionable
  message (install first or pass --lifecycle build). Skips the gate when
  the user passes --lifecycle, --filter, --only, or a non-Node --type.

* Soft gate in run-cdxgen.mts: after cdxgen writes its output, parse it
  and warn loudly when `components` is empty. Covers configurations that
  slip past the hard gate (overly narrow --filter/--only, ecosystem
  mismatch, etc.) so an empty SBOM cannot ship silently.

The detection helpers (`detectNodejsCdxgenSources`, `isNodejsCdxgenType`)
are exported for unit testing and accept a `cwd` override so the probe
can be exercised without relying on process.chdir (not supported in
vitest workers).
The soft gate attached an `async .finally()` handler to `spawnPromise`
but `runCdxgen` returned the original `cdxgenResult` — not the chained
promise. The caller in `cmd-manifest-cdxgen.mts` awaited the original
`spawnPromise` and then called `process.exit(result.code)`, so when the
async finally yielded at `await warnIfEmptyComponents(...)` the caller's
continuation fired first and killed the process before the warning
printed.

Rebind `spawnPromise` on the returned result to the chained promise so
`await spawnPromise` in the caller naturally awaits the cleanup + warning.
.finally() preserves the original value and rejection, so semantics for
existing callers are unchanged.

TypeScript note: .finally() returns plain Promise<T> which strips the
`process` / `stdin` extras that SpawnResult carries. Callers only use
the promise shape, so cast back to SpawnResult.
@jdalton

This comment was marked as resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant