Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Prevent heap overflow in large monorepo scans by using streaming-based filtering to avoid accumulating all file paths in memory before filtering.
- `socket scan create` now rejects `--default-branch=<name>` and `--default-branch <name>` (space-separated) with an actionable error instead of silently dropping the branch name. Scans that used the misuse shape were getting recorded without a branch tag and disappearing from the Main/PR dashboard tabs.
- `socket repository create` / `socket repository update` now reject bare `--default-branch` (no value) and `--default-branch=` (empty value). Previously both persisted a blank default-branch name on the repo record.
- `socket cdxgen` no longer silently produces SBOMs with an empty `components` array when run in the default `--lifecycle pre-build` + `--no-install-deps` mode against a Node.js project that has no lockfile and no `node_modules/`. The command now fails fast with an actionable error (install dependencies or pass `--lifecycle build`), and when the generated BOM still ends up empty for any other reason (e.g. overly narrow `--filter`/`--only`), emits a post-run warning so the condition is surfaced instead of shipping an SBOM that renders as "no alerts" on the Socket dashboard.

## [2.1.0](https://github.com/SocketDev/socket-cli/releases/tag/v2.1.0) - 2025-11-02

Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { getDefaultLogger } from '@socketsecurity/lib/logger'
import { isPath } from '@socketsecurity/lib/paths/normalize'
import { pluralize } from '@socketsecurity/lib/words'

import { runCdxgen } from './run-cdxgen.mts'
import {
detectNodejsCdxgenSources,
isNodejsCdxgenType,
runCdxgen,
} from './run-cdxgen.mts'
import { FLAG_HELP } from '../../constants/cli.mjs'
import { outputDryRunExecute } from '../../utils/dry-run/output.mts'
import { commonFlags, outputFlags } from '../../flags.mts'
Expand Down Expand Up @@ -285,7 +289,8 @@ async function run(
// Make 'lifecycle' default to 'pre-build', which also sets 'install-deps' to `false`,
// to avoid arbitrary code execution on the cdxgen scan.
// https://github.com/CycloneDX/cdxgen/issues/1328
if (yargv.lifecycle === undefined) {
const lifecycleWasDefaulted = yargv.lifecycle === undefined
if (lifecycleWasDefaulted) {
yargv.lifecycle = 'pre-build'
yargv['install-deps'] = false
logger.info(
Expand All @@ -298,6 +303,29 @@ async function run(
if (yargv.output === undefined) {
yargv.output = 'socket-cdx.json'
}

// Hard gate: in the default pre-build + install-deps=false path, cdxgen
// needs either a lockfile or an installed node_modules/ to produce any
// Node.js components. Without both, it emits a valid CycloneDX doc with
// "components": []. Refuse with an actionable error instead of shipping
// an empty SBOM.
if (
lifecycleWasDefaulted &&
isNodejsCdxgenType(yargv.type) &&
!yargv['filter'] &&
!yargv['only']
) {
const { hasLockfile, hasNodeModules } = await detectNodejsCdxgenSources()
if (!hasLockfile && !hasNodeModules) {
process.exitCode = 2
logger.fail(
`socket cdxgen found no lockfile (pnpm-lock.yaml / package-lock.json / yarn.lock) or node_modules/ at or above ${process.cwd()}.\n` +
' The default --lifecycle pre-build with --no-install-deps needs one of them to resolve components; otherwise the SBOM ships with "components": [].\n' +
' 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.',
)
return
}
}
}

process.exitCode = 1
Expand Down
105 changes: 101 additions & 4 deletions packages/cli/src/commands/manifest/run-cdxgen.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync } from 'node:fs'
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'

import colors from 'yoctocolors-cjs'
Expand All @@ -8,6 +8,7 @@ import { safeDeleteSync } from '@socketsecurity/lib/fs'
import { getDefaultLogger } from '@socketsecurity/lib/logger'

import { FLAG_HELP } from '../../constants/cli.mjs'
import { NODE_MODULES } from '../../constants/packages.mts'
import {
PACKAGE_LOCK_JSON,
PNPM_LOCK_YAML,
Expand All @@ -32,6 +33,55 @@ const nodejsPlatformTypes = new Set([
'typescript',
])

/**
* Result of probing a cwd for Node.js SBOM inputs that cdxgen needs in the
* default `pre-build` + `install-deps: false` mode.
*/
export type NodejsCdxgenSources = {
hasLockfile: boolean
hasNodeModules: boolean
}

/**
* Probe upward from cwd for a recognized lockfile and for a co-located
* `node_modules/` directory. cdxgen's `pre-build` lifecycle needs at least one
* of these to produce a non-empty `components` array for a Node.js project.
*/
export async function detectNodejsCdxgenSources(
cwd: string = process.cwd(),
): Promise<NodejsCdxgenSources> {
const [pnpmLockPath, npmLockPath, yarnLockPath, nodeModulesPath] =
await Promise.all([
findUp(PNPM_LOCK_YAML, { cwd, onlyFiles: true }),
findUp(PACKAGE_LOCK_JSON, { cwd, onlyFiles: true }),
findUp(YARN_LOCK, { cwd, onlyFiles: true }),
findUp(NODE_MODULES, { cwd, onlyDirectories: true }),
])
return {
hasLockfile: Boolean(pnpmLockPath || npmLockPath || yarnLockPath),
hasNodeModules: Boolean(nodeModulesPath),
}
}

/**
* True when the argv `type` resolves to a Node.js platform (the cdxgen default
* when the user does not pass `--type`).
*/
export function isNodejsCdxgenType(argvType: unknown): boolean {
if (argvType === undefined || argvType === null) {
return true
}
if (typeof argvType === 'string') {
return nodejsPlatformTypes.has(argvType)
}
if (Array.isArray(argvType)) {
return argvType.some(
t => typeof t === 'string' && nodejsPlatformTypes.has(t),
)
}
return false
}

export type ArgvObject = {
[key: string]: boolean | null | number | string | Array<string | number>
}
Expand Down Expand Up @@ -121,8 +171,11 @@ export async function runCdxgen(argvObj: ArgvObject): Promise<DlxSpawnResult> {
agent,
})

// Use finally handler for cleanup instead of process.on('exit').
cdxgenResult.spawnPromise.finally(() => {
// Post-run cleanup + empty-BOM warning. We replace spawnPromise with a
// chained promise so the caller's `await spawnPromise` also awaits this
// work — otherwise the caller's continuation (e.g. `process.exit`) races
// the first `await` inside the finally body and the warning never prints.
const chainedSpawnPromise = cdxgenResult.spawnPromise.finally(async () => {
if (cleanupPackageLock) {
try {
// This removes the temporary package-lock.json we created for cdxgen.
Expand Down Expand Up @@ -150,9 +203,53 @@ export async function runCdxgen(argvObj: ArgvObject): Promise<DlxSpawnResult> {
}
if (existsSync(fullOutputPath)) {
logger.log(colors.cyanBright(`${outputPath} created!`))
await warnIfEmptyComponents(fullOutputPath, argvMutable)
Comment thread
jdalton marked this conversation as resolved.
}
}
})

return cdxgenResult
// Cast back to SpawnResult: .finally() returns plain Promise<T> which
// drops the `process` / `stdin` extras SpawnResult carries. Callers of
// runCdxgen only use `await spawnPromise` for the result, not those
// extras, so the shape loss is safe.
return {
...cdxgenResult,
spawnPromise: chainedSpawnPromise as typeof cdxgenResult.spawnPromise,
}
}

/**
* Read a generated CycloneDX BOM and warn when its `components` array is
* empty. An empty components array parses as valid CycloneDX but carries no
* dependency data, so the Socket dashboard cannot surface alerts for it. This
* catches configurations we did not hard-gate (non-default lifecycle, custom
* `--filter`/`--only` wiping all components, ecosystem mismatch, etc.).
*/
async function warnIfEmptyComponents(
outputPath: string,
argvMutable: ArgvObject,
): Promise<void> {
let raw: string
try {
raw = await fs.readFile(outputPath, 'utf8')
} catch {
return
}
let bom: { components?: unknown } | undefined
try {
bom = JSON.parse(raw)
} catch {
return
}
if (!bom || !Array.isArray(bom.components) || bom.components.length > 0) {
return
}
const lifecycle = argvMutable['lifecycle']
const lifecycleHint =
lifecycle === 'pre-build' || lifecycle === undefined
? ' Pass --lifecycle build to resolve components during the build, or run a package install first so node_modules/ exists.\n'
: ' Re-check --type, --filter, and --only — a filter may be excluding every component.\n'
logger.warn(
`${outputPath} has an empty "components" array — the generated SBOM contains no dependencies and the Socket dashboard will show no alerts for it.\n${lifecycleHint}`,
)
}
105 changes: 105 additions & 0 deletions packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ vi.mock('@socketsecurity/lib/logger', () => ({

// Mock runCdxgen to prevent actual cdxgen execution.
const mockRunCdxgen = vi.hoisted(() => vi.fn())
const mockDetectNodejsCdxgenSources = vi.hoisted(() =>
// Default to "sources available" so pre-existing tests don't trip the
// empty-components hard gate.
vi.fn().mockResolvedValue({ hasLockfile: true, hasNodeModules: true }),
)
const mockIsNodejsCdxgenType = vi.hoisted(() => vi.fn().mockReturnValue(true))

vi.mock('../../../../src/commands/manifest/run-cdxgen.mts', () => ({
detectNodejsCdxgenSources: mockDetectNodejsCdxgenSources,
isNodejsCdxgenType: mockIsNodejsCdxgenType,
runCdxgen: mockRunCdxgen,
}))

Expand All @@ -49,6 +57,11 @@ const { cmdManifestCdxgen } =
describe('cmd-manifest-cdxgen', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDetectNodejsCdxgenSources.mockResolvedValue({
hasLockfile: true,
hasNodeModules: true,
})
mockIsNodejsCdxgenType.mockReturnValue(true)
process.exitCode = undefined
})

Expand Down Expand Up @@ -311,6 +324,98 @@ describe('cmd-manifest-cdxgen', () => {
})
})

describe('empty-components hard gate', () => {
it('fails when default pre-build path has no lockfile and no node_modules', async () => {
mockDetectNodejsCdxgenSources.mockResolvedValue({
hasLockfile: false,
hasNodeModules: false,
})

await cmdManifestCdxgen.run(['.'], importMeta, context)

expect(process.exitCode).toBe(2)
expect(mockLogger.fail).toHaveBeenCalledWith(
expect.stringContaining('no lockfile'),
)
expect(mockRunCdxgen).not.toHaveBeenCalled()
})

it('allows the run when only a lockfile is present', async () => {
mockDetectNodejsCdxgenSources.mockResolvedValue({
hasLockfile: true,
hasNodeModules: false,
})
const mockSpawnPromise = Promise.resolve({ code: 0, signal: null })
mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise })
const mockExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as any)

await cmdManifestCdxgen.run(['.'], importMeta, context)

expect(mockRunCdxgen).toHaveBeenCalled()
mockExit.mockRestore()
})

it('allows the run when only node_modules/ is present', async () => {
mockDetectNodejsCdxgenSources.mockResolvedValue({
hasLockfile: false,
hasNodeModules: true,
})
const mockSpawnPromise = Promise.resolve({ code: 0, signal: null })
mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise })
const mockExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as any)

await cmdManifestCdxgen.run(['.'], importMeta, context)

expect(mockRunCdxgen).toHaveBeenCalled()
mockExit.mockRestore()
})

it('skips the gate when user passes --lifecycle explicitly', async () => {
mockDetectNodejsCdxgenSources.mockResolvedValue({
hasLockfile: false,
hasNodeModules: false,
})
const mockSpawnPromise = Promise.resolve({ code: 0, signal: null })
mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise })
const mockExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as any)

await cmdManifestCdxgen.run(
['--lifecycle', 'build', '.'],
importMeta,
context,
)

expect(mockDetectNodejsCdxgenSources).not.toHaveBeenCalled()
expect(mockRunCdxgen).toHaveBeenCalled()
mockExit.mockRestore()
})

it('skips the gate for non-Node.js project types', async () => {
mockIsNodejsCdxgenType.mockReturnValue(false)
const mockSpawnPromise = Promise.resolve({ code: 0, signal: null })
mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise })
const mockExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as any)

await cmdManifestCdxgen.run(
['--type', 'python', '.'],
importMeta,
context,
)

expect(mockDetectNodejsCdxgenSources).not.toHaveBeenCalled()
expect(mockRunCdxgen).toHaveBeenCalled()
mockExit.mockRestore()
})
})

describe('help flag handling', () => {
it('should pass --help flag to cdxgen', async () => {
const mockSpawnPromise = Promise.resolve({ code: 0, signal: null })
Expand Down
Loading