Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-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.

## [1.2.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.1) - 2026-09-28

### Changed
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,39 @@ 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 uv workspace members

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-members ./packages/api ./packages/worker
```

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, 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.

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

Socket reachability analysis comes in three forms:
Expand Down
25 changes: 25 additions & 0 deletions src/commands/scan/cmd-scan-create.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import path from 'node:path'
import { logger } from '@socketsecurity/registry/lib/logger'

import { assertValidExcludePaths } from './exclude-paths.mts'
import {
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 {
Expand Down Expand Up @@ -172,6 +176,12 @@ const generalFlags: MeowFlags = {
'Set the visibility (true/false) of the scan in your dashboard.',
shortFlag: 't',
},
uvMembers: {
type: 'boolean',
default: false,
description:
'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.',
},
}

export const cmdScanCreate = {
Expand Down Expand Up @@ -241,6 +251,7 @@ async function run(
$ ${command}
$ ${command} ./proj --json
$ ${command} --repo=test-repo --branch=main ./package.json
$ ${command} --uv-members ./packages/api ./packages/worker
`,
}

Expand Down Expand Up @@ -332,6 +343,7 @@ async function run(
)

const dryRun = !!cli.flags['dryRun']
const uvMembers = !!cli.flags['uvMembers']

let {
autoManifest,
Expand Down Expand Up @@ -472,6 +484,7 @@ async function run(
detected.count > 0 &&
!autoManifest &&
!dynamicSbomInference &&
!uvMembers &&
!hasFactsFile
) {
logger.info(
Expand Down Expand Up @@ -567,6 +580,13 @@ async function run(
message: 'At least one TARGET (e.g. `.` or `./package.json`)',
fail: 'missing',
},
{
nook: true,
test: !uvMembers || (!autoManifest && !dynamicSbomInference),
message:
'--uv-members cannot be combined with --auto-manifest or --dynamic-sbom-inference',
fail: 'select one source of generated SBOMs',
},
{
nook: true,
test: !json || !markdown,
Expand Down Expand Up @@ -629,6 +649,8 @@ async function run(
return
}

const uvMemberDirs = uvMembers ? resolveUvMemberDirs(targets, cwd) : undefined

if (dryRun) {
logger.log(constants.DRY_RUN_BAILING_NOW)
return
Expand All @@ -642,6 +664,9 @@ async function run(
committers: (committers && String(committers)) || '',
cwd,
defaultBranch: Boolean(defaultBranch),
generateScanFiles: uvMemberDirs
? () => generateUvMemberSboms(uvMemberDirs)
: undefined,
interactive: Boolean(interactive),
orgSlug,
outputKind,
Expand Down
70 changes: 68 additions & 2 deletions src/commands/scan/cmd-scan-create.test.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'node:path'

import { describe, expect } from 'vitest'
import { describe, expect, it } from 'vitest'

import constants, {
FLAG_CONFIG,
Expand All @@ -17,6 +17,70 @@ 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 uv member directories as targets',
{ timeout: 30_000 },
async () => {
const result = await spawnSocketCli(binCliPath, [
...uvBaseArgs,
'--uv-members',
'packages/api',
'packages/other',
])
expect(result.code).toBe(0)
expect(result.stdout).toContain('[DryRun]: Bailing now')
},
)

it.each([
{
args: ['--uv-members', 'packages/missing'],
error: 'directory inside --cwd',
},
{
args: ['--uv-members', '..'],
error: 'directory inside --cwd',
},
{
args: ['--uv-members', 'packages'],
error: 'requires a pyproject.toml in every TARGET',
},
{
args: ['--uv-members', 'packages/api', '--auto-manifest'],
error: 'cannot be combined',
},
{
args: ['--uv-members', 'packages/api', '--dynamic-sbom-inference'],
error: 'cannot be combined',
},
])(
'rejects invalid uv member 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, '{}'],
`should support ${FLAG_HELP}`,
Expand Down Expand Up @@ -54,6 +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-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)
Expand Down Expand Up @@ -109,7 +174,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-members ./packages/api ./packages/worker"
`)
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
"
Expand Down
Loading
Loading