From 7aedc83684aa53ef6b19c759cd434918845c794a Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 28 Sep 2026 11:28:42 +0200 Subject: [PATCH 1/7] feat(scan): scan selected uv packages with dependency graphs --- CHANGELOG.md | 6 + README.md | 38 +++ src/commands/scan/cmd-scan-create.mts | 46 +++- src/commands/scan/cmd-scan-create.test.mts | 83 ++++++- .../generate-uv-package-sboms.e2e.test.mts | 220 ++++++++++++++++++ .../scan/generate-uv-package-sboms.mts | 134 +++++++++++ .../scan/generate-uv-package-sboms.test.mts | 144 ++++++++++++ src/commands/scan/handle-create-new-scan.mts | 61 ++++- .../scan/handle-create-new-scan.test.mts | 156 +++++++++++++ .../scan/perform-reachability-analysis.mts | 5 +- .../perform-reachability-analysis.test.mts | 23 ++ .../uv-workspace/packages/api/pyproject.toml | 11 + .../packages/other/pyproject.toml | 5 + .../packages/shared/pyproject.toml | 5 + .../commands/scan/uv-workspace/pyproject.toml | 9 + .../commands/scan/uv-workspace/uv.lock | 161 +++++++++++++ 16 files changed, 1090 insertions(+), 17 deletions(-) create mode 100644 src/commands/scan/generate-uv-package-sboms.e2e.test.mts create mode 100644 src/commands/scan/generate-uv-package-sboms.mts create mode 100644 src/commands/scan/generate-uv-package-sboms.test.mts create mode 100644 test/fixtures/commands/scan/uv-workspace/packages/api/pyproject.toml create mode 100644 test/fixtures/commands/scan/uv-workspace/packages/other/pyproject.toml create mode 100644 test/fixtures/commands/scan/uv-workspace/packages/shared/pyproject.toml create mode 100644 test/fixtures/commands/scan/uv-workspace/pyproject.toml create mode 100644 test/fixtures/commands/scan/uv-workspace/uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index e454361d1d..dddbc43796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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 --uv-package ` scans selected uv packages with locked versions and dependency relationships from their shared `uv.lock`. Repeat the option to select more packages. Requires uv with CycloneDX export support. + ## [1.2.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.0) - 2026-09-27 ### Added diff --git a/README.md b/README.md index 5b3ce65c05..f1f7aaf075 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,44 @@ All aliases support the flags and arguments of the commands they alias. - `socket ci` - Alias for `socket scan create --report` (creates report and exits with error if unhealthy) +### Scanning selected uv packages + +Use `--uv-package` to scan specific packages from a shared `uv.lock`, with +locked versions and dependency relationships preserved: + +```sh +socket scan create . --uv-package api --uv-package worker +``` + +Each value is a package's `project.name` from `pyproject.toml`, rather than a +directory path. Repeat the option to select more packages. TARGET must be one +project root containing both `pyproject.toml` and `uv.lock`. Use `--cwd` to run +from another directory: + +```sh +socket scan create --cwd ./python-workspace . --uv-package api +``` + +This mode requires uv on PATH with support for `uv export --format cyclonedx1.5`. +It exports a separate CycloneDX SBOM for each package, including transitive +and local workspace dependencies, all extras, and all dependency groups. +The export runs offline with `--frozen`, so it uses the existing lockfile +without resolving newer versions, installing packages, or changing the project. +uv currently treats CycloneDX export as a preview feature. + +Only the generated SBOMs are uploaded. The target supplies workspace context, +and package selection replaces regular manifest discovery and its file ignore +patterns. Unrelated manifests and the shared lockfile are not added to the scan. +Temporary SBOMs are removed after the scan, including on failure. Export errors +stop the scan. `--read-only` prepares the SBOMs without uploading them, and +`--dry-run` validates the options without running uv. + +You can combine this option with `--reach`. The same scoped SBOMs are used for +its manifest upload, while source analysis runs against TARGET and respects +reachability exclusions. `--uv-package` cannot be combined with +`--auto-manifest` or `--dynamic-sbom-inference`. Scans without `--uv-package` +keep their usual manifest discovery behavior. + ### Reachability analysis Socket reachability analysis comes in three forms: diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index b733ebbec4..a07eedbb28 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -4,6 +4,10 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' import { assertValidExcludePaths } from './exclude-paths.mts' +import { + normalizeUvPackageNames, + resolveUvProjectRoot, +} from './generate-uv-package-sboms.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import { @@ -24,6 +28,7 @@ import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { determineOrgSlug } from '../../utils/determine-org-slug.mts' import { parseReachEcosystems } from '../../utils/ecosystem.mts' +import { InputError } from '../../utils/errors.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { detectDefaultBranch, @@ -172,6 +177,12 @@ const generalFlags: MeowFlags = { 'Set the visibility (true/false) of the scan in your dashboard.', shortFlag: 't', }, + uvPackage: { + type: 'string', + isMultiple: true, + description: + 'Scan only the named uv packages from one project root using CycloneDX dependency graphs from its uv.lock. Use project.name from pyproject.toml. Repeat to select more packages. Requires uv on PATH. Includes all extras and dependency groups.', + }, } export const cmdScanCreate = { @@ -241,6 +252,7 @@ async function run( $ ${command} $ ${command} ./proj --json $ ${command} --repo=test-repo --branch=main ./package.json + $ ${command} . --uv-package api --uv-package worker `, } @@ -332,6 +344,23 @@ async function run( ) const dryRun = !!cli.flags['dryRun'] + const uvPackageValues = (cli.flags['uvPackage'] ?? []) as string[] + // Meow drops empty values from repeated string flags. + let uvPackageFlagCount = 0 + for (const arg of argv) { + if (arg === '--') { + break + } + if (/^--uv(?:-package|Package)(?:=|$)/.test(arg)) { + uvPackageFlagCount++ + } + } + if (uvPackageFlagCount > uvPackageValues.length) { + throw new InputError( + '--uv-package requires a package name after every occurrence.', + ) + } + const uvPackages = normalizeUvPackageNames(uvPackageValues) let { autoManifest, @@ -409,7 +438,7 @@ async function run( // Accept zero or more paths. Default to cwd() if none given. let targets = cli.input.length ? cli.input : [] - if (!targets.length && !dryRun && interactive) { + if (!targets.length && !dryRun && interactive && !uvPackages.length) { targets = await suggestTarget() updatedInput = true } @@ -419,7 +448,7 @@ async function run( // because wrapPrompt swallows non-TypeError errors and returns undefined), // default to '.' so that downstream validations don't fail with confusing // "At least one TARGET (missing)" errors. - if (!targets.length && !dryRun) { + if (!targets.length && (!dryRun || uvPackages.length)) { targets = ['.'] } @@ -472,6 +501,7 @@ async function run( detected.count > 0 && !autoManifest && !dynamicSbomInference && + !uvPackages.length && !hasFactsFile ) { logger.info( @@ -567,6 +597,13 @@ async function run( message: 'At least one TARGET (e.g. `.` or `./package.json`)', fail: 'missing', }, + { + nook: true, + test: !uvPackages.length || (!autoManifest && !dynamicSbomInference), + message: + '--uv-package cannot be combined with --auto-manifest or --dynamic-sbom-inference', + fail: 'select one source of generated SBOMs', + }, { nook: true, test: !json || !markdown, @@ -629,6 +666,10 @@ async function run( return } + if (uvPackages.length) { + resolveUvProjectRoot(targets, cwd) + } + if (dryRun) { logger.log(constants.DRY_RUN_BAILING_NOW) return @@ -677,6 +718,7 @@ async function run( reportLevel, targets, tmp: Boolean(tmp), + uvPackages, workspace: (workspace && String(workspace)) || '', }) } diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 2cb91ba196..bc82eaa0b4 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -1,6 +1,6 @@ import path from 'node:path' -import { describe, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import constants, { FLAG_CONFIG, @@ -17,6 +17,83 @@ const fixtureBaseDir = path.join(testPath, 'fixtures/commands/scan/create') describe('socket scan create', async () => { const { binCliPath } = constants + const uvFixture = path.join(testPath, 'fixtures/commands/scan/uv-workspace') + const uvBaseArgs = [ + 'scan', + 'create', + '--cwd', + uvFixture, + '--org', + 'test-org', + '--repo', + 'test-repo', + '--branch', + 'main', + '--dry-run', + '--no-interactive', + FLAG_CONFIG, + '{}', + ] + + it('accepts repeated uv package selectors with an implicit root target', async () => { + const result = await spawnSocketCli(binCliPath, [ + ...uvBaseArgs, + '--uv-package', + 'workspace-api', + '--uv-package', + 'workspace-other', + ]) + expect(result.code).toBe(0) + expect(result.stdout).toContain('[DryRun]: Bailing now') + }) + + it.each([ + { + args: ['.', '--uv-package', './packages/api'], + error: 'expects a project.name', + }, + { + args: ['.', '--uv-package='], + error: 'requires a package name after every occurrence', + }, + { + args: ['.', '--uv-package'], + error: 'requires a package name after every occurrence', + }, + { + args: ['.', '--uvPackage'], + error: 'requires a package name after every occurrence', + }, + { + args: ['.', '--uv-package', 'workspace-api', '--uv-package='], + error: 'requires a package name after every occurrence', + }, + { + args: ['.', '--uv-package', '--uv-package', 'workspace-api'], + error: 'requires a package name after every occurrence', + }, + { + args: ['.', '.', '--uv-package', 'workspace-api'], + error: 'requires exactly one uv project root', + }, + { + args: ['packages/api', '--uv-package', 'workspace-api'], + error: 'requires pyproject.toml and uv.lock', + }, + { + args: ['.', '--uv-package', 'workspace-api', '--auto-manifest'], + error: 'cannot be combined', + }, + { + args: ['.', '--uv-package', 'workspace-api', '--dynamic-sbom-inference'], + error: 'cannot be combined', + }, + ])('rejects invalid uv package options: $args', async ({ args, error }) => { + const result = await spawnSocketCli(binCliPath, [...uvBaseArgs, ...args]) + expect(result.code).not.toBe(0) + expect(result.stdout + result.stderr).toContain(error) + }) + cmdit( ['scan', 'create', FLAG_HELP, FLAG_CONFIG, '{}'], `should support ${FLAG_HELP}`, @@ -54,6 +131,7 @@ describe('socket scan create', async () => { --report-level Which policy level alerts should be reported (default 'error') --set-as-alerts-page When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true. --tmp Set the visibility (true/false) of the scan in your dashboard. + --uv-package Scan only the named uv packages from one project root using CycloneDX dependency graphs from its uv.lock. Use project.name from pyproject.toml. Repeat to select more packages. Requires uv on PATH. Includes all extras and dependency groups. --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) @@ -109,7 +187,8 @@ describe('socket scan create', async () => { Examples $ socket scan create $ socket scan create ./proj --json - $ socket scan create --repo=test-repo --branch=main ./package.json" + $ socket scan create --repo=test-repo --branch=main ./package.json + $ socket scan create . --uv-package api --uv-package worker" `) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " diff --git a/src/commands/scan/generate-uv-package-sboms.e2e.test.mts b/src/commands/scan/generate-uv-package-sboms.e2e.test.mts new file mode 100644 index 0000000000..b035d28d1e --- /dev/null +++ b/src/commands/scan/generate-uv-package-sboms.e2e.test.mts @@ -0,0 +1,220 @@ +import { existsSync, promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { generateUvPackageSboms } from './generate-uv-package-sboms.mts' +import { handleCreateNewScan } from './handle-create-new-scan.mts' + +import type { HandleCreateNewScanConfig } from './handle-create-new-scan.mts' + +const { mockCreateFullScan } = vi.hoisted(() => ({ + mockCreateFullScan: vi.fn(), +})) + +vi.mock('./fetch-supported-scan-file-names.mts', () => ({ + fetchSupportedScanFileNames: async () => ({ + ok: true, + data: { cdx: { json: { pattern: '*cdx.json' } } }, + }), +})) + +vi.mock('./output-create-new-scan.mts', () => ({ + outputCreateNewScan: vi.fn(), +})) + +vi.mock('../../utils/sdk.mts', () => ({ + setupSdk: async () => ({ + ok: true, + data: { createFullScan: mockCreateFullScan }, + }), +})) + +const fixture = fileURLToPath( + new URL('../../../test/fixtures/commands/scan/uv-workspace', import.meta.url), +) + +type Component = { + 'bom-ref': string + name: string + version: string + properties?: Array<{ name: string; value: string }> +} + +type Sbom = { + bomFormat: string + metadata: { component: Component } + components: Component[] + dependencies: Array<{ ref: string; dependsOn: string[] }> +} + +function assertApiGraph(sbom: Sbom): void { + expect(sbom.bomFormat).toBe('CycloneDX') + expect(sbom.metadata.component.name).toBe('workspace-api') + const components = [sbom.metadata.component, ...sbom.components] + const nameToRef = new Map(components.map(c => [c.name, c['bom-ref']])) + const edges = new Map(sbom.dependencies.map(d => [d.ref, d.dependsOn])) + expect(edges.get(nameToRef.get('workspace-api')!)).toContain( + nameToRef.get('workspace-shared'), + ) + expect(edges.get(nameToRef.get('workspace-shared')!)).toContain( + nameToRef.get('typing-extensions'), + ) + expect(edges.get(nameToRef.get('workspace-api')!)).not.toContain( + nameToRef.get('typing-extensions'), + ) + expect(sbom.components.map(c => `${c.name}@${c.version}`).sort()).toEqual([ + 'colorama@0.4.6', + 'idna@3.10', + 'iniconfig@2.1.0', + 'typing-extensions@4.12.2', + 'tzdata@2025.1', + 'workspace-shared@0.1.0', + ]) + expect( + sbom.components.find(c => c.name === 'tzdata')?.properties, + ).toContainEqual({ + name: 'uv:package:marker', + value: "sys_platform == 'win32'", + }) + const refs = new Set(components.map(c => c['bom-ref'])) + for (const edge of sbom.dependencies) { + expect(refs.has(edge.ref)).toBe(true) + expect(edge.dependsOn.every(ref => refs.has(ref))).toBe(true) + } +} + +describe('uv package scans with the real uv binary', () => { + let projectRoot: string + let outputDir: string + + beforeEach(async () => { + vi.clearAllMocks() + projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-project-')) + outputDir = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-sboms-')) + await fs.cp(fixture, projectRoot, { recursive: true }) + }) + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }) + await fs.rm(outputDir, { recursive: true, force: true }) + }) + + it('preserves pinned dependency edges, extras, groups and markers without unrelated packages', async () => { + const lock = await fs.readFile(path.join(projectRoot, 'uv.lock'), 'utf8') + const paths = await generateUvPackageSboms({ + outputDir, + packageNames: ['workspace-api'], + projectRoot, + }) + assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) + expect(await fs.readFile(path.join(projectRoot, 'uv.lock'), 'utf8')).toBe( + lock, + ) + expect(existsSync(path.join(projectRoot, '.venv'))).toBe(false) + }) + + it('exports distinct package roots and graphs when multiple packages are requested', async () => { + const paths = await generateUvPackageSboms({ + outputDir, + packageNames: ['workspace-api', 'workspace-other'], + projectRoot, + }) + expect(paths).toHaveLength(2) + assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) + const other = JSON.parse(await fs.readFile(paths[1]!, 'utf8')) as Sbom + expect(other.metadata.component.name).toBe('workspace-other') + expect(other.components.map(c => c.name)).toEqual(['sniffio']) + }) + + it('uses the existing pins when the member allows a newer version', async () => { + const manifest = path.join(projectRoot, 'packages/api/pyproject.toml') + await fs.writeFile( + manifest, + (await fs.readFile(manifest, 'utf8')).replace('idna==3.10', 'idna>=3'), + ) + const paths = await generateUvPackageSboms({ + outputDir, + packageNames: ['workspace-api'], + projectRoot, + }) + assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) + }) + + it('rejects unknown packages and does not fall back to the workspace root', async () => { + await expect( + generateUvPackageSboms({ + outputDir, + packageNames: ['does-not-exist'], + projectRoot, + }), + ).rejects.toThrow('Could not export uv package "does-not-exist"') + }) + + it('passes only the scoped graph to the SDK and cleans up after upload', async () => { + let uploadRoot = '' + mockCreateFullScan.mockImplementationOnce( + async (_org, paths: string[], options) => { + uploadRoot = options.pathsRelativeTo + expect(paths).toEqual([ + path.join(uploadRoot, 'socket-workspace-api-cdx.json'), + ]) + assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) + expect(existsSync(path.join(uploadRoot, 'uv.lock'))).toBe(false) + expect(existsSync(path.join(uploadRoot, 'pyproject.toml'))).toBe(false) + return { success: true, status: 200, data: { id: 'test-scan' } } + }, + ) + const config: HandleCreateNewScanConfig = { + autoManifest: false, + branchName: 'main', + commitHash: '', + commitMessage: '', + committers: '', + cwd: projectRoot, + defaultBranch: false, + interactive: false, + orgSlug: 'test-org', + outputKind: 'text', + pendingHead: false, + pullRequest: 0, + reach: { + dynamicSbomInference: false, + excludePaths: [], + reachAnalysisMemoryLimit: '8192', + reachAnalysisTimeout: '', + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: [], + reachFallbackToRegularScan: false, + reachRetainFactsFile: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + runReachabilityAnalysis: false, + }, + readOnly: false, + repoName: 'test-repo', + report: false, + reportLevel: 'error', + targets: ['.'], + tmp: true, + uvPackages: ['workspace-api'], + } + await handleCreateNewScan(config) + expect(mockCreateFullScan).toHaveBeenCalledOnce() + expect(uploadRoot).not.toBe('') + expect(existsSync(uploadRoot)).toBe(false) + }) +}) diff --git a/src/commands/scan/generate-uv-package-sboms.mts b/src/commands/scan/generate-uv-package-sboms.mts new file mode 100644 index 0000000000..e2039d20ea --- /dev/null +++ b/src/commands/scan/generate-uv-package-sboms.mts @@ -0,0 +1,134 @@ +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' + +import { isDirSync } from '@socketsecurity/registry/lib/fs' +import { logger } from '@socketsecurity/registry/lib/logger' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' +import { InputError, getErrorMessage } from '../../utils/errors.mts' + +function normalizePackageName(name: string): string { + return name.toLowerCase().replaceAll(/[._-]+/g, '-') +} + +function validateSbom(content: string, packageName: string): void { + try { + const sbom = JSON.parse(content) as { + bomFormat?: string + dependencies?: unknown[] + metadata?: { component?: { name?: string } } + } + const rootName = sbom?.metadata?.component?.name + if ( + sbom?.bomFormat === 'CycloneDX' && + Array.isArray(sbom.dependencies) && + typeof rootName === 'string' && + normalizePackageName(rootName) === packageName + ) { + return + } + } catch {} + throw new InputError( + `uv did not return a CycloneDX dependency graph rooted at "${packageName}". Update uv and try again.`, + ) +} + +export async function generateUvPackageSboms({ + outputDir, + packageNames, + projectRoot, +}: { + outputDir: string + packageNames: string[] + projectRoot: string +}): Promise { + const paths: string[] = [] + for (const packageName of normalizeUvPackageNames(packageNames)) { + logger.info(`Exporting the uv dependency graph for ${packageName}...`) + let content: string + try { + // Export each package separately so each SBOM has its own project root. + // eslint-disable-next-line no-await-in-loop + const { stdout } = await spawn( + 'uv', + [ + 'export', + '--project', + projectRoot, + '--package', + packageName, + '--format', + 'cyclonedx1.5', + '--frozen', + '--offline', + '--no-python-downloads', + '--all-extras', + '--all-groups', + ], + { + cwd: projectRoot, + signal: constants.abortSignal, + stdio: 'pipe', + }, + ) + content = stdout + } catch (e) { + throw new InputError( + `Could not export uv package "${packageName}" from ${projectRoot}. Install uv on PATH with CycloneDX export support and check that this package is in the shared uv.lock.`, + e && typeof e === 'object' && 'stderr' in e + ? String(e.stderr).trim() || getErrorMessage(e) + : getErrorMessage(e), + ) + } + validateSbom(content, packageName) + // Keep workspace-relative paths in the SBOM relative to the upload root. + const filename = path.join(outputDir, `socket-${packageName}-cdx.json`) + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(outputDir, { recursive: true }) + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(filename, content) + paths.push(filename) + } + return paths +} + +export function normalizeUvPackageNames(values: readonly string[]): string[] { + for (const value of values) { + if (!/^[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i.test(value)) { + throw new InputError( + '--uv-package expects a project.name from pyproject.toml, such as "api". Repeat the flag to select more packages.', + ) + } + } + return Array.from(new Set(values.map(normalizePackageName))) +} + +export function resolveUvProjectRoot(targets: string[], cwd: string): string { + if (targets.length !== 1) { + throw new InputError( + '--uv-package requires exactly one uv project root as TARGET', + ) + } + const projectRoot = path.resolve(cwd, targets[0]!) + const relativeRoot = path.relative(cwd, projectRoot) + if ( + relativeRoot === '..' || + relativeRoot.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeRoot) || + !isDirSync(projectRoot) + ) { + throw new InputError( + '--uv-package requires a target directory inside --cwd', + ) + } + if ( + !existsSync(path.join(projectRoot, 'pyproject.toml')) || + !existsSync(path.join(projectRoot, 'uv.lock')) + ) { + throw new InputError( + '--uv-package requires pyproject.toml and uv.lock in the target directory', + ) + } + return projectRoot +} diff --git a/src/commands/scan/generate-uv-package-sboms.test.mts b/src/commands/scan/generate-uv-package-sboms.test.mts new file mode 100644 index 0000000000..2e53b02598 --- /dev/null +++ b/src/commands/scan/generate-uv-package-sboms.test.mts @@ -0,0 +1,144 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + generateUvPackageSboms, + normalizeUvPackageNames, + resolveUvProjectRoot, +} from './generate-uv-package-sboms.mts' + +const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })) + +vi.mock('@socketsecurity/registry/lib/spawn', () => ({ spawn: mockSpawn })) + +const sbom = JSON.stringify({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + metadata: { component: { name: 'api', 'bom-ref': 'api' } }, + components: [{ name: 'idna', version: '3.10', 'bom-ref': 'idna' }], + dependencies: [{ ref: 'api', dependsOn: ['idna'] }], +}) + +describe('uv package SBOM export', () => { + let projectRoot: string + let outputDir: string + + beforeEach(async () => { + vi.clearAllMocks() + projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-unit-')) + outputDir = path.join(projectRoot, 'output') + await fs.writeFile(path.join(projectRoot, 'pyproject.toml'), '') + await fs.writeFile(path.join(projectRoot, 'uv.lock'), '') + mockSpawn.mockResolvedValue({ stdout: sbom }) + }) + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }) + }) + + it('exports the graph without changing its contents', async () => { + const paths = await generateUvPackageSboms({ + outputDir, + packageNames: ['api'], + projectRoot, + }) + expect(paths).toEqual([path.join(outputDir, 'socket-api-cdx.json')]) + expect(await fs.readFile(paths[0]!, 'utf8')).toBe(sbom) + expect(mockSpawn).toHaveBeenCalledWith( + 'uv', + [ + 'export', + '--project', + projectRoot, + '--package', + 'api', + '--format', + 'cyclonedx1.5', + '--frozen', + '--offline', + '--no-python-downloads', + '--all-extras', + '--all-groups', + ], + expect.objectContaining({ cwd: projectRoot, stdio: 'pipe' }), + ) + }) + + it('normalizes and deduplicates package names', () => { + expect(normalizeUvPackageNames(['My_API', 'my.api', 'other'])).toEqual([ + 'my-api', + 'other', + ]) + expect(normalizeUvPackageNames([])).toEqual([]) + }) + + it.each([ + '', + './packages/api', + '../api', + '--all-packages', + 'api,worker', + '*', + 'api/worker', + ])('rejects invalid package selector %j', value => { + expect(() => normalizeUvPackageNames([value])).toThrow('project.name') + }) + + it.each([ + 'not JSON', + '{}', + '{"bomFormat":"CycloneDX","metadata":{"component":{"name":"api"}}}', + sbom.replace('"name":"api"', '"name":"wrong-root"'), + ])('rejects an invalid or incorrectly scoped SBOM', async stdout => { + mockSpawn.mockResolvedValueOnce({ stdout }) + await expect( + generateUvPackageSboms({ outputDir, packageNames: ['api'], projectRoot }), + ).rejects.toThrow('dependency graph rooted at "api"') + await expect( + fs.stat(path.join(outputDir, 'socket-api-cdx.json')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it.each([ + 'No workspace member named api', + 'Unsupported lockfile version', + 'uv is not installed', + ])('reports an export failure: %s', async stderr => { + mockSpawn.mockRejectedValueOnce( + Object.assign(new Error('command failed'), { stderr }), + ) + await expect( + generateUvPackageSboms({ outputDir, packageNames: ['api'], projectRoot }), + ).rejects.toMatchObject({ + message: expect.stringContaining('Could not export uv package "api"'), + body: stderr, + }) + }) + + it('resolves a single project root relative to cwd', () => { + expect(resolveUvProjectRoot(['.'], projectRoot)).toBe(projectRoot) + expect(resolveUvProjectRoot([projectRoot], projectRoot)).toBe(projectRoot) + }) + + it.each([[], ['.', '.'], ['pyproject.toml'], ['missing'], ['..']])( + 'rejects invalid project roots %j', + (...targets) => { + expect(() => resolveUvProjectRoot(targets, projectRoot)).toThrow( + '--uv-package requires', + ) + }, + ) + + it.each(['pyproject.toml', 'uv.lock'])( + 'requires %s at the target root', + async filename => { + await fs.unlink(path.join(projectRoot, filename)) + expect(() => resolveUvProjectRoot(['.'], projectRoot)).toThrow( + 'requires pyproject.toml and uv.lock', + ) + }, + ) +}) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index f76cc57ede..ea3358717f 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -1,4 +1,4 @@ -import { unlink } from 'node:fs/promises' +import { copyFile, unlink } from 'node:fs/promises' import path from 'node:path' import micromatch from 'micromatch' @@ -11,6 +11,10 @@ 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 { + generateUvPackageSboms, + resolveUvProjectRoot, +} from './generate-uv-package-sboms.mts' import { handleScanReport } from './handle-scan-report.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' @@ -22,6 +26,7 @@ import { snapshotSocketFacts, } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' +import { InputError } from '../../utils/errors.mts' import { withTmpDir } from '../../utils/fs.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' @@ -98,6 +103,7 @@ export type HandleCreateNewScanConfig = { reportLevel: REPORT_LEVEL targets: string[] tmp: boolean + uvPackages?: string[] | undefined workspace?: string | undefined } @@ -121,9 +127,18 @@ export async function handleCreateNewScan({ reportLevel, targets, tmp, + uvPackages = [], workspace, }: HandleCreateNewScanConfig): Promise { let scanTargets = targets + if (uvPackages.length && (autoManifest || reach.dynamicSbomInference)) { + throw new InputError( + '--uv-package cannot be combined with --auto-manifest or --dynamic-sbom-inference', + ) + } + const uvProjectRoot = uvPackages.length + ? resolveUvProjectRoot(targets, cwd) + : undefined debugFn( 'notice', @@ -239,7 +254,11 @@ export async function handleCreateNewScan({ `Fetched ${supportedFilesCResult.data['size']} supported file types`, ) - spinner.start('Searching for local files to include in scan...') + spinner.start( + uvProjectRoot + ? 'Exporting selected uv packages...' + : 'Searching for local files to include in scan...', + ) const supportedFiles = supportedFilesCResult.data @@ -256,15 +275,19 @@ export async function handleCreateNewScan({ target: targets[0]!, }) - const packagePaths = await getPackageFilesForScan( - scanTargets, - supportedFiles, - { - additionalIgnores: additionalScaIgnores, - config: socketConfig, - cwd, - }, - ) + // Explicit package selection supplies the complete scan input. Uploading + // discovered manifests alongside these SBOMs would expand the scan again. + const packagePaths = uvProjectRoot + ? await generateUvPackageSboms({ + outputDir: manifestTmpDir, + packageNames: uvPackages, + projectRoot: uvProjectRoot, + }) + : await getPackageFilesForScan(scanTargets, supportedFiles, { + additionalIgnores: additionalScaIgnores, + config: socketConfig, + cwd, + }) spinner.successAndStop( `Found ${packagePaths.length} ${pluralize('file', packagePaths.length)} to include in scan.`, @@ -320,6 +343,7 @@ export async function handleCreateNewScan({ orgSlug, outputKind, packagePaths, + ...(uvProjectRoot ? { manifestUploadRoot: manifestTmpDir } : {}), reachabilityOptions: mergedReachabilityOptions, repoName, resolvedPathsSidecar, @@ -375,6 +399,19 @@ export async function handleCreateNewScan({ } } + // Keep the reachability report beside the exported SBOMs for upload. + // Coana still runs against the original source directory. + if (uvProjectRoot && reachabilityReport && !reachabilityFallback) { + const stagedReport = path.join( + manifestTmpDir, + constants.DOT_SOCKET_DOT_FACTS_JSON, + ) + await copyFile(path.resolve(cwd, reachabilityReport), stagedReport) + scanPaths = scanPaths.map(p => + p === reachabilityReport ? stagedReport : p, + ) + } + // Brotli-compress any .socket.facts.json paths in scanPaths just before // upload. depscan's api-v0 multipart boundary streams brotli decode based // on the .br filename suffix. Coana keeps writing plain .socket.facts.json @@ -401,7 +438,7 @@ export async function handleCreateNewScan({ workspace, }, { - cwd, + cwd: uvProjectRoot ? manifestTmpDir : cwd, defaultBranch, pendingHead, tmp, diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 13652875b5..39e835e19a 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -24,6 +24,7 @@ const { mockFindSocketYmlSync, mockGenerateAutoManifest, mockGenerateRecursiveManifests, + mockGenerateUvPackageSboms, mockGetPackageFilesForScan, mockPerformReachabilityAnalysis, mockReadOrDefaultSocketJson, @@ -33,6 +34,7 @@ const { mockFindSocketYmlSync: vi.fn(), mockGenerateAutoManifest: vi.fn(), mockGenerateRecursiveManifests: vi.fn(), + mockGenerateUvPackageSboms: vi.fn(), mockGetPackageFilesForScan: vi.fn(), mockPerformReachabilityAnalysis: vi.fn(), mockReadOrDefaultSocketJson: vi.fn(), @@ -50,6 +52,11 @@ vi.mock('./finalize-tier1-scan.mts', () => ({ finalizeTier1Scan: vi.fn(), })) +vi.mock('./generate-uv-package-sboms.mts', () => ({ + generateUvPackageSboms: mockGenerateUvPackageSboms, + resolveUvProjectRoot: () => '/repo', +})) + vi.mock('./handle-scan-report.mts', () => ({ handleScanReport: vi.fn(), })) @@ -155,6 +162,17 @@ describe('handleCreateNewScan excludePaths', () => { mockGenerateAutoManifest.mockResolvedValue({ generatedFiles: [] }) mockGenerateRecursiveManifests.mockResolvedValue([]) mockGetPackageFilesForScan.mockResolvedValue(['package.json']) + mockGenerateUvPackageSboms.mockImplementation( + async ({ outputDir, packageNames }) => + packageNames.map((name: string) => { + const filename = path.join(outputDir, `socket-${name}-cdx.json`) + writeFileSync( + filename, + JSON.stringify({ bomFormat: 'CycloneDX', package: name }), + ) + return filename + }), + ) mockPerformReachabilityAnalysis.mockResolvedValue({ data: { reachabilityReport: '.socket.facts.json', @@ -165,6 +183,144 @@ describe('handleCreateNewScan excludePaths', () => { mockReadOrDefaultSocketJson.mockReturnValue({}) }) + it('uses only selected uv SBOMs and cleans them up after uploading', async () => { + let uploadRoot = '' + mockGetPackageFilesForScan.mockResolvedValue([ + '/repo/uv.lock', + '/repo/pyproject.toml', + ]) + mockFetchCreateOrgFullScan.mockImplementationOnce( + async (paths, _org, _config, options) => { + uploadRoot = options.cwd + expect(paths).toEqual([ + path.join(uploadRoot, 'socket-api-cdx.json'), + path.join(uploadRoot, 'socket-worker-cdx.json'), + ]) + expect(JSON.parse(readFileSync(paths[0], 'utf8')).package).toBe('api') + return { ok: true, data: { id: 'scan-id' } } + }, + ) + await handleCreateNewScan(createConfig({ uvPackages: ['api', 'worker'] })) + expect(mockGenerateUvPackageSboms).toHaveBeenCalledWith({ + outputDir: uploadRoot, + packageNames: ['api', 'worker'], + projectRoot: '/repo', + }) + expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() + expect(existsSync(uploadRoot)).toBe(false) + }) + + it('does not export uv packages unless explicitly requested', async () => { + mockGetPackageFilesForScan.mockResolvedValueOnce([ + '/repo/packages/api/pyproject.toml', + ]) + await handleCreateNewScan(createConfig()) + expect(mockGenerateUvPackageSboms).not.toHaveBeenCalled() + expect(mockFetchCreateOrgFullScan.mock.calls[0]?.[0]).toEqual([ + '/repo/packages/api/pyproject.toml', + ]) + }) + + it('prepares and cleans uv SBOMs in read-only mode without uploading', async () => { + await handleCreateNewScan( + createConfig({ readOnly: true, uvPackages: ['api'] }), + ) + expect(mockGenerateUvPackageSboms).toHaveBeenCalledOnce() + expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() + expect( + existsSync(mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir), + ).toBe(false) + }) + + it('cleans partial uv output and does not upload if any export fails', async () => { + let outputDir = '' + mockGenerateUvPackageSboms.mockImplementationOnce(async options => { + outputDir = options.outputDir + writeFileSync(path.join(outputDir, 'partial.json'), '{}') + throw new Error('second package failed') + }) + await expect( + handleCreateNewScan(createConfig({ uvPackages: ['api', 'worker'] })), + ).rejects.toThrow('second package failed') + expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() + expect(existsSync(outputDir)).toBe(false) + }) + + it('cleans uv output when the upload throws', async () => { + mockFetchCreateOrgFullScan.mockRejectedValueOnce(new Error('upload failed')) + await expect( + handleCreateNewScan(createConfig({ uvPackages: ['api'] })), + ).rejects.toThrow('upload failed') + expect( + existsSync(mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir), + ).toBe(false) + }) + + it('rejects other generators in uv package mode before generating files', async () => { + await expect( + handleCreateNewScan( + createConfig({ autoManifest: true, uvPackages: ['api'] }), + ), + ).rejects.toThrow('cannot be combined') + expect(mockGenerateAutoManifest).not.toHaveBeenCalled() + expect(mockGenerateUvPackageSboms).not.toHaveBeenCalled() + }) + + it('uses the same uv SBOMs for reachability and stages its report for the final scan', async () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'socket-uv-reach-')) + try { + const config = createConfig({ cwd, targets: [cwd], uvPackages: ['api'] }) + config.reach.runReachabilityAnalysis = true + mockPerformReachabilityAnalysis.mockImplementationOnce(async options => { + expect(options.cwd).toBe(cwd) + expect(options.target).toBe(cwd) + expect(options.packagePaths).toEqual([ + path.join(options.manifestUploadRoot, 'socket-api-cdx.json'), + ]) + writeFileSync(path.join(cwd, '.socket.facts.json'), '{"components":[]}') + return { + ok: true, + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + } + }) + mockFetchCreateOrgFullScan.mockImplementationOnce( + async (paths, _org, _config, options) => { + expect(paths).toEqual([ + path.join(options.cwd, 'socket-api-cdx.json'), + path.join(options.cwd, '.socket.facts.json.br'), + ]) + expect(paths.every(existsSync)).toBe(true) + return { ok: true, data: { id: 'scan-id' } } + }, + ) + await handleCreateNewScan(config) + expect( + existsSync(mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir), + ).toBe(false) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) + + it('keeps the scoped uv SBOMs when reachability falls back to a regular scan', async () => { + const config = createConfig({ uvPackages: ['api'] }) + config.reach.runReachabilityAnalysis = true + config.reach.reachFallbackToRegularScan = true + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + ok: false, + message: 'analysis failed', + }) + await handleCreateNewScan(config) + const uploadRoot = mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir + expect(mockFetchCreateOrgFullScan.mock.calls[0]?.[0]).toEqual([ + path.join(uploadRoot, 'socket-api-cdx.json'), + ]) + expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() + }) + it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 3314e1bc98..ab4befdc8a 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -54,6 +54,8 @@ export type ReachabilityAnalysisOptions = { outputKind?: OutputKind | undefined outputPath?: string | undefined packagePaths: string[] + // Generated SBOMs can live outside the source tree during an upload. + manifestUploadRoot?: string | undefined reachabilityOptions: ReachabilityOptions // Resolved-paths sidecar from the auto-manifest run; passed to coana so it // reuses these paths instead of re-resolving the build. @@ -74,6 +76,7 @@ export async function performReachabilityAnalysis( const { branchName, cwd = process.cwd(), + manifestUploadRoot, orgSlug, outputKind = 'text', outputPath, @@ -143,7 +146,7 @@ export async function performReachabilityAnalysis( // deletion in handle-create-new-scan.mts. const uploadCResult = await handleApiCall( sockSdk.uploadManifestFiles(orgSlug, packagePaths, { - pathsRelativeTo: path.resolve(cwd, analysisTarget), + pathsRelativeTo: manifestUploadRoot ?? path.resolve(cwd, analysisTarget), }), { description: 'upload manifests', diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index bf24a6dbcb..2d31616b04 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -220,6 +220,29 @@ describe('performReachabilityAnalysis manifests tar hash', () => { expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) + it('uploads generated SBOMs relative to their staging root while analyzing the original target', async () => { + const uploadManifestFiles = vi.fn() + mockSetupSdk.mockResolvedValueOnce({ + ok: true, + data: { uploadManifestFiles }, + }) + const packagePaths = ['/staged/socket-api-cdx.json'] + await performReachabilityAnalysis({ + cwd: scanCwd, + manifestUploadRoot: '/staged', + orgSlug: TEST_ORG_SLUG, + packagePaths, + reachabilityOptions: makeReachabilityOptions(), + target: scanCwd, + }) + expect(uploadManifestFiles).toHaveBeenCalledWith( + TEST_ORG_SLUG, + packagePaths, + { pathsRelativeTo: '/staged' }, + ) + expect(mockSpawnCoanaDlx.mock.calls[0]?.[2].cwd).toBe(scanCwd) + }) + it('fails without spawning Coana when the upload returns no tar hash', async () => { mockHandleApiCall.mockResolvedValueOnce({ ok: true, data: {} } as never) diff --git a/test/fixtures/commands/scan/uv-workspace/packages/api/pyproject.toml b/test/fixtures/commands/scan/uv-workspace/packages/api/pyproject.toml new file mode 100644 index 0000000000..52909e5fd2 --- /dev/null +++ b/test/fixtures/commands/scan/uv-workspace/packages/api/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "workspace-api" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["workspace-shared", "idna==3.10"] +[project.optional-dependencies] +color = ["colorama==0.4.6"] +[dependency-groups] +dev = ["iniconfig==2.1.0"] +[tool.uv.sources] +workspace-shared = { workspace = true } diff --git a/test/fixtures/commands/scan/uv-workspace/packages/other/pyproject.toml b/test/fixtures/commands/scan/uv-workspace/packages/other/pyproject.toml new file mode 100644 index 0000000000..fac1cef462 --- /dev/null +++ b/test/fixtures/commands/scan/uv-workspace/packages/other/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "workspace-other" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["sniffio==1.3.1"] diff --git a/test/fixtures/commands/scan/uv-workspace/packages/shared/pyproject.toml b/test/fixtures/commands/scan/uv-workspace/packages/shared/pyproject.toml new file mode 100644 index 0000000000..40b6854ddb --- /dev/null +++ b/test/fixtures/commands/scan/uv-workspace/packages/shared/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "workspace-shared" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["typing-extensions==4.12.2", "tzdata==2025.1; sys_platform == 'win32'"] diff --git a/test/fixtures/commands/scan/uv-workspace/pyproject.toml b/test/fixtures/commands/scan/uv-workspace/pyproject.toml new file mode 100644 index 0000000000..1d6431e4b5 --- /dev/null +++ b/test/fixtures/commands/scan/uv-workspace/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "workspace-root" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["click==8.1.7"] +[dependency-groups] +dev = ["packaging==24.2"] +[tool.uv.workspace] +members = ["packages/*"] diff --git a/test/fixtures/commands/scan/uv-workspace/uv.lock b/test/fixtures/commands/scan/uv-workspace/uv.lock new file mode 100644 index 0000000000..64e2253c71 --- /dev/null +++ b/test/fixtures/commands/scan/uv-workspace/uv.lock @@ -0,0 +1,161 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[manifest] +members = [ + "workspace-api", + "workspace-other", + "workspace-root", + "workspace-shared", +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950, upload-time = "2025-01-21T19:49:38.686Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762, upload-time = "2025-01-21T19:49:37.187Z" }, +] + +[[package]] +name = "workspace-api" +version = "0.1.0" +source = { virtual = "packages/api" } +dependencies = [ + { name = "idna" }, + { name = "workspace-shared" }, +] + +[package.optional-dependencies] +color = [ + { name = "colorama" }, +] + +[package.dev-dependencies] +dev = [ + { name = "iniconfig" }, +] + +[package.metadata] +requires-dist = [ + { name = "colorama", marker = "extra == 'color'", specifier = "==0.4.6" }, + { name = "idna", specifier = "==3.10" }, + { name = "workspace-shared", editable = "packages/shared" }, +] +provides-extras = ["color"] + +[package.metadata.requires-dev] +dev = [{ name = "iniconfig", specifier = "==2.1.0" }] + +[[package]] +name = "workspace-other" +version = "0.1.0" +source = { virtual = "packages/other" } +dependencies = [ + { name = "sniffio" }, +] + +[package.metadata] +requires-dist = [{ name = "sniffio", specifier = "==1.3.1" }] + +[[package]] +name = "workspace-root" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "click" }, +] + +[package.dev-dependencies] +dev = [ + { name = "packaging" }, +] + +[package.metadata] +requires-dist = [{ name = "click", specifier = "==8.1.7" }] + +[package.metadata.requires-dev] +dev = [{ name = "packaging", specifier = "==24.2" }] + +[[package]] +name = "workspace-shared" +version = "0.1.0" +source = { editable = "packages/shared" } +dependencies = [ + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "typing-extensions", specifier = "==4.12.2" }, + { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.1" }, +] From 4ce7c1018bc5d2ac6d607ae4ba4b9eec86b605bd Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 28 Sep 2026 13:05:51 +0200 Subject: [PATCH 2/7] docs(scan): remove stale and repeated upload comments --- src/commands/scan/handle-create-new-scan.mts | 2 -- src/commands/scan/perform-reachability-analysis.mts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index ea3358717f..c2493b96ca 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -275,8 +275,6 @@ export async function handleCreateNewScan({ target: targets[0]!, }) - // Explicit package selection supplies the complete scan input. Uploading - // discovered manifests alongside these SBOMs would expand the scan again. const packagePaths = uvProjectRoot ? await generateUvPackageSboms({ outputDir: manifestTmpDir, diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index ab4befdc8a..c44440ebe4 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -137,7 +137,6 @@ export async function performReachabilityAnalysis( spinner?.start('Uploading manifests for reachability analysis...') - // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path // NOTE: previously stripped any `.socket.facts.json` from packagePaths // here to avoid uploading leftover post-reachability output. With the // producer flow (`socket manifest gradle --facts`) those files are From 6c1869c529bfb18ab61cbf10d2e400c826fa607b Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 28 Sep 2026 13:28:19 +0200 Subject: [PATCH 3/7] fix(scan): preserve development scopes in uv package scans --- CHANGELOG.md | 2 +- README.md | 1 + .../generate-uv-package-sboms.e2e.test.mts | 48 +++++++ .../scan/generate-uv-package-sboms.mts | 123 ++++++++++++------ .../scan/generate-uv-package-sboms.test.mts | 104 ++++++++++++++- 5 files changed, 231 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dddbc43796..d7810ed207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- `socket scan create --uv-package ` scans selected uv packages with locked versions and dependency relationships from their shared `uv.lock`. Repeat the option to select more packages. Requires uv with CycloneDX export support. +- `socket scan create --uv-package ` scans selected uv packages with locked versions and dependency relationships from their shared `uv.lock`. Dependencies used only by dependency groups are marked as development dependencies. Repeat the option to select more packages. Requires uv with CycloneDX export support. ## [1.2.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.0) - 2026-09-27 diff --git a/README.md b/README.md index f1f7aaf075..7432adcebb 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ socket scan create --cwd ./python-workspace . --uv-package api This mode requires uv on PATH with support for `uv export --format cyclonedx1.5`. It exports a separate CycloneDX SBOM for each package, including transitive and local workspace dependencies, all extras, and all dependency groups. +Dependencies used only by dependency groups are marked as development dependencies. The export runs offline with `--frozen`, so it uses the existing lockfile without resolving newer versions, installing packages, or changing the project. uv currently treats CycloneDX export as a preview feature. diff --git a/src/commands/scan/generate-uv-package-sboms.e2e.test.mts b/src/commands/scan/generate-uv-package-sboms.e2e.test.mts index b035d28d1e..305e312bde 100644 --- a/src/commands/scan/generate-uv-package-sboms.e2e.test.mts +++ b/src/commands/scan/generate-uv-package-sboms.e2e.test.mts @@ -40,6 +40,7 @@ type Component = { 'bom-ref': string name: string version: string + scope?: string properties?: Array<{ name: string; value: string }> } @@ -73,6 +74,11 @@ function assertApiGraph(sbom: Sbom): void { 'tzdata@2025.1', 'workspace-shared@0.1.0', ]) + for (const component of sbom.components) { + expect(component.scope).toBe( + component.name === 'iniconfig' ? 'optional' : 'required', + ) + } expect( sbom.components.find(c => c.name === 'tzdata')?.properties, ).toContainEqual({ @@ -143,6 +149,48 @@ describe('uv package scans with the real uv binary', () => { assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) }) + it('classifies transitive group dependencies and keeps shared production dependencies required', async () => { + const manifest = path.join(projectRoot, 'packages/api/pyproject.toml') + await fs.writeFile( + manifest, + (await fs.readFile(manifest, 'utf8')) + .replace( + 'dev = ["iniconfig==2.1.0"]', + 'dev = ["iniconfig==2.1.0", "idna==3.10", "workspace-other"]\nqa = ["packaging==24.2"]', + ) + .concat( + '\nworkspace-other = { workspace = true }\n[tool.uv]\ndefault-groups = ["dev", "qa"]\n', + ), + ) + const lockfile = path.join(projectRoot, 'uv.lock') + await fs.writeFile( + lockfile, + (await fs.readFile(lockfile, 'utf8')).replace( + 'dev = [\n { name = "iniconfig" },\n]', + 'dev = [\n { name = "iniconfig" },\n { name = "idna" },\n { name = "workspace-other" },\n]\nqa = [{ name = "packaging" }]', + ), + ) + const [filename] = await generateUvPackageSboms({ + outputDir, + packageNames: ['workspace-api'], + projectRoot, + }) + const sbom = JSON.parse(await fs.readFile(filename!, 'utf8')) as Sbom + expect( + Object.fromEntries(sbom.components.map(c => [c.name, c.scope])), + ).toEqual({ + colorama: 'required', + idna: 'required', + iniconfig: 'optional', + packaging: 'optional', + sniffio: 'optional', + 'typing-extensions': 'required', + tzdata: 'required', + 'workspace-other': 'optional', + 'workspace-shared': 'required', + }) + }) + it('rejects unknown packages and does not fall back to the workspace root', async () => { await expect( generateUvPackageSboms({ diff --git a/src/commands/scan/generate-uv-package-sboms.mts b/src/commands/scan/generate-uv-package-sboms.mts index e2039d20ea..5fc21bc21c 100644 --- a/src/commands/scan/generate-uv-package-sboms.mts +++ b/src/commands/scan/generate-uv-package-sboms.mts @@ -8,25 +8,36 @@ import { spawn } from '@socketsecurity/registry/lib/spawn' import constants from '../../constants.mts' import { InputError, getErrorMessage } from '../../utils/errors.mts' +type Component = { + name: string + version?: string + purl?: string + scope?: string +} + +type Sbom = { + bomFormat?: string + components?: Component[] + dependencies?: unknown[] + metadata?: { component?: { name?: string } } +} + function normalizePackageName(name: string): string { return name.toLowerCase().replaceAll(/[._-]+/g, '-') } -function validateSbom(content: string, packageName: string): void { +function parseSbom(content: string, packageName: string): Sbom { try { - const sbom = JSON.parse(content) as { - bomFormat?: string - dependencies?: unknown[] - metadata?: { component?: { name?: string } } - } + const sbom = JSON.parse(content) as Sbom const rootName = sbom?.metadata?.component?.name if ( sbom?.bomFormat === 'CycloneDX' && Array.isArray(sbom.dependencies) && + (sbom.components === undefined || Array.isArray(sbom.components)) && typeof rootName === 'string' && normalizePackageName(rootName) === packageName ) { - return + return sbom } } catch {} throw new InputError( @@ -34,6 +45,51 @@ function validateSbom(content: string, packageName: string): void { ) } +function componentIdentity(component: Component): string { + return JSON.stringify([component.name, component.version, component.purl]) +} + +async function exportSbom( + projectRoot: string, + packageName: string, + groups: '--all-groups' | '--no-default-groups', +): Promise { + let content: string + try { + const { stdout } = await spawn( + 'uv', + [ + 'export', + '--project', + projectRoot, + '--package', + packageName, + '--format', + 'cyclonedx1.5', + '--frozen', + '--offline', + '--no-python-downloads', + '--all-extras', + groups, + ], + { + cwd: projectRoot, + signal: constants.abortSignal, + stdio: 'pipe', + }, + ) + content = stdout + } catch (e) { + throw new InputError( + `Could not export uv package "${packageName}" from ${projectRoot}. Install uv on PATH with CycloneDX export support and check that this package is in the shared uv.lock.`, + e && typeof e === 'object' && 'stderr' in e + ? String(e.stderr).trim() || getErrorMessage(e) + : getErrorMessage(e), + ) + } + return parseSbom(content, packageName) +} + export async function generateUvPackageSboms({ outputDir, packageNames, @@ -46,48 +102,29 @@ export async function generateUvPackageSboms({ const paths: string[] = [] for (const packageName of normalizeUvPackageNames(packageNames)) { logger.info(`Exporting the uv dependency graph for ${packageName}...`) - let content: string - try { - // Export each package separately so each SBOM has its own project root. - // eslint-disable-next-line no-await-in-loop - const { stdout } = await spawn( - 'uv', - [ - 'export', - '--project', - projectRoot, - '--package', - packageName, - '--format', - 'cyclonedx1.5', - '--frozen', - '--offline', - '--no-python-downloads', - '--all-extras', - '--all-groups', - ], - { - cwd: projectRoot, - signal: constants.abortSignal, - stdio: 'pipe', - }, - ) - content = stdout - } catch (e) { - throw new InputError( - `Could not export uv package "${packageName}" from ${projectRoot}. Install uv on PATH with CycloneDX export support and check that this package is in the shared uv.lock.`, - e && typeof e === 'object' && 'stderr' in e - ? String(e.stderr).trim() || getErrorMessage(e) - : getErrorMessage(e), - ) + // eslint-disable-next-line no-await-in-loop + const sbom = await exportSbom(projectRoot, packageName, '--all-groups') + // eslint-disable-next-line no-await-in-loop + const productionSbom = await exportSbom( + projectRoot, + packageName, + '--no-default-groups', + ) + // uv can renumber bom-ref values between exports. + const productionPackages = new Set( + productionSbom.components?.map(componentIdentity), + ) + for (const component of sbom.components ?? []) { + component.scope = productionPackages.has(componentIdentity(component)) + ? 'required' + : 'optional' } - validateSbom(content, packageName) // Keep workspace-relative paths in the SBOM relative to the upload root. const filename = path.join(outputDir, `socket-${packageName}-cdx.json`) // eslint-disable-next-line no-await-in-loop await fs.mkdir(outputDir, { recursive: true }) // eslint-disable-next-line no-await-in-loop - await fs.writeFile(filename, content) + await fs.writeFile(filename, JSON.stringify(sbom)) paths.push(filename) } return paths diff --git a/src/commands/scan/generate-uv-package-sboms.test.mts b/src/commands/scan/generate-uv-package-sboms.test.mts index 2e53b02598..2e02109b3b 100644 --- a/src/commands/scan/generate-uv-package-sboms.test.mts +++ b/src/commands/scan/generate-uv-package-sboms.test.mts @@ -39,15 +39,22 @@ describe('uv package SBOM export', () => { await fs.rm(projectRoot, { recursive: true, force: true }) }) - it('exports the graph without changing its contents', async () => { + it('preserves the graph and marks production dependencies as required', async () => { const paths = await generateUvPackageSboms({ outputDir, packageNames: ['api'], projectRoot, }) expect(paths).toEqual([path.join(outputDir, 'socket-api-cdx.json')]) - expect(await fs.readFile(paths[0]!, 'utf8')).toBe(sbom) - expect(mockSpawn).toHaveBeenCalledWith( + expect(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))).toEqual({ + ...JSON.parse(sbom), + components: [ + { name: 'idna', version: '3.10', 'bom-ref': 'idna', scope: 'required' }, + ], + }) + expect(mockSpawn).toHaveBeenCalledTimes(2) + expect(mockSpawn).toHaveBeenNthCalledWith( + 1, 'uv', [ 'export', @@ -65,8 +72,99 @@ describe('uv package SBOM export', () => { ], expect.objectContaining({ cwd: projectRoot, stdio: 'pipe' }), ) + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + 'uv', + [...mockSpawn.mock.calls[0]![1].slice(0, -1), '--no-default-groups'], + expect.objectContaining({ cwd: projectRoot, stdio: 'pipe' }), + ) }) + it('matches package identities across exports without changing edges or metadata', async () => { + const graph = { + ...JSON.parse(sbom), + components: [ + { name: 'shared', version: '1', 'bom-ref': 'shared-2' }, + { name: 'library', version: '1', 'bom-ref': 'library-3' }, + { name: 'library', version: '2', 'bom-ref': 'library-4' }, + { + name: 'library', + version: '1', + purl: 'pkg:pypi/library@1?repository_url=https://other.example', + 'bom-ref': 'library-5', + }, + ], + dependencies: [ + { ref: 'api', dependsOn: ['shared-2', 'library-4'] }, + { ref: 'shared-2', dependsOn: ['library-3'] }, + { ref: 'library-4', dependsOn: ['library-5'] }, + ], + } + mockSpawn + .mockResolvedValueOnce({ stdout: JSON.stringify(graph) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + ...JSON.parse(sbom), + components: [ + { name: 'shared', version: '1', 'bom-ref': 'shared-4' }, + { name: 'library', version: '1', 'bom-ref': 'library-2' }, + ], + }), + }) + const [filename] = await generateUvPackageSboms({ + outputDir, + packageNames: ['api'], + projectRoot, + }) + expect(JSON.parse(await fs.readFile(filename!, 'utf8'))).toEqual({ + ...graph, + components: graph.components.map((component, index) => ({ + ...component, + scope: index < 2 ? 'required' : 'optional', + })), + }) + }) + + it('marks all dependencies as development when the production graph is empty', async () => { + mockSpawn.mockResolvedValueOnce({ stdout: sbom }).mockResolvedValueOnce({ + stdout: JSON.stringify({ + ...JSON.parse(sbom), + components: undefined, + dependencies: [], + }), + }) + const [filename] = await generateUvPackageSboms({ + outputDir, + packageNames: ['api'], + projectRoot, + }) + expect(JSON.parse(await fs.readFile(filename!, 'utf8')).components).toEqual( + [{ name: 'idna', version: '3.10', 'bom-ref': 'idna', scope: 'optional' }], + ) + }) + + it.each(['invalid graph', 'export failure'])( + 'does not write an SBOM when the production export has an %s', + async failure => { + mockSpawn.mockResolvedValueOnce({ stdout: sbom }) + if (failure === 'invalid graph') { + mockSpawn.mockResolvedValueOnce({ stdout: '{}' }) + } else { + mockSpawn.mockRejectedValueOnce(new Error('export failed')) + } + await expect( + generateUvPackageSboms({ + outputDir, + packageNames: ['api'], + projectRoot, + }), + ).rejects.toThrow() + await expect( + fs.stat(path.join(outputDir, 'socket-api-cdx.json')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + }, + ) + it('normalizes and deduplicates package names', () => { expect(normalizeUvPackageNames(['My_API', 'my.api', 'other'])).toEqual([ 'my-api', From 9b4b0cfc6fb7ca8fd13d0d4bc592bcb2ac47d344 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 28 Sep 2026 14:20:51 +0200 Subject: [PATCH 4/7] test(scan): give uv CLI tests the 30s CLI spawn timeout These cases spawn the built CLI like the cmdit tests do, but ran with vitest's 5s default and timed out under parallel load. --- src/commands/scan/cmd-scan-create.test.mts | 40 +++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index bc82eaa0b4..5f3a7de416 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -35,17 +35,21 @@ describe('socket scan create', async () => { '{}', ] - it('accepts repeated uv package selectors with an implicit root target', async () => { - const result = await spawnSocketCli(binCliPath, [ - ...uvBaseArgs, - '--uv-package', - 'workspace-api', - '--uv-package', - 'workspace-other', - ]) - expect(result.code).toBe(0) - expect(result.stdout).toContain('[DryRun]: Bailing now') - }) + it( + 'accepts repeated uv package selectors with an implicit root target', + { timeout: 30_000 }, + async () => { + const result = await spawnSocketCli(binCliPath, [ + ...uvBaseArgs, + '--uv-package', + 'workspace-api', + '--uv-package', + 'workspace-other', + ]) + expect(result.code).toBe(0) + expect(result.stdout).toContain('[DryRun]: Bailing now') + }, + ) it.each([ { @@ -88,11 +92,15 @@ describe('socket scan create', async () => { args: ['.', '--uv-package', 'workspace-api', '--dynamic-sbom-inference'], error: 'cannot be combined', }, - ])('rejects invalid uv package options: $args', async ({ args, error }) => { - const result = await spawnSocketCli(binCliPath, [...uvBaseArgs, ...args]) - expect(result.code).not.toBe(0) - expect(result.stdout + result.stderr).toContain(error) - }) + ])( + 'rejects invalid uv package options: $args', + { timeout: 30_000 }, + async ({ args, error }) => { + const result = await spawnSocketCli(binCliPath, [...uvBaseArgs, ...args]) + expect(result.code).not.toBe(0) + expect(result.stdout + result.stderr).toContain(error) + }, + ) cmdit( ['scan', 'create', FLAG_HELP, FLAG_CONFIG, '{}'], From 11a7a7d586fe42c332c4f25ec653c3613c172f46 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 28 Sep 2026 14:52:48 +0200 Subject: [PATCH 5/7] feat(scan): select uv workspace members by target directory Replace --uv-package with a boolean --uv-members flag. Each TARGET is a member directory, and uv resolves it from the uv.lock in that directory or its workspace root, so one scan can cover members of several workspaces. Each member's CycloneDX SBOM is written beside its pyproject.toml and removed after the scan. That keeps the existing upload roots and reachability target unchanged, so the scan handler takes a generic generateScanFiles hook and no longer has uv-specific branches. With --reach, analysis runs on the member directory only. The e2e assertions now accept uv 0.12, which omits dependsOn on leaf nodes and adds workspace-root dependency groups to members without their own. --- ... => generate-uv-member-sboms.e2e.test.mts} | 0 ...sboms.mts => generate-uv-member-sboms.mts} | 0 ....mts => generate-uv-member-sboms.test.mts} | 0 .../scan/handle-create-new-scan.test.mts | 156 ------------------ .../scan/perform-reachability-analysis.mts | 6 +- .../perform-reachability-analysis.test.mts | 23 --- 6 files changed, 2 insertions(+), 183 deletions(-) rename src/commands/scan/{generate-uv-package-sboms.e2e.test.mts => generate-uv-member-sboms.e2e.test.mts} (100%) rename src/commands/scan/{generate-uv-package-sboms.mts => generate-uv-member-sboms.mts} (100%) rename src/commands/scan/{generate-uv-package-sboms.test.mts => generate-uv-member-sboms.test.mts} (100%) diff --git a/src/commands/scan/generate-uv-package-sboms.e2e.test.mts b/src/commands/scan/generate-uv-member-sboms.e2e.test.mts similarity index 100% rename from src/commands/scan/generate-uv-package-sboms.e2e.test.mts rename to src/commands/scan/generate-uv-member-sboms.e2e.test.mts diff --git a/src/commands/scan/generate-uv-package-sboms.mts b/src/commands/scan/generate-uv-member-sboms.mts similarity index 100% rename from src/commands/scan/generate-uv-package-sboms.mts rename to src/commands/scan/generate-uv-member-sboms.mts diff --git a/src/commands/scan/generate-uv-package-sboms.test.mts b/src/commands/scan/generate-uv-member-sboms.test.mts similarity index 100% rename from src/commands/scan/generate-uv-package-sboms.test.mts rename to src/commands/scan/generate-uv-member-sboms.test.mts diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 39e835e19a..13652875b5 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -24,7 +24,6 @@ const { mockFindSocketYmlSync, mockGenerateAutoManifest, mockGenerateRecursiveManifests, - mockGenerateUvPackageSboms, mockGetPackageFilesForScan, mockPerformReachabilityAnalysis, mockReadOrDefaultSocketJson, @@ -34,7 +33,6 @@ const { mockFindSocketYmlSync: vi.fn(), mockGenerateAutoManifest: vi.fn(), mockGenerateRecursiveManifests: vi.fn(), - mockGenerateUvPackageSboms: vi.fn(), mockGetPackageFilesForScan: vi.fn(), mockPerformReachabilityAnalysis: vi.fn(), mockReadOrDefaultSocketJson: vi.fn(), @@ -52,11 +50,6 @@ vi.mock('./finalize-tier1-scan.mts', () => ({ finalizeTier1Scan: vi.fn(), })) -vi.mock('./generate-uv-package-sboms.mts', () => ({ - generateUvPackageSboms: mockGenerateUvPackageSboms, - resolveUvProjectRoot: () => '/repo', -})) - vi.mock('./handle-scan-report.mts', () => ({ handleScanReport: vi.fn(), })) @@ -162,17 +155,6 @@ describe('handleCreateNewScan excludePaths', () => { mockGenerateAutoManifest.mockResolvedValue({ generatedFiles: [] }) mockGenerateRecursiveManifests.mockResolvedValue([]) mockGetPackageFilesForScan.mockResolvedValue(['package.json']) - mockGenerateUvPackageSboms.mockImplementation( - async ({ outputDir, packageNames }) => - packageNames.map((name: string) => { - const filename = path.join(outputDir, `socket-${name}-cdx.json`) - writeFileSync( - filename, - JSON.stringify({ bomFormat: 'CycloneDX', package: name }), - ) - return filename - }), - ) mockPerformReachabilityAnalysis.mockResolvedValue({ data: { reachabilityReport: '.socket.facts.json', @@ -183,144 +165,6 @@ describe('handleCreateNewScan excludePaths', () => { mockReadOrDefaultSocketJson.mockReturnValue({}) }) - it('uses only selected uv SBOMs and cleans them up after uploading', async () => { - let uploadRoot = '' - mockGetPackageFilesForScan.mockResolvedValue([ - '/repo/uv.lock', - '/repo/pyproject.toml', - ]) - mockFetchCreateOrgFullScan.mockImplementationOnce( - async (paths, _org, _config, options) => { - uploadRoot = options.cwd - expect(paths).toEqual([ - path.join(uploadRoot, 'socket-api-cdx.json'), - path.join(uploadRoot, 'socket-worker-cdx.json'), - ]) - expect(JSON.parse(readFileSync(paths[0], 'utf8')).package).toBe('api') - return { ok: true, data: { id: 'scan-id' } } - }, - ) - await handleCreateNewScan(createConfig({ uvPackages: ['api', 'worker'] })) - expect(mockGenerateUvPackageSboms).toHaveBeenCalledWith({ - outputDir: uploadRoot, - packageNames: ['api', 'worker'], - projectRoot: '/repo', - }) - expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() - expect(existsSync(uploadRoot)).toBe(false) - }) - - it('does not export uv packages unless explicitly requested', async () => { - mockGetPackageFilesForScan.mockResolvedValueOnce([ - '/repo/packages/api/pyproject.toml', - ]) - await handleCreateNewScan(createConfig()) - expect(mockGenerateUvPackageSboms).not.toHaveBeenCalled() - expect(mockFetchCreateOrgFullScan.mock.calls[0]?.[0]).toEqual([ - '/repo/packages/api/pyproject.toml', - ]) - }) - - it('prepares and cleans uv SBOMs in read-only mode without uploading', async () => { - await handleCreateNewScan( - createConfig({ readOnly: true, uvPackages: ['api'] }), - ) - expect(mockGenerateUvPackageSboms).toHaveBeenCalledOnce() - expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() - expect( - existsSync(mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir), - ).toBe(false) - }) - - it('cleans partial uv output and does not upload if any export fails', async () => { - let outputDir = '' - mockGenerateUvPackageSboms.mockImplementationOnce(async options => { - outputDir = options.outputDir - writeFileSync(path.join(outputDir, 'partial.json'), '{}') - throw new Error('second package failed') - }) - await expect( - handleCreateNewScan(createConfig({ uvPackages: ['api', 'worker'] })), - ).rejects.toThrow('second package failed') - expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() - expect(existsSync(outputDir)).toBe(false) - }) - - it('cleans uv output when the upload throws', async () => { - mockFetchCreateOrgFullScan.mockRejectedValueOnce(new Error('upload failed')) - await expect( - handleCreateNewScan(createConfig({ uvPackages: ['api'] })), - ).rejects.toThrow('upload failed') - expect( - existsSync(mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir), - ).toBe(false) - }) - - it('rejects other generators in uv package mode before generating files', async () => { - await expect( - handleCreateNewScan( - createConfig({ autoManifest: true, uvPackages: ['api'] }), - ), - ).rejects.toThrow('cannot be combined') - expect(mockGenerateAutoManifest).not.toHaveBeenCalled() - expect(mockGenerateUvPackageSboms).not.toHaveBeenCalled() - }) - - it('uses the same uv SBOMs for reachability and stages its report for the final scan', async () => { - const cwd = mkdtempSync(path.join(tmpdir(), 'socket-uv-reach-')) - try { - const config = createConfig({ cwd, targets: [cwd], uvPackages: ['api'] }) - config.reach.runReachabilityAnalysis = true - mockPerformReachabilityAnalysis.mockImplementationOnce(async options => { - expect(options.cwd).toBe(cwd) - expect(options.target).toBe(cwd) - expect(options.packagePaths).toEqual([ - path.join(options.manifestUploadRoot, 'socket-api-cdx.json'), - ]) - writeFileSync(path.join(cwd, '.socket.facts.json'), '{"components":[]}') - return { - ok: true, - data: { - reachabilityReport: '.socket.facts.json', - tier1ReachabilityScanId: 'tier1-id', - }, - } - }) - mockFetchCreateOrgFullScan.mockImplementationOnce( - async (paths, _org, _config, options) => { - expect(paths).toEqual([ - path.join(options.cwd, 'socket-api-cdx.json'), - path.join(options.cwd, '.socket.facts.json.br'), - ]) - expect(paths.every(existsSync)).toBe(true) - return { ok: true, data: { id: 'scan-id' } } - }, - ) - await handleCreateNewScan(config) - expect( - existsSync(mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir), - ).toBe(false) - } finally { - rmSync(cwd, { recursive: true, force: true }) - } - }) - - it('keeps the scoped uv SBOMs when reachability falls back to a regular scan', async () => { - const config = createConfig({ uvPackages: ['api'] }) - config.reach.runReachabilityAnalysis = true - config.reach.reachFallbackToRegularScan = true - mockPerformReachabilityAnalysis.mockResolvedValueOnce({ - ok: false, - message: 'analysis failed', - }) - await handleCreateNewScan(config) - const uploadRoot = mockGenerateUvPackageSboms.mock.calls[0]?.[0].outputDir - expect(mockFetchCreateOrgFullScan.mock.calls[0]?.[0]).toEqual([ - path.join(uploadRoot, 'socket-api-cdx.json'), - ]) - expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() - }) - it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index c44440ebe4..3314e1bc98 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -54,8 +54,6 @@ export type ReachabilityAnalysisOptions = { outputKind?: OutputKind | undefined outputPath?: string | undefined packagePaths: string[] - // Generated SBOMs can live outside the source tree during an upload. - manifestUploadRoot?: string | undefined reachabilityOptions: ReachabilityOptions // Resolved-paths sidecar from the auto-manifest run; passed to coana so it // reuses these paths instead of re-resolving the build. @@ -76,7 +74,6 @@ export async function performReachabilityAnalysis( const { branchName, cwd = process.cwd(), - manifestUploadRoot, orgSlug, outputKind = 'text', outputPath, @@ -137,6 +134,7 @@ export async function performReachabilityAnalysis( spinner?.start('Uploading manifests for reachability analysis...') + // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path // NOTE: previously stripped any `.socket.facts.json` from packagePaths // here to avoid uploading leftover post-reachability output. With the // producer flow (`socket manifest gradle --facts`) those files are @@ -145,7 +143,7 @@ export async function performReachabilityAnalysis( // deletion in handle-create-new-scan.mts. const uploadCResult = await handleApiCall( sockSdk.uploadManifestFiles(orgSlug, packagePaths, { - pathsRelativeTo: manifestUploadRoot ?? path.resolve(cwd, analysisTarget), + pathsRelativeTo: path.resolve(cwd, analysisTarget), }), { description: 'upload manifests', diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index 2d31616b04..bf24a6dbcb 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -220,29 +220,6 @@ describe('performReachabilityAnalysis manifests tar hash', () => { expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) - it('uploads generated SBOMs relative to their staging root while analyzing the original target', async () => { - const uploadManifestFiles = vi.fn() - mockSetupSdk.mockResolvedValueOnce({ - ok: true, - data: { uploadManifestFiles }, - }) - const packagePaths = ['/staged/socket-api-cdx.json'] - await performReachabilityAnalysis({ - cwd: scanCwd, - manifestUploadRoot: '/staged', - orgSlug: TEST_ORG_SLUG, - packagePaths, - reachabilityOptions: makeReachabilityOptions(), - target: scanCwd, - }) - expect(uploadManifestFiles).toHaveBeenCalledWith( - TEST_ORG_SLUG, - packagePaths, - { pathsRelativeTo: '/staged' }, - ) - expect(mockSpawnCoanaDlx.mock.calls[0]?.[2].cwd).toBe(scanCwd) - }) - it('fails without spawning Coana when the upload returns no tar hash', async () => { mockHandleApiCall.mockResolvedValueOnce({ ok: true, data: {} } as never) From da0e0e10cc77ee69f0f7d5146742eea6e685c81e Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 28 Sep 2026 14:54:04 +0200 Subject: [PATCH 6/7] feat(scan): add the uv member target code, tests and docs Completes the previous commit, which only picked up the renames and the reachability revert. --- CHANGELOG.md | 2 +- README.md | 59 +++--- src/commands/scan/cmd-scan-create.mts | 53 ++--- src/commands/scan/cmd-scan-create.test.mts | 51 ++--- .../generate-uv-member-sboms.e2e.test.mts | 109 +++++----- .../scan/generate-uv-member-sboms.mts | 187 +++++++++--------- .../scan/generate-uv-member-sboms.test.mts | 186 ++++++++--------- src/commands/scan/handle-create-new-scan.mts | 122 +++++------- .../scan/handle-create-new-scan.test.mts | 98 +++++++++ 9 files changed, 438 insertions(+), 429 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ce966ee4..cbe9052c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- `socket scan create --uv-package ` scans selected uv packages with locked versions and dependency relationships from their shared `uv.lock`. Dependencies used only by dependency groups are marked as development dependencies. Repeat the option to select more packages. Requires uv with CycloneDX export support. +- `socket scan create --uv-members ` scans selected uv workspace members with the versions and dependency relationships pinned in their shared `uv.lock`, leaving unrelated members out of the scan. Dependencies used only by dependency groups are marked as development dependencies. Requires uv with CycloneDX export support. ## [1.2.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.1) - 2026-09-28 diff --git a/README.md b/README.md index 7432adcebb..abeabd10b6 100644 --- a/README.md +++ b/README.md @@ -63,44 +63,37 @@ All aliases support the flags and arguments of the commands they alias. - `socket ci` - Alias for `socket scan create --report` (creates report and exits with error if unhealthy) -### Scanning selected uv packages +### Scanning uv workspace members -Use `--uv-package` to scan specific packages from a shared `uv.lock`, with -locked versions and dependency relationships preserved: +Use `--uv-members` to scan selected directories of a uv workspace with the +versions pinned in the workspace's shared `uv.lock`: ```sh -socket scan create . --uv-package api --uv-package worker +socket scan create --uv-members ./packages/api ./packages/worker ``` -Each value is a package's `project.name` from `pyproject.toml`, rather than a -directory path. Repeat the option to select more packages. TARGET must be one -project root containing both `pyproject.toml` and `uv.lock`. Use `--cwd` to run -from another directory: - -```sh -socket scan create --cwd ./python-workspace . --uv-package api -``` - -This mode requires uv on PATH with support for `uv export --format cyclonedx1.5`. -It exports a separate CycloneDX SBOM for each package, including transitive -and local workspace dependencies, all extras, and all dependency groups. -Dependencies used only by dependency groups are marked as development dependencies. -The export runs offline with `--frozen`, so it uses the existing lockfile -without resolving newer versions, installing packages, or changing the project. -uv currently treats CycloneDX export as a preview feature. - -Only the generated SBOMs are uploaded. The target supplies workspace context, -and package selection replaces regular manifest discovery and its file ignore -patterns. Unrelated manifests and the shared lockfile are not added to the scan. -Temporary SBOMs are removed after the scan, including on failure. Export errors -stop the scan. `--read-only` prepares the SBOMs without uploading them, and -`--dry-run` validates the options without running uv. - -You can combine this option with `--reach`. The same scoped SBOMs are used for -its manifest upload, while source analysis runs against TARGET and respects -reachability exclusions. `--uv-package` cannot be combined with -`--auto-manifest` or `--dynamic-sbom-inference`. Scans without `--uv-package` -keep their usual manifest discovery behavior. +Each TARGET is a directory with its own `pyproject.toml`. uv finds the +`uv.lock` in that directory or in its workspace root, so one scan can cover +members of several workspaces. The scan includes each target's transitive and +local workspace dependencies, all extras, and all dependency groups. +Dependencies used only by dependency groups are marked as development +dependencies. Unrelated members and the shared lockfile are not added to the +scan. + +This mode requires uv on PATH with support for `uv export --format cyclonedx1.5`, +which uv currently treats as a preview feature. The export runs offline with +`--frozen`, so it uses the existing lockfile without resolving newer versions, +installing packages, or changing the project. + +The CLI writes a `socket-uv-cdx.json` SBOM into each target directory, uploads +only those SBOMs in place of regular manifest discovery, and removes them after +the scan, including on failure. It stops if that file already exists. +`--read-only` prepares the SBOMs without uploading them, and `--dry-run` +validates the options without running uv. + +With `--reach`, pass a single target. Reachability analysis then runs on that +member's directory. `--uv-members` cannot be combined with `--auto-manifest` or +`--dynamic-sbom-inference`. ### Reachability analysis diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index a07eedbb28..7768cfd1df 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -5,9 +5,9 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { assertValidExcludePaths } from './exclude-paths.mts' import { - normalizeUvPackageNames, - resolveUvProjectRoot, -} from './generate-uv-package-sboms.mts' + generateUvMemberSboms, + resolveUvMemberDirs, +} from './generate-uv-member-sboms.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import { @@ -28,7 +28,6 @@ import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { determineOrgSlug } from '../../utils/determine-org-slug.mts' import { parseReachEcosystems } from '../../utils/ecosystem.mts' -import { InputError } from '../../utils/errors.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { detectDefaultBranch, @@ -177,11 +176,11 @@ const generalFlags: MeowFlags = { 'Set the visibility (true/false) of the scan in your dashboard.', shortFlag: 't', }, - uvPackage: { - type: 'string', - isMultiple: true, + uvMembers: { + type: 'boolean', + default: false, description: - 'Scan only the named uv packages from one project root using CycloneDX dependency graphs from its uv.lock. Use project.name from pyproject.toml. Repeat to select more packages. Requires uv on PATH. Includes all extras and dependency groups.', + 'Scan each TARGET directory as a uv project, using the versions pinned in its uv.lock or its workspace root uv.lock. Uploads a CycloneDX dependency graph per TARGET in place of manifest discovery, including all extras and dependency groups. Requires uv on PATH.', }, } @@ -252,7 +251,7 @@ async function run( $ ${command} $ ${command} ./proj --json $ ${command} --repo=test-repo --branch=main ./package.json - $ ${command} . --uv-package api --uv-package worker + $ ${command} --uv-members ./packages/api ./packages/worker `, } @@ -344,23 +343,7 @@ async function run( ) const dryRun = !!cli.flags['dryRun'] - const uvPackageValues = (cli.flags['uvPackage'] ?? []) as string[] - // Meow drops empty values from repeated string flags. - let uvPackageFlagCount = 0 - for (const arg of argv) { - if (arg === '--') { - break - } - if (/^--uv(?:-package|Package)(?:=|$)/.test(arg)) { - uvPackageFlagCount++ - } - } - if (uvPackageFlagCount > uvPackageValues.length) { - throw new InputError( - '--uv-package requires a package name after every occurrence.', - ) - } - const uvPackages = normalizeUvPackageNames(uvPackageValues) + const uvMembers = !!cli.flags['uvMembers'] let { autoManifest, @@ -438,7 +421,7 @@ async function run( // Accept zero or more paths. Default to cwd() if none given. let targets = cli.input.length ? cli.input : [] - if (!targets.length && !dryRun && interactive && !uvPackages.length) { + if (!targets.length && !dryRun && interactive) { targets = await suggestTarget() updatedInput = true } @@ -448,7 +431,7 @@ async function run( // because wrapPrompt swallows non-TypeError errors and returns undefined), // default to '.' so that downstream validations don't fail with confusing // "At least one TARGET (missing)" errors. - if (!targets.length && (!dryRun || uvPackages.length)) { + if (!targets.length && !dryRun) { targets = ['.'] } @@ -501,7 +484,7 @@ async function run( detected.count > 0 && !autoManifest && !dynamicSbomInference && - !uvPackages.length && + !uvMembers && !hasFactsFile ) { logger.info( @@ -599,9 +582,9 @@ async function run( }, { nook: true, - test: !uvPackages.length || (!autoManifest && !dynamicSbomInference), + test: !uvMembers || (!autoManifest && !dynamicSbomInference), message: - '--uv-package cannot be combined with --auto-manifest or --dynamic-sbom-inference', + '--uv-members cannot be combined with --auto-manifest or --dynamic-sbom-inference', fail: 'select one source of generated SBOMs', }, { @@ -666,9 +649,7 @@ async function run( return } - if (uvPackages.length) { - resolveUvProjectRoot(targets, cwd) - } + const uvMemberDirs = uvMembers ? resolveUvMemberDirs(targets, cwd) : undefined if (dryRun) { logger.log(constants.DRY_RUN_BAILING_NOW) @@ -683,6 +664,9 @@ async function run( committers: (committers && String(committers)) || '', cwd, defaultBranch: Boolean(defaultBranch), + generateScanFiles: uvMemberDirs + ? () => generateUvMemberSboms(uvMemberDirs) + : undefined, interactive: Boolean(interactive), orgSlug, outputKind, @@ -718,7 +702,6 @@ async function run( reportLevel, targets, tmp: Boolean(tmp), - uvPackages, workspace: (workspace && String(workspace)) || '', }) } diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 5f3a7de416..8d22ebf8ed 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -36,15 +36,14 @@ describe('socket scan create', async () => { ] it( - 'accepts repeated uv package selectors with an implicit root target', + 'accepts uv member directories as targets', { timeout: 30_000 }, async () => { const result = await spawnSocketCli(binCliPath, [ ...uvBaseArgs, - '--uv-package', - 'workspace-api', - '--uv-package', - 'workspace-other', + '--uv-members', + 'packages/api', + 'packages/other', ]) expect(result.code).toBe(0) expect(result.stdout).toContain('[DryRun]: Bailing now') @@ -53,47 +52,27 @@ describe('socket scan create', async () => { it.each([ { - args: ['.', '--uv-package', './packages/api'], - error: 'expects a project.name', + args: ['--uv-members', 'packages/missing'], + error: 'directory inside --cwd', }, { - args: ['.', '--uv-package='], - error: 'requires a package name after every occurrence', + args: ['--uv-members', '..'], + error: 'directory inside --cwd', }, { - args: ['.', '--uv-package'], - error: 'requires a package name after every occurrence', + args: ['--uv-members', 'packages'], + error: 'requires a pyproject.toml in every TARGET', }, { - args: ['.', '--uvPackage'], - error: 'requires a package name after every occurrence', - }, - { - args: ['.', '--uv-package', 'workspace-api', '--uv-package='], - error: 'requires a package name after every occurrence', - }, - { - args: ['.', '--uv-package', '--uv-package', 'workspace-api'], - error: 'requires a package name after every occurrence', - }, - { - args: ['.', '.', '--uv-package', 'workspace-api'], - error: 'requires exactly one uv project root', - }, - { - args: ['packages/api', '--uv-package', 'workspace-api'], - error: 'requires pyproject.toml and uv.lock', - }, - { - args: ['.', '--uv-package', 'workspace-api', '--auto-manifest'], + args: ['--uv-members', 'packages/api', '--auto-manifest'], error: 'cannot be combined', }, { - args: ['.', '--uv-package', 'workspace-api', '--dynamic-sbom-inference'], + args: ['--uv-members', 'packages/api', '--dynamic-sbom-inference'], error: 'cannot be combined', }, ])( - 'rejects invalid uv package options: $args', + 'rejects invalid uv member options: $args', { timeout: 30_000 }, async ({ args, error }) => { const result = await spawnSocketCli(binCliPath, [...uvBaseArgs, ...args]) @@ -139,7 +118,7 @@ describe('socket scan create', async () => { --report-level Which policy level alerts should be reported (default 'error') --set-as-alerts-page When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true. --tmp Set the visibility (true/false) of the scan in your dashboard. - --uv-package Scan only the named uv packages from one project root using CycloneDX dependency graphs from its uv.lock. Use project.name from pyproject.toml. Repeat to select more packages. Requires uv on PATH. Includes all extras and dependency groups. + --uv-members Scan each TARGET directory as a uv project, using the versions pinned in its uv.lock or its workspace root uv.lock. Uploads a CycloneDX dependency graph per TARGET in place of manifest discovery, including all extras and dependency groups. Requires uv on PATH. --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) @@ -196,7 +175,7 @@ describe('socket scan create', async () => { $ socket scan create $ socket scan create ./proj --json $ socket scan create --repo=test-repo --branch=main ./package.json - $ socket scan create . --uv-package api --uv-package worker" + $ socket scan create --uv-members ./packages/api ./packages/worker" `) expect(`\n ${stderr}`).toMatchInlineSnapshot(` " diff --git a/src/commands/scan/generate-uv-member-sboms.e2e.test.mts b/src/commands/scan/generate-uv-member-sboms.e2e.test.mts index 305e312bde..266a9fe95c 100644 --- a/src/commands/scan/generate-uv-member-sboms.e2e.test.mts +++ b/src/commands/scan/generate-uv-member-sboms.e2e.test.mts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { generateUvPackageSboms } from './generate-uv-package-sboms.mts' +import { generateUvMemberSboms } from './generate-uv-member-sboms.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import type { HandleCreateNewScanConfig } from './handle-create-new-scan.mts' @@ -48,7 +48,7 @@ type Sbom = { bomFormat: string metadata: { component: Component } components: Component[] - dependencies: Array<{ ref: string; dependsOn: string[] }> + dependencies: Array<{ ref: string; dependsOn?: string[] }> } function assertApiGraph(sbom: Sbom): void { @@ -88,51 +88,54 @@ function assertApiGraph(sbom: Sbom): void { const refs = new Set(components.map(c => c['bom-ref'])) for (const edge of sbom.dependencies) { expect(refs.has(edge.ref)).toBe(true) - expect(edge.dependsOn.every(ref => refs.has(ref))).toBe(true) + // uv 0.12 omits dependsOn for leaf nodes. + expect((edge.dependsOn ?? []).every(ref => refs.has(ref))).toBe(true) } } -describe('uv package scans with the real uv binary', () => { +describe('uv member scans with the real uv binary', () => { + let apiDir: string let projectRoot: string - let outputDir: string beforeEach(async () => { vi.clearAllMocks() projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-project-')) - outputDir = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-sboms-')) + apiDir = path.join(projectRoot, 'packages/api') await fs.cp(fixture, projectRoot, { recursive: true }) }) afterEach(async () => { await fs.rm(projectRoot, { recursive: true, force: true }) - await fs.rm(outputDir, { recursive: true, force: true }) }) it('preserves pinned dependency edges, extras, groups and markers without unrelated packages', async () => { const lock = await fs.readFile(path.join(projectRoot, 'uv.lock'), 'utf8') - const paths = await generateUvPackageSboms({ - outputDir, - packageNames: ['workspace-api'], - projectRoot, - }) - assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) + const { files } = await generateUvMemberSboms([apiDir]) + expect(files).toEqual([path.join(apiDir, 'socket-uv-cdx.json')]) + assertApiGraph(JSON.parse(await fs.readFile(files[0]!, 'utf8'))) expect(await fs.readFile(path.join(projectRoot, 'uv.lock'), 'utf8')).toBe( lock, ) expect(existsSync(path.join(projectRoot, '.venv'))).toBe(false) + expect(existsSync(path.join(apiDir, '.venv'))).toBe(false) }) - it('exports distinct package roots and graphs when multiple packages are requested', async () => { - const paths = await generateUvPackageSboms({ - outputDir, - packageNames: ['workspace-api', 'workspace-other'], - projectRoot, - }) - expect(paths).toHaveLength(2) - assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) - const other = JSON.parse(await fs.readFile(paths[1]!, 'utf8')) as Sbom + it('exports distinct package roots and graphs when multiple members are requested', async () => { + const { files } = await generateUvMemberSboms([ + apiDir, + path.join(projectRoot, 'packages/other'), + ]) + expect(files).toHaveLength(2) + assertApiGraph(JSON.parse(await fs.readFile(files[0]!, 'utf8'))) + const other = JSON.parse(await fs.readFile(files[1]!, 'utf8')) as Sbom expect(other.metadata.component.name).toBe('workspace-other') - expect(other.components.map(c => c.name)).toEqual(['sniffio']) + expect( + other.components.filter(c => c.scope === 'required').map(c => c.name), + ).toEqual(['sniffio']) + // uv 0.12 adds the workspace root's dependency groups to this member. + for (const name of ['colorama', 'idna', 'iniconfig']) { + expect(other.components.map(c => c.name)).not.toContain(name) + } }) it('uses the existing pins when the member allows a newer version', async () => { @@ -141,12 +144,8 @@ describe('uv package scans with the real uv binary', () => { manifest, (await fs.readFile(manifest, 'utf8')).replace('idna==3.10', 'idna>=3'), ) - const paths = await generateUvPackageSboms({ - outputDir, - packageNames: ['workspace-api'], - projectRoot, - }) - assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) + const { files } = await generateUvMemberSboms([apiDir]) + assertApiGraph(JSON.parse(await fs.readFile(files[0]!, 'utf8'))) }) it('classifies transitive group dependencies and keeps shared production dependencies required', async () => { @@ -170,12 +169,8 @@ describe('uv package scans with the real uv binary', () => { 'dev = [\n { name = "iniconfig" },\n { name = "idna" },\n { name = "workspace-other" },\n]\nqa = [{ name = "packaging" }]', ), ) - const [filename] = await generateUvPackageSboms({ - outputDir, - packageNames: ['workspace-api'], - projectRoot, - }) - const sbom = JSON.parse(await fs.readFile(filename!, 'utf8')) as Sbom + const { files } = await generateUvMemberSboms([apiDir]) + const sbom = JSON.parse(await fs.readFile(files[0]!, 'utf8')) as Sbom expect( Object.fromEntries(sbom.components.map(c => [c.name, c.scope])), ).toEqual({ @@ -191,27 +186,32 @@ describe('uv package scans with the real uv binary', () => { }) }) - it('rejects unknown packages and does not fall back to the workspace root', async () => { - await expect( - generateUvPackageSboms({ - outputDir, - packageNames: ['does-not-exist'], - projectRoot, - }), - ).rejects.toThrow('Could not export uv package "does-not-exist"') + it('rejects a project outside any uv workspace without writing an SBOM', async () => { + const loneDir = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-lone-')) + try { + await fs.writeFile( + path.join(loneDir, 'pyproject.toml'), + '[project]\nname = "lone"\nversion = "0.1.0"\ndependencies = []\n', + ) + await expect(generateUvMemberSboms([loneDir])).rejects.toMatchObject({ + message: expect.stringContaining( + `Could not export the uv dependency graph for ${loneDir}`, + ), + body: expect.stringContaining('uv.lock'), + }) + expect(existsSync(path.join(loneDir, 'socket-uv-cdx.json'))).toBe(false) + } finally { + await fs.rm(loneDir, { recursive: true, force: true }) + } }) - it('passes only the scoped graph to the SDK and cleans up after upload', async () => { - let uploadRoot = '' + it('passes only the member graph to the SDK and cleans up after upload', async () => { + const sbomPath = path.join(apiDir, 'socket-uv-cdx.json') mockCreateFullScan.mockImplementationOnce( async (_org, paths: string[], options) => { - uploadRoot = options.pathsRelativeTo - expect(paths).toEqual([ - path.join(uploadRoot, 'socket-workspace-api-cdx.json'), - ]) + expect(options.pathsRelativeTo).toBe(projectRoot) + expect(paths).toEqual([sbomPath]) assertApiGraph(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))) - expect(existsSync(path.join(uploadRoot, 'uv.lock'))).toBe(false) - expect(existsSync(path.join(uploadRoot, 'pyproject.toml'))).toBe(false) return { success: true, status: 200, data: { id: 'test-scan' } } }, ) @@ -223,6 +223,7 @@ describe('uv package scans with the real uv binary', () => { committers: '', cwd: projectRoot, defaultBranch: false, + generateScanFiles: () => generateUvMemberSboms([apiDir]), interactive: false, orgSlug: 'test-org', outputKind: 'text', @@ -256,13 +257,11 @@ describe('uv package scans with the real uv binary', () => { repoName: 'test-repo', report: false, reportLevel: 'error', - targets: ['.'], + targets: ['packages/api'], tmp: true, - uvPackages: ['workspace-api'], } await handleCreateNewScan(config) expect(mockCreateFullScan).toHaveBeenCalledOnce() - expect(uploadRoot).not.toBe('') - expect(existsSync(uploadRoot)).toBe(false) + expect(existsSync(sbomPath)).toBe(false) }) }) diff --git a/src/commands/scan/generate-uv-member-sboms.mts b/src/commands/scan/generate-uv-member-sboms.mts index 5fc21bc21c..036c746963 100644 --- a/src/commands/scan/generate-uv-member-sboms.mts +++ b/src/commands/scan/generate-uv-member-sboms.mts @@ -8,6 +8,10 @@ import { spawn } from '@socketsecurity/registry/lib/spawn' import constants from '../../constants.mts' import { InputError, getErrorMessage } from '../../utils/errors.mts' +import type { GeneratedScanFiles } from './handle-create-new-scan.mts' + +const UV_SBOM_FILENAME = 'socket-uv-cdx.json' + type Component = { name: string version?: string @@ -22,36 +26,27 @@ type Sbom = { metadata?: { component?: { name?: string } } } -function normalizePackageName(name: string): string { - return name.toLowerCase().replaceAll(/[._-]+/g, '-') +function componentIdentity(component: Component): string { + return JSON.stringify([component.name, component.version, component.purl]) } -function parseSbom(content: string, packageName: string): Sbom { - try { - const sbom = JSON.parse(content) as Sbom - const rootName = sbom?.metadata?.component?.name - if ( - sbom?.bomFormat === 'CycloneDX' && - Array.isArray(sbom.dependencies) && - (sbom.components === undefined || Array.isArray(sbom.components)) && - typeof rootName === 'string' && - normalizePackageName(rootName) === packageName - ) { - return sbom - } - } catch {} - throw new InputError( - `uv did not return a CycloneDX dependency graph rooted at "${packageName}". Update uv and try again.`, +async function exportMemberSbom(memberDir: string): Promise { + const sbom = await exportSbom(memberDir, '--all-groups') + const productionSbom = await exportSbom(memberDir, '--no-default-groups') + // uv can renumber bom-ref values between exports. + const productionPackages = new Set( + productionSbom.components?.map(componentIdentity), ) -} - -function componentIdentity(component: Component): string { - return JSON.stringify([component.name, component.version, component.purl]) + for (const component of sbom.components ?? []) { + component.scope = productionPackages.has(componentIdentity(component)) + ? 'required' + : 'optional' + } + return sbom } async function exportSbom( - projectRoot: string, - packageName: string, + memberDir: string, groups: '--all-groups' | '--no-default-groups', ): Promise { let content: string @@ -61,9 +56,7 @@ async function exportSbom( [ 'export', '--project', - projectRoot, - '--package', - packageName, + memberDir, '--format', 'cyclonedx1.5', '--frozen', @@ -73,7 +66,7 @@ async function exportSbom( groups, ], { - cwd: projectRoot, + cwd: memberDir, signal: constants.abortSignal, stdio: 'pipe', }, @@ -81,91 +74,87 @@ async function exportSbom( content = stdout } catch (e) { throw new InputError( - `Could not export uv package "${packageName}" from ${projectRoot}. Install uv on PATH with CycloneDX export support and check that this package is in the shared uv.lock.`, + `Could not export the uv dependency graph for ${memberDir}. Install uv on PATH with CycloneDX export support and check that the directory is a uv project with a uv.lock in it or in its workspace root.`, e && typeof e === 'object' && 'stderr' in e ? String(e.stderr).trim() || getErrorMessage(e) : getErrorMessage(e), ) } - return parseSbom(content, packageName) + return parseSbom(content, memberDir) } -export async function generateUvPackageSboms({ - outputDir, - packageNames, - projectRoot, -}: { - outputDir: string - packageNames: string[] - projectRoot: string -}): Promise { - const paths: string[] = [] - for (const packageName of normalizeUvPackageNames(packageNames)) { - logger.info(`Exporting the uv dependency graph for ${packageName}...`) - // eslint-disable-next-line no-await-in-loop - const sbom = await exportSbom(projectRoot, packageName, '--all-groups') - // eslint-disable-next-line no-await-in-loop - const productionSbom = await exportSbom( - projectRoot, - packageName, - '--no-default-groups', - ) - // uv can renumber bom-ref values between exports. - const productionPackages = new Set( - productionSbom.components?.map(componentIdentity), - ) - for (const component of sbom.components ?? []) { - component.scope = productionPackages.has(componentIdentity(component)) - ? 'required' - : 'optional' +function parseSbom(content: string, memberDir: string): Sbom { + try { + const sbom = JSON.parse(content) as Sbom + if ( + sbom?.bomFormat === 'CycloneDX' && + Array.isArray(sbom.dependencies) && + (sbom.components === undefined || Array.isArray(sbom.components)) && + typeof sbom.metadata?.component?.name === 'string' + ) { + return sbom } - // Keep workspace-relative paths in the SBOM relative to the upload root. - const filename = path.join(outputDir, `socket-${packageName}-cdx.json`) - // eslint-disable-next-line no-await-in-loop - await fs.mkdir(outputDir, { recursive: true }) + } catch {} + throw new InputError( + `uv did not return a CycloneDX dependency graph for ${memberDir}. Update uv and try again.`, + ) +} + +export async function generateUvMemberSboms( + memberDirs: string[], +): Promise { + const sboms: Sbom[] = [] + for (const memberDir of memberDirs) { + logger.info(`Exporting the uv dependency graph for ${memberDir}...`) // eslint-disable-next-line no-await-in-loop - await fs.writeFile(filename, JSON.stringify(sbom)) - paths.push(filename) + sboms.push(await exportMemberSbom(memberDir)) + } + const files: string[] = [] + const cleanup = async () => { + await Promise.all(files.map(file => fs.rm(file, { force: true }))) } - return paths + try { + for (let i = 0; i < memberDirs.length; i += 1) { + // The SBOM sits where the member's own manifest would, so upload paths + // and the reachability target line up with the member directory. + const filename = path.join(memberDirs[i]!, UV_SBOM_FILENAME) + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(filename, JSON.stringify(sboms[i]), { flag: 'wx' }) + files.push(filename) + } + } catch (e) { + await cleanup() + throw e + } + return { cleanup, files } } -export function normalizeUvPackageNames(values: readonly string[]): string[] { - for (const value of values) { - if (!/^[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i.test(value)) { +export function resolveUvMemberDirs(targets: string[], cwd: string): string[] { + const memberDirs = new Set() + for (const target of targets) { + const memberDir = path.resolve(cwd, target) + const relativeDir = path.relative(cwd, memberDir) + if ( + relativeDir === '..' || + relativeDir.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDir) || + !isDirSync(memberDir) + ) { throw new InputError( - '--uv-package expects a project.name from pyproject.toml, such as "api". Repeat the flag to select more packages.', + `--uv-members requires every TARGET to be a directory inside --cwd, but got ${target}`, ) } + if (!existsSync(path.join(memberDir, 'pyproject.toml'))) { + throw new InputError( + `--uv-members requires a pyproject.toml in every TARGET, but ${target} has none`, + ) + } + if (existsSync(path.join(memberDir, UV_SBOM_FILENAME))) { + throw new InputError( + `${path.join(target, UV_SBOM_FILENAME)} already exists. Remove it and run the scan again.`, + ) + } + memberDirs.add(memberDir) } - return Array.from(new Set(values.map(normalizePackageName))) -} - -export function resolveUvProjectRoot(targets: string[], cwd: string): string { - if (targets.length !== 1) { - throw new InputError( - '--uv-package requires exactly one uv project root as TARGET', - ) - } - const projectRoot = path.resolve(cwd, targets[0]!) - const relativeRoot = path.relative(cwd, projectRoot) - if ( - relativeRoot === '..' || - relativeRoot.startsWith(`..${path.sep}`) || - path.isAbsolute(relativeRoot) || - !isDirSync(projectRoot) - ) { - throw new InputError( - '--uv-package requires a target directory inside --cwd', - ) - } - if ( - !existsSync(path.join(projectRoot, 'pyproject.toml')) || - !existsSync(path.join(projectRoot, 'uv.lock')) - ) { - throw new InputError( - '--uv-package requires pyproject.toml and uv.lock in the target directory', - ) - } - return projectRoot + return Array.from(memberDirs) } diff --git a/src/commands/scan/generate-uv-member-sboms.test.mts b/src/commands/scan/generate-uv-member-sboms.test.mts index 2e02109b3b..7467788bbf 100644 --- a/src/commands/scan/generate-uv-member-sboms.test.mts +++ b/src/commands/scan/generate-uv-member-sboms.test.mts @@ -1,14 +1,13 @@ -import { promises as fs } from 'node:fs' +import { existsSync, promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - generateUvPackageSboms, - normalizeUvPackageNames, - resolveUvProjectRoot, -} from './generate-uv-package-sboms.mts' + generateUvMemberSboms, + resolveUvMemberDirs, +} from './generate-uv-member-sboms.mts' const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })) @@ -22,31 +21,31 @@ const sbom = JSON.stringify({ dependencies: [{ ref: 'api', dependsOn: ['idna'] }], }) -describe('uv package SBOM export', () => { - let projectRoot: string - let outputDir: string +describe('uv member SBOM export', () => { + let cwd: string + let apiDir: string + let workerDir: string beforeEach(async () => { vi.clearAllMocks() - projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-unit-')) - outputDir = path.join(projectRoot, 'output') - await fs.writeFile(path.join(projectRoot, 'pyproject.toml'), '') - await fs.writeFile(path.join(projectRoot, 'uv.lock'), '') + cwd = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-unit-')) + apiDir = path.join(cwd, 'packages/api') + workerDir = path.join(cwd, 'packages/worker') + await fs.mkdir(apiDir, { recursive: true }) + await fs.mkdir(workerDir, { recursive: true }) + await fs.writeFile(path.join(apiDir, 'pyproject.toml'), '') + await fs.writeFile(path.join(workerDir, 'pyproject.toml'), '') mockSpawn.mockResolvedValue({ stdout: sbom }) }) afterEach(async () => { - await fs.rm(projectRoot, { recursive: true, force: true }) + await fs.rm(cwd, { recursive: true, force: true }) }) - it('preserves the graph and marks production dependencies as required', async () => { - const paths = await generateUvPackageSboms({ - outputDir, - packageNames: ['api'], - projectRoot, - }) - expect(paths).toEqual([path.join(outputDir, 'socket-api-cdx.json')]) - expect(JSON.parse(await fs.readFile(paths[0]!, 'utf8'))).toEqual({ + it('writes the graph beside the member and marks production dependencies as required', async () => { + const { files } = await generateUvMemberSboms([apiDir]) + expect(files).toEqual([path.join(apiDir, 'socket-uv-cdx.json')]) + expect(JSON.parse(await fs.readFile(files[0]!, 'utf8'))).toEqual({ ...JSON.parse(sbom), components: [ { name: 'idna', version: '3.10', 'bom-ref': 'idna', scope: 'required' }, @@ -59,9 +58,7 @@ describe('uv package SBOM export', () => { [ 'export', '--project', - projectRoot, - '--package', - 'api', + apiDir, '--format', 'cyclonedx1.5', '--frozen', @@ -70,16 +67,27 @@ describe('uv package SBOM export', () => { '--all-extras', '--all-groups', ], - expect.objectContaining({ cwd: projectRoot, stdio: 'pipe' }), + expect.objectContaining({ cwd: apiDir, stdio: 'pipe' }), ) expect(mockSpawn).toHaveBeenNthCalledWith( 2, 'uv', [...mockSpawn.mock.calls[0]![1].slice(0, -1), '--no-default-groups'], - expect.objectContaining({ cwd: projectRoot, stdio: 'pipe' }), + expect.objectContaining({ cwd: apiDir, stdio: 'pipe' }), ) }) + it('removes every written SBOM on cleanup', async () => { + const { cleanup, files } = await generateUvMemberSboms([apiDir, workerDir]) + expect(files).toEqual([ + path.join(apiDir, 'socket-uv-cdx.json'), + path.join(workerDir, 'socket-uv-cdx.json'), + ]) + expect(files.every(existsSync)).toBe(true) + await cleanup() + expect(files.some(existsSync)).toBe(false) + }) + it('matches package identities across exports without changing edges or metadata', async () => { const graph = { ...JSON.parse(sbom), @@ -111,12 +119,8 @@ describe('uv package SBOM export', () => { ], }), }) - const [filename] = await generateUvPackageSboms({ - outputDir, - packageNames: ['api'], - projectRoot, - }) - expect(JSON.parse(await fs.readFile(filename!, 'utf8'))).toEqual({ + const { files } = await generateUvMemberSboms([apiDir]) + expect(JSON.parse(await fs.readFile(files[0]!, 'utf8'))).toEqual({ ...graph, components: graph.components.map((component, index) => ({ ...component, @@ -133,110 +137,90 @@ describe('uv package SBOM export', () => { dependencies: [], }), }) - const [filename] = await generateUvPackageSboms({ - outputDir, - packageNames: ['api'], - projectRoot, - }) - expect(JSON.parse(await fs.readFile(filename!, 'utf8')).components).toEqual( + const { files } = await generateUvMemberSboms([apiDir]) + expect(JSON.parse(await fs.readFile(files[0]!, 'utf8')).components).toEqual( [{ name: 'idna', version: '3.10', 'bom-ref': 'idna', scope: 'optional' }], ) }) it.each(['invalid graph', 'export failure'])( - 'does not write an SBOM when the production export has an %s', + 'writes no SBOM for any member when a later export has an %s', async failure => { - mockSpawn.mockResolvedValueOnce({ stdout: sbom }) + mockSpawn + .mockResolvedValueOnce({ stdout: sbom }) + .mockResolvedValueOnce({ stdout: sbom }) + .mockResolvedValueOnce({ stdout: sbom }) if (failure === 'invalid graph') { mockSpawn.mockResolvedValueOnce({ stdout: '{}' }) } else { mockSpawn.mockRejectedValueOnce(new Error('export failed')) } - await expect( - generateUvPackageSboms({ - outputDir, - packageNames: ['api'], - projectRoot, - }), - ).rejects.toThrow() - await expect( - fs.stat(path.join(outputDir, 'socket-api-cdx.json')), - ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(generateUvMemberSboms([apiDir, workerDir])).rejects.toThrow() + expect(existsSync(path.join(apiDir, 'socket-uv-cdx.json'))).toBe(false) + expect(existsSync(path.join(workerDir, 'socket-uv-cdx.json'))).toBe(false) }, ) - it('normalizes and deduplicates package names', () => { - expect(normalizeUvPackageNames(['My_API', 'my.api', 'other'])).toEqual([ - 'my-api', - 'other', - ]) - expect(normalizeUvPackageNames([])).toEqual([]) - }) - - it.each([ - '', - './packages/api', - '../api', - '--all-packages', - 'api,worker', - '*', - 'api/worker', - ])('rejects invalid package selector %j', value => { - expect(() => normalizeUvPackageNames([value])).toThrow('project.name') + it('removes SBOMs it already wrote when a later write fails', async () => { + await fs.writeFile(path.join(workerDir, 'socket-uv-cdx.json'), 'user file') + await expect(generateUvMemberSboms([apiDir, workerDir])).rejects.toThrow() + expect(existsSync(path.join(apiDir, 'socket-uv-cdx.json'))).toBe(false) + expect( + await fs.readFile(path.join(workerDir, 'socket-uv-cdx.json'), 'utf8'), + ).toBe('user file') }) it.each([ 'not JSON', '{}', '{"bomFormat":"CycloneDX","metadata":{"component":{"name":"api"}}}', - sbom.replace('"name":"api"', '"name":"wrong-root"'), - ])('rejects an invalid or incorrectly scoped SBOM', async stdout => { + '{"bomFormat":"CycloneDX","dependencies":[]}', + ])('rejects an invalid SBOM', async stdout => { mockSpawn.mockResolvedValueOnce({ stdout }) - await expect( - generateUvPackageSboms({ outputDir, packageNames: ['api'], projectRoot }), - ).rejects.toThrow('dependency graph rooted at "api"') - await expect( - fs.stat(path.join(outputDir, 'socket-api-cdx.json')), - ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(generateUvMemberSboms([apiDir])).rejects.toThrow( + `uv did not return a CycloneDX dependency graph for ${apiDir}`, + ) + expect(existsSync(path.join(apiDir, 'socket-uv-cdx.json'))).toBe(false) }) it.each([ - 'No workspace member named api', + 'Unable to find lockfile at `uv.lock`', 'Unsupported lockfile version', 'uv is not installed', ])('reports an export failure: %s', async stderr => { mockSpawn.mockRejectedValueOnce( Object.assign(new Error('command failed'), { stderr }), ) - await expect( - generateUvPackageSboms({ outputDir, packageNames: ['api'], projectRoot }), - ).rejects.toMatchObject({ - message: expect.stringContaining('Could not export uv package "api"'), + await expect(generateUvMemberSboms([apiDir])).rejects.toMatchObject({ + message: expect.stringContaining( + `Could not export the uv dependency graph for ${apiDir}`, + ), body: stderr, }) }) - it('resolves a single project root relative to cwd', () => { - expect(resolveUvProjectRoot(['.'], projectRoot)).toBe(projectRoot) - expect(resolveUvProjectRoot([projectRoot], projectRoot)).toBe(projectRoot) + it('resolves member directories relative to cwd and deduplicates them', () => { + expect( + resolveUvMemberDirs( + ['packages/api', './packages/api/', apiDir, 'packages/worker'], + cwd, + ), + ).toEqual([apiDir, workerDir]) }) - it.each([[], ['.', '.'], ['pyproject.toml'], ['missing'], ['..']])( - 'rejects invalid project roots %j', - (...targets) => { - expect(() => resolveUvProjectRoot(targets, projectRoot)).toThrow( - '--uv-package requires', - ) - }, - ) + it.each([ + ['packages/missing', 'directory inside --cwd'], + ['packages/api/pyproject.toml', 'directory inside --cwd'], + ['..', 'directory inside --cwd'], + ['packages', 'requires a pyproject.toml in every TARGET'], + ])('rejects target %j', (target, error) => { + expect(() => resolveUvMemberDirs([target], cwd)).toThrow(error) + }) - it.each(['pyproject.toml', 'uv.lock'])( - 'requires %s at the target root', - async filename => { - await fs.unlink(path.join(projectRoot, filename)) - expect(() => resolveUvProjectRoot(['.'], projectRoot)).toThrow( - 'requires pyproject.toml and uv.lock', - ) - }, - ) + it('refuses to overwrite an existing SBOM in a member directory', async () => { + await fs.writeFile(path.join(apiDir, 'socket-uv-cdx.json'), '{}') + expect(() => resolveUvMemberDirs(['packages/api'], cwd)).toThrow( + 'already exists', + ) + }) }) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index c2493b96ca..3a19b6a220 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -1,4 +1,4 @@ -import { copyFile, unlink } from 'node:fs/promises' +import { unlink } from 'node:fs/promises' import path from 'node:path' import micromatch from 'micromatch' @@ -11,10 +11,6 @@ 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 { - generateUvPackageSboms, - resolveUvProjectRoot, -} from './generate-uv-package-sboms.mts' import { handleScanReport } from './handle-scan-report.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' @@ -26,7 +22,6 @@ import { snapshotSocketFacts, } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' -import { InputError } from '../../utils/errors.mts' import { withTmpDir } from '../../utils/fs.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' @@ -78,6 +73,11 @@ function filterToPregeneratedSboms( ) } +export type GeneratedScanFiles = { + cleanup: () => Promise + files: string[] +} + export type HandleCreateNewScanConfig = { autoManifest: boolean branchName: string @@ -86,6 +86,8 @@ export type HandleCreateNewScanConfig = { committers: string cwd: string defaultBranch: boolean + // Supplies the complete scan input and replaces manifest discovery. + generateScanFiles?: (() => Promise) | undefined interactive: boolean orgSlug: string pendingHead: boolean @@ -103,42 +105,35 @@ export type HandleCreateNewScanConfig = { reportLevel: REPORT_LEVEL targets: string[] tmp: boolean - uvPackages?: string[] | undefined workspace?: string | undefined } -export async function handleCreateNewScan({ - autoManifest, - branchName, - commitHash, - commitMessage, - committers, - cwd, - defaultBranch, - interactive, - orgSlug, - outputKind, - pendingHead, - pullRequest, - reach, - readOnly, - repoName, - report, - reportLevel, - targets, - tmp, - uvPackages = [], - workspace, -}: HandleCreateNewScanConfig): Promise { +async function createNewScan( + { + autoManifest, + branchName, + commitHash, + commitMessage, + committers, + cwd, + defaultBranch, + interactive, + orgSlug, + outputKind, + pendingHead, + pullRequest, + reach, + readOnly, + repoName, + report, + reportLevel, + targets, + tmp, + workspace, + }: HandleCreateNewScanConfig, + scanFiles: string[] | undefined, +): Promise { let scanTargets = targets - if (uvPackages.length && (autoManifest || reach.dynamicSbomInference)) { - throw new InputError( - '--uv-package cannot be combined with --auto-manifest or --dynamic-sbom-inference', - ) - } - const uvProjectRoot = uvPackages.length - ? resolveUvProjectRoot(targets, cwd) - : undefined debugFn( 'notice', @@ -254,11 +249,7 @@ export async function handleCreateNewScan({ `Fetched ${supportedFilesCResult.data['size']} supported file types`, ) - spinner.start( - uvProjectRoot - ? 'Exporting selected uv packages...' - : 'Searching for local files to include in scan...', - ) + spinner.start('Searching for local files to include in scan...') const supportedFiles = supportedFilesCResult.data @@ -275,17 +266,13 @@ export async function handleCreateNewScan({ target: targets[0]!, }) - const packagePaths = uvProjectRoot - ? await generateUvPackageSboms({ - outputDir: manifestTmpDir, - packageNames: uvPackages, - projectRoot: uvProjectRoot, - }) - : await getPackageFilesForScan(scanTargets, supportedFiles, { - additionalIgnores: additionalScaIgnores, - config: socketConfig, - cwd, - }) + const packagePaths = + scanFiles ?? + (await getPackageFilesForScan(scanTargets, supportedFiles, { + additionalIgnores: additionalScaIgnores, + config: socketConfig, + cwd, + })) spinner.successAndStop( `Found ${packagePaths.length} ${pluralize('file', packagePaths.length)} to include in scan.`, @@ -341,7 +328,6 @@ export async function handleCreateNewScan({ orgSlug, outputKind, packagePaths, - ...(uvProjectRoot ? { manifestUploadRoot: manifestTmpDir } : {}), reachabilityOptions: mergedReachabilityOptions, repoName, resolvedPathsSidecar, @@ -397,19 +383,6 @@ export async function handleCreateNewScan({ } } - // Keep the reachability report beside the exported SBOMs for upload. - // Coana still runs against the original source directory. - if (uvProjectRoot && reachabilityReport && !reachabilityFallback) { - const stagedReport = path.join( - manifestTmpDir, - constants.DOT_SOCKET_DOT_FACTS_JSON, - ) - await copyFile(path.resolve(cwd, reachabilityReport), stagedReport) - scanPaths = scanPaths.map(p => - p === reachabilityReport ? stagedReport : p, - ) - } - // Brotli-compress any .socket.facts.json paths in scanPaths just before // upload. depscan's api-v0 multipart boundary streams brotli decode based // on the .br filename suffix. Coana keeps writing plain .socket.facts.json @@ -436,7 +409,7 @@ export async function handleCreateNewScan({ workspace, }, { - cwd: uvProjectRoot ? manifestTmpDir : cwd, + cwd, defaultBranch, pendingHead, tmp, @@ -532,3 +505,14 @@ export async function handleCreateNewScan({ } }) } + +export async function handleCreateNewScan( + config: HandleCreateNewScanConfig, +): Promise { + const generated = await config.generateScanFiles?.() + try { + await createNewScan(config, generated?.files) + } finally { + await generated?.cleanup() + } +} diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 13652875b5..7a750749a2 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -165,6 +165,104 @@ describe('handleCreateNewScan excludePaths', () => { mockReadOrDefaultSocketJson.mockReturnValue({}) }) + it('uploads only generated scan files and cleans them up after uploading', async () => { + const cleanup = vi.fn() + const files = [ + '/repo/packages/api/socket-uv-cdx.json', + '/repo/packages/worker/socket-uv-cdx.json', + ] + mockFetchCreateOrgFullScan.mockImplementationOnce(async () => { + expect(cleanup).not.toHaveBeenCalled() + return { ok: true, data: { id: 'scan-id' } } + }) + await handleCreateNewScan( + createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + targets: ['packages/api', 'packages/worker'], + }), + ) + expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + files, + 'fakeOrg', + expect.anything(), + expect.objectContaining({ cwd: '/repo' }), + ) + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('cleans up generated scan files in read-only mode without uploading', async () => { + const cleanup = vi.fn() + await handleCreateNewScan( + createConfig({ + generateScanFiles: async () => ({ cleanup, files: ['/repo/a.json'] }), + readOnly: true, + }), + ) + expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('cleans up generated scan files when the upload throws', async () => { + const cleanup = vi.fn() + mockFetchCreateOrgFullScan.mockRejectedValueOnce(new Error('upload failed')) + await expect( + handleCreateNewScan( + createConfig({ + generateScanFiles: async () => ({ cleanup, files: ['/repo/a.json'] }), + }), + ), + ).rejects.toThrow('upload failed') + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('stops before discovery and upload when generating scan files fails', async () => { + await expect( + handleCreateNewScan( + createConfig({ + generateScanFiles: async () => { + throw new Error('export failed') + }, + }), + ), + ).rejects.toThrow('export failed') + expect(mockFetchSupportedScanFileNames).not.toHaveBeenCalled() + expect(mockGetPackageFilesForScan).not.toHaveBeenCalled() + expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() + }) + + it('analyzes generated scan files against the target with the default upload roots', async () => { + const cleanup = vi.fn() + const files = ['/repo/packages/api/socket-uv-cdx.json'] + const config = createConfig({ + generateScanFiles: async () => ({ cleanup, files }), + targets: ['packages/api'], + }) + config.reach.runReachabilityAnalysis = true + mockPerformReachabilityAnalysis.mockResolvedValueOnce({ + data: { + reachabilityReport: 'packages/api/.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + await handleCreateNewScan(config) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/repo', + packagePaths: files, + target: 'packages/api', + }), + ) + expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( + [...files, 'packages/api/.socket.facts.json'], + 'fakeOrg', + expect.anything(), + expect.objectContaining({ cwd: '/repo' }), + ) + expect(cleanup).toHaveBeenCalledOnce() + }) + it('includes generated auto-manifest files in SCA discovery targets', async () => { mockGenerateAutoManifest.mockResolvedValueOnce({ generatedFiles: ['/repo/.socket-auto-manifest/maven_install.json'], From f15b699ca259517f829f7a410e931a51ffd70261 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 28 Sep 2026 15:36:55 +0200 Subject: [PATCH 7/7] fix(scan): remove uv member SBOMs when a scan is interrupted The launcher SIGKILLs a signalled scan after a short grace period, and a signal or process.exit() skips finally blocks, so Ctrl-C or a CI cancel left socket-uv-cdx.json in the member directory. A later regular scan would then pick it up as a pre-generated SBOM. Written SBOMs are now also removed synchronously on exit, SIGHUP, SIGINT and SIGTERM. The real-uv e2e tests get a 30s timeout, since each one runs uv twice per member and the handler test sometimes passed 5s. --- README.md | 3 ++- .../generate-uv-member-sboms.e2e.test.mts | 2 +- .../scan/generate-uv-member-sboms.mts | 19 +++++++++++++++++- .../scan/generate-uv-member-sboms.test.mts | 20 +++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index abeabd10b6..f62e4e5b91 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,8 @@ installing packages, or changing the project. The CLI writes a `socket-uv-cdx.json` SBOM into each target directory, uploads only those SBOMs in place of regular manifest discovery, and removes them after -the scan, including on failure. It stops if that file already exists. +the scan, including on failure, Ctrl-C, or SIGTERM. It stops if that file +already exists. `--read-only` prepares the SBOMs without uploading them, and `--dry-run` validates the options without running uv. diff --git a/src/commands/scan/generate-uv-member-sboms.e2e.test.mts b/src/commands/scan/generate-uv-member-sboms.e2e.test.mts index 266a9fe95c..1c466eae57 100644 --- a/src/commands/scan/generate-uv-member-sboms.e2e.test.mts +++ b/src/commands/scan/generate-uv-member-sboms.e2e.test.mts @@ -93,7 +93,7 @@ function assertApiGraph(sbom: Sbom): void { } } -describe('uv member scans with the real uv binary', () => { +describe('uv member scans with the real uv binary', { timeout: 30_000 }, () => { let apiDir: string let projectRoot: string diff --git a/src/commands/scan/generate-uv-member-sboms.mts b/src/commands/scan/generate-uv-member-sboms.mts index 036c746963..e01f2bc4b6 100644 --- a/src/commands/scan/generate-uv-member-sboms.mts +++ b/src/commands/scan/generate-uv-member-sboms.mts @@ -1,4 +1,4 @@ -import { existsSync, promises as fs } from 'node:fs' +import { existsSync, promises as fs, rmSync } from 'node:fs' import path from 'node:path' import { isDirSync } from '@socketsecurity/registry/lib/fs' @@ -10,6 +10,8 @@ import { InputError, getErrorMessage } from '../../utils/errors.mts' import type { GeneratedScanFiles } from './handle-create-new-scan.mts' +const CLEANUP_SIGNALS: NodeJS.Signals[] = ['SIGHUP', 'SIGINT', 'SIGTERM'] + const UV_SBOM_FILENAME = 'socket-uv-cdx.json' type Component = { @@ -110,7 +112,22 @@ export async function generateUvMemberSboms( sboms.push(await exportMemberSbom(memberDir)) } const files: string[] = [] + const removeFilesSync = () => { + for (const file of files) { + rmSync(file, { force: true }) + } + } + // A signalled or exiting scan skips finally blocks, and the bin launcher + // SIGKILLs a signalled scan after a short grace period. + process.once('exit', removeFilesSync) + for (const signal of CLEANUP_SIGNALS) { + process.once(signal, removeFilesSync) + } const cleanup = async () => { + process.removeListener('exit', removeFilesSync) + for (const signal of CLEANUP_SIGNALS) { + process.removeListener(signal, removeFilesSync) + } await Promise.all(files.map(file => fs.rm(file, { force: true }))) } try { diff --git a/src/commands/scan/generate-uv-member-sboms.test.mts b/src/commands/scan/generate-uv-member-sboms.test.mts index 7467788bbf..a8d6a4afb3 100644 --- a/src/commands/scan/generate-uv-member-sboms.test.mts +++ b/src/commands/scan/generate-uv-member-sboms.test.mts @@ -88,6 +88,26 @@ describe('uv member SBOM export', () => { expect(files.some(existsSync)).toBe(false) }) + it.each(['exit', 'SIGHUP', 'SIGINT', 'SIGTERM'])( + 'removes written SBOMs synchronously on %s', + async event => { + const once = vi.spyOn(process, 'once') + try { + const { cleanup, files } = await generateUvMemberSboms([apiDir]) + const call = once.mock.calls.find(({ 0: name }) => name === event) + expect(call).toBeDefined() + ;(call![1] as () => void)() + expect(files.some(existsSync)).toBe(false) + await cleanup() + expect(process.listeners(event as NodeJS.Signals)).not.toContain( + call![1], + ) + } finally { + once.mockRestore() + } + }, + ) + it('matches package identities across exports without changing edges or metadata', async () => { const graph = { ...JSON.parse(sbom),