Skip to content

Commit 9d721ef

Browse files
authored
feat(scan): scan selected uv workspace members with dependency graphs (#1565)
* feat(scan): scan selected uv packages with dependency graphs * docs(scan): remove stale and repeated upload comments * fix(scan): preserve development scopes in uv package scans * 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. * feat(scan): select uv workspace members by target directory Replace --uv-package <project.name> 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. * 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. * 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.
1 parent 4400e0e commit 9d721ef

14 files changed

Lines changed: 1158 additions & 30 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- `socket scan create --uv-members <dir...>` 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.
12+
713
## [1.2.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.1) - 2026-09-28
814

915
### Changed

‎README.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,39 @@ All aliases support the flags and arguments of the commands they alias.
6363

6464
- `socket ci` - Alias for `socket scan create --report` (creates report and exits with error if unhealthy)
6565

66+
### Scanning uv workspace members
67+
68+
Use `--uv-members` to scan selected directories of a uv workspace with the
69+
versions pinned in the workspace's shared `uv.lock`:
70+
71+
```sh
72+
socket scan create --uv-members ./packages/api ./packages/worker
73+
```
74+
75+
Each TARGET is a directory with its own `pyproject.toml`. uv finds the
76+
`uv.lock` in that directory or in its workspace root, so one scan can cover
77+
members of several workspaces. The scan includes each target's transitive and
78+
local workspace dependencies, all extras, and all dependency groups.
79+
Dependencies used only by dependency groups are marked as development
80+
dependencies. Unrelated members and the shared lockfile are not added to the
81+
scan.
82+
83+
This mode requires uv on PATH with support for `uv export --format cyclonedx1.5`,
84+
which uv currently treats as a preview feature. The export runs offline with
85+
`--frozen`, so it uses the existing lockfile without resolving newer versions,
86+
installing packages, or changing the project.
87+
88+
The CLI writes a `socket-uv-cdx.json` SBOM into each target directory, uploads
89+
only those SBOMs in place of regular manifest discovery, and removes them after
90+
the scan, including on failure, Ctrl-C, or SIGTERM. It stops if that file
91+
already exists.
92+
`--read-only` prepares the SBOMs without uploading them, and `--dry-run`
93+
validates the options without running uv.
94+
95+
With `--reach`, pass a single target. Reachability analysis then runs on that
96+
member's directory. `--uv-members` cannot be combined with `--auto-manifest` or
97+
`--dynamic-sbom-inference`.
98+
6699
### Reachability analysis
67100

68101
Socket reachability analysis comes in three forms:

‎src/commands/scan/cmd-scan-create.mts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import path from 'node:path'
44
import { logger } from '@socketsecurity/registry/lib/logger'
55

66
import { assertValidExcludePaths } from './exclude-paths.mts'
7+
import {
8+
generateUvMemberSboms,
9+
resolveUvMemberDirs,
10+
} from './generate-uv-member-sboms.mts'
711
import { handleCreateNewScan } from './handle-create-new-scan.mts'
812
import { outputCreateNewScan } from './output-create-new-scan.mts'
913
import {
@@ -172,6 +176,12 @@ const generalFlags: MeowFlags = {
172176
'Set the visibility (true/false) of the scan in your dashboard.',
173177
shortFlag: 't',
174178
},
179+
uvMembers: {
180+
type: 'boolean',
181+
default: false,
182+
description:
183+
'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.',
184+
},
175185
}
176186

177187
export const cmdScanCreate = {
@@ -241,6 +251,7 @@ async function run(
241251
$ ${command}
242252
$ ${command} ./proj --json
243253
$ ${command} --repo=test-repo --branch=main ./package.json
254+
$ ${command} --uv-members ./packages/api ./packages/worker
244255
`,
245256
}
246257

@@ -332,6 +343,7 @@ async function run(
332343
)
333344

334345
const dryRun = !!cli.flags['dryRun']
346+
const uvMembers = !!cli.flags['uvMembers']
335347

336348
let {
337349
autoManifest,
@@ -472,6 +484,7 @@ async function run(
472484
detected.count > 0 &&
473485
!autoManifest &&
474486
!dynamicSbomInference &&
487+
!uvMembers &&
475488
!hasFactsFile
476489
) {
477490
logger.info(
@@ -567,6 +580,13 @@ async function run(
567580
message: 'At least one TARGET (e.g. `.` or `./package.json`)',
568581
fail: 'missing',
569582
},
583+
{
584+
nook: true,
585+
test: !uvMembers || (!autoManifest && !dynamicSbomInference),
586+
message:
587+
'--uv-members cannot be combined with --auto-manifest or --dynamic-sbom-inference',
588+
fail: 'select one source of generated SBOMs',
589+
},
570590
{
571591
nook: true,
572592
test: !json || !markdown,
@@ -629,6 +649,8 @@ async function run(
629649
return
630650
}
631651

652+
const uvMemberDirs = uvMembers ? resolveUvMemberDirs(targets, cwd) : undefined
653+
632654
if (dryRun) {
633655
logger.log(constants.DRY_RUN_BAILING_NOW)
634656
return
@@ -642,6 +664,9 @@ async function run(
642664
committers: (committers && String(committers)) || '',
643665
cwd,
644666
defaultBranch: Boolean(defaultBranch),
667+
generateScanFiles: uvMemberDirs
668+
? () => generateUvMemberSboms(uvMemberDirs)
669+
: undefined,
645670
interactive: Boolean(interactive),
646671
orgSlug,
647672
outputKind,

‎src/commands/scan/cmd-scan-create.test.mts‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from 'node:path'
22

3-
import { describe, expect } from 'vitest'
3+
import { describe, expect, it } from 'vitest'
44

55
import constants, {
66
FLAG_CONFIG,
@@ -17,6 +17,70 @@ const fixtureBaseDir = path.join(testPath, 'fixtures/commands/scan/create')
1717
describe('socket scan create', async () => {
1818
const { binCliPath } = constants
1919

20+
const uvFixture = path.join(testPath, 'fixtures/commands/scan/uv-workspace')
21+
const uvBaseArgs = [
22+
'scan',
23+
'create',
24+
'--cwd',
25+
uvFixture,
26+
'--org',
27+
'test-org',
28+
'--repo',
29+
'test-repo',
30+
'--branch',
31+
'main',
32+
'--dry-run',
33+
'--no-interactive',
34+
FLAG_CONFIG,
35+
'{}',
36+
]
37+
38+
it(
39+
'accepts uv member directories as targets',
40+
{ timeout: 30_000 },
41+
async () => {
42+
const result = await spawnSocketCli(binCliPath, [
43+
...uvBaseArgs,
44+
'--uv-members',
45+
'packages/api',
46+
'packages/other',
47+
])
48+
expect(result.code).toBe(0)
49+
expect(result.stdout).toContain('[DryRun]: Bailing now')
50+
},
51+
)
52+
53+
it.each([
54+
{
55+
args: ['--uv-members', 'packages/missing'],
56+
error: 'directory inside --cwd',
57+
},
58+
{
59+
args: ['--uv-members', '..'],
60+
error: 'directory inside --cwd',
61+
},
62+
{
63+
args: ['--uv-members', 'packages'],
64+
error: 'requires a pyproject.toml in every TARGET',
65+
},
66+
{
67+
args: ['--uv-members', 'packages/api', '--auto-manifest'],
68+
error: 'cannot be combined',
69+
},
70+
{
71+
args: ['--uv-members', 'packages/api', '--dynamic-sbom-inference'],
72+
error: 'cannot be combined',
73+
},
74+
])(
75+
'rejects invalid uv member options: $args',
76+
{ timeout: 30_000 },
77+
async ({ args, error }) => {
78+
const result = await spawnSocketCli(binCliPath, [...uvBaseArgs, ...args])
79+
expect(result.code).not.toBe(0)
80+
expect(result.stdout + result.stderr).toContain(error)
81+
},
82+
)
83+
2084
cmdit(
2185
['scan', 'create', FLAG_HELP, FLAG_CONFIG, '{}'],
2286
`should support ${FLAG_HELP}`,
@@ -54,6 +118,7 @@ describe('socket scan create', async () => {
54118
--report-level Which policy level alerts should be reported (default 'error')
55119
--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.
56120
--tmp Set the visibility (true/false) of the scan in your dashboard.
121+
--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.
57122
--workspace The workspace in the Socket Organization that the repository is in to associate with the full scan.
58123
59124
Reachability Options (when --reach is used)
@@ -109,7 +174,8 @@ describe('socket scan create', async () => {
109174
Examples
110175
$ socket scan create
111176
$ socket scan create ./proj --json
112-
$ socket scan create --repo=test-repo --branch=main ./package.json"
177+
$ socket scan create --repo=test-repo --branch=main ./package.json
178+
$ socket scan create --uv-members ./packages/api ./packages/worker"
113179
`)
114180
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
115181
"

0 commit comments

Comments
 (0)