From 8d4cd9930314ac0fd33b02345e57c87065859cc5 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 9 Nov 2026 13:10:04 -0500 Subject: [PATCH] docs: explain organization dependency inventory --- README.md | 2 + docs/organization-dependencies.md | 35 ++++++ .../cmd-organization-dependencies.mts | 16 ++- .../cmd-organization-dependencies.test.mts | 79 +++++++++++- .../organization/dependencies-filters.mts | 115 ++++++++++++++++++ .../dependencies-filters.test.mts | 113 +++++++++++++++++ .../organization/fetch-dependencies.mts | 33 +++-- .../organization/handle-dependencies.mts | 6 +- 8 files changed, 387 insertions(+), 12 deletions(-) create mode 100644 docs/organization-dependencies.md create mode 100644 src/commands/organization/dependencies-filters.mts create mode 100644 src/commands/organization/dependencies-filters.test.mts diff --git a/README.md b/README.md index 8ace54a8d1..606e105c88 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ Supports version 2 format with `projectIgnorePaths` for excluding files from rep ### See also +- [Organization dependency inventory guide](docs/organization-dependencies.md) + - [Socket API Reference](https://docs.socket.dev/reference) - [Socket GitHub App](https://github.com/apps/socket-security) - [`@socketsecurity/sdk`](https://github.com/SocketDev/socket-sdk-js) diff --git a/docs/organization-dependencies.md b/docs/organization-dependencies.md new file mode 100644 index 0000000000..30513c74ee --- /dev/null +++ b/docs/organization-dependencies.md @@ -0,0 +1,35 @@ +# Organization dependency inventory + +`socket organization dependencies` lists dependency records known to your +organization; it does not scan the current directory. + +Each request returns one page (`--limit` defaults to 50; `--offset` to 0). +Increase the offset by the limit until the output says there is no more data: + +```sh +socket organization dependencies +socket organization dependencies --limit 20 --offset 0 +socket organization dependencies --limit 20 --offset 20 +``` + +## Filter by package + +Pass a complete package URL (PURL) to `--purls`; repeat the flag for multiple +packages: + +```sh +socket organization dependencies \ + --purls 'pkg:npm/minimist@0.0.4' \ + --purls 'pkg:pypi/requests' +``` + +A versioned PURL matches that version; omitting it matches all indexed versions. +Filters do not support substrings, wildcards, version ranges, qualifiers, or +subpaths. + +Use `--json` to write the result envelope as JSON; dependency records are in +`data.rows`: + +```sh +socket organization dependencies --json > dependencies.json +``` diff --git a/src/commands/organization/cmd-organization-dependencies.mts b/src/commands/organization/cmd-organization-dependencies.mts index 04e084788a..0a5a197e8c 100644 --- a/src/commands/organization/cmd-organization-dependencies.mts +++ b/src/commands/organization/cmd-organization-dependencies.mts @@ -4,6 +4,7 @@ import { handleDependencies } from './handle-dependencies.mts' import constants, { FLAG_JSON, FLAG_MARKDOWN } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' +import { InputError } from '../../utils/errors.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { @@ -11,6 +12,7 @@ import { getFlagListOutput, } from '../../utils/output-formatting.mts' import { hasDefaultApiToken } from '../../utils/sdk.mts' +import { parsePurlsOption } from './dependencies-filters.mts' import type { CliCommandConfig, @@ -49,7 +51,12 @@ async function run( offset: { type: 'number', default: 0, - description: 'Page number', + description: 'Number of dependencies to skip', + }, + purls: { + type: 'string', + isMultiple: true, + description: 'Search for a full PURL; repeat this flag for more PURLs', }, ...outputFlags, }, @@ -66,6 +73,7 @@ async function run( Examples ${command} ${command} --limit 20 --offset 10 + ${command} --purls 'pkg:npm/minimist@0.0.4' `, } @@ -77,6 +85,11 @@ async function run( }) const { json, limit, markdown, offset } = cli.flags + const parsedPurls = parsePurlsOption(argv, cli.flags['purls']) + if (!parsedPurls.ok) { + throw new InputError(parsedPurls.message) + } + const { purls } = parsedPurls const dryRun = !!cli.flags['dryRun'] @@ -111,6 +124,7 @@ async function run( await handleDependencies({ limit: Number(limit || 0) || 0, offset: Number(offset || 0) || 0, + purls, outputKind, }) } diff --git a/src/commands/organization/cmd-organization-dependencies.test.mts b/src/commands/organization/cmd-organization-dependencies.test.mts index a860c78950..66aaf4c083 100644 --- a/src/commands/organization/cmd-organization-dependencies.test.mts +++ b/src/commands/organization/cmd-organization-dependencies.test.mts @@ -29,11 +29,13 @@ describe('socket organization dependencies', async () => { --json Output as JSON --limit Maximum number of dependencies returned --markdown Output as Markdown - --offset Page number + --offset Number of dependencies to skip + --purls Search for a full PURL; repeat this flag for more PURLs Examples socket organization dependencies - socket organization dependencies --limit 20 --offset 10" + socket organization dependencies --limit 20 --offset 10 + socket organization dependencies --purls 'pkg:npm/minimist@0.0.4'" `, ) expect(`\n ${stderr}`).toMatchInlineSnapshot(` @@ -74,4 +76,77 @@ describe('socket organization dependencies', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + [ + 'organization', + 'dependencies', + FLAG_DRY_RUN, + '--purls', + 'pkg:npm/minimist@0.0.4', + '--purls', + 'pkg:pypi/requests', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept repeated PURL filters in a dry run', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(code).toBe(0) + expect(stdout).toContain('[DryRun]: Bailing now') + }, + ) + + cmdit( + [ + 'organization', + 'dependencies', + FLAG_DRY_RUN, + '--purls', + 'minimist', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should reject invalid PURLs before dry-run success', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(code).not.toBe(0) + expect(`${stdout}\n${stderr}`).toContain('Invalid PURL "minimist"') + expect(stdout).not.toContain('[DryRun]: Bailing now') + }, + ) + + cmdit( + [ + 'organization', + 'dependencies', + FLAG_DRY_RUN, + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + '--purls', + ], + 'should reject a bare PURL flag', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(code).not.toBe(0) + expect(stdout).not.toContain('[DryRun]: Bailing now') + }, + ) + + cmdit( + [ + 'organization', + 'dependencies', + FLAG_DRY_RUN, + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + '--purls=', + ], + 'should reject an explicitly empty PURL filter', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(code).not.toBe(0) + expect(stdout).not.toContain('[DryRun]: Bailing now') + }, + ) }) diff --git a/src/commands/organization/dependencies-filters.mts b/src/commands/organization/dependencies-filters.mts new file mode 100644 index 0000000000..4468a7767c --- /dev/null +++ b/src/commands/organization/dependencies-filters.mts @@ -0,0 +1,115 @@ +import { getPurlObject } from '../../utils/purl.mts' + +export type DependenciesPurlsResult = + | { ok: true; purls: string[] | undefined } + | { ok: false; message: string } + +export function parsePurlsOption( + argv: readonly string[], + suppliedPurls: unknown, +): DependenciesPurlsResult { + const hasPurlsOption = argv.some( + argument => argument === '--purls' || argument.startsWith('--purls='), + ) + if (suppliedPurls === undefined) { + return hasPurlsOption + ? parseDependenciesPurls([]) + : { ok: true, purls: undefined } + } + if ( + !hasPurlsOption && + Array.isArray(suppliedPurls) && + suppliedPurls.length === 0 + ) { + return { ok: true, purls: undefined } + } + if ( + !Array.isArray(suppliedPurls) || + !suppliedPurls.every( + (value: unknown): value is string => typeof value === 'string', + ) + ) { + return { + ok: false, + message: 'Provide at least one non-empty PURL with --purls.', + } + } + return parseDependenciesPurls(suppliedPurls) +} + +export function parseDependenciesPurls( + values: readonly string[] | undefined, +): DependenciesPurlsResult { + if (values === undefined) { + return { ok: true, purls: undefined } + } + + const purls: string[] = [] + const seen = new Set() + + for (const value of values) { + const purl = value.trim() + const parsed = + purl.startsWith('pkg:') && purl.length > 4 + ? getPurlObject(purl, { throws: false }) + : undefined + + if (!parsed) { + return { + ok: false, + message: `Invalid PURL "${value}". Use a full PURL such as pkg:npm/minimist@1.2.3.`, + } + } + + if (Object.keys(parsed.qualifiers ?? {}).length > 0 || parsed.subpath) { + return { + ok: false, + message: `PURL "${value}" includes qualifiers or a subpath, which this search does not support. Search by package identity and version only.`, + } + } + + if (!seen.has(purl)) { + seen.add(purl) + purls.push(purl) + } + } + + if (purls.length === 0) { + return { + ok: false, + message: 'Provide at least one non-empty PURL with --purls.', + } + } + + return { ok: true, purls } +} + +export function matchesConfirmedPurls( + requested: readonly string[], + filters: unknown, +): boolean { + if (typeof filters !== 'object' || filters === null) { + return false + } + if (!('valid' in filters) || !('invalid' in filters)) { + return false + } + const { invalid, valid } = filters + if ( + !Array.isArray(valid) || + !valid.every(value => typeof value === 'string') || + !Array.isArray(invalid) || + !invalid.every(value => typeof value === 'string') || + invalid.length > 0 + ) { + return false + } + const expected = new Set(requested) + const actual = new Set(valid) + return ( + expected.size === requested.length && + actual.size === valid.length && + expected.size === actual.size && + [...expected].every(value => actual.has(value)) + ) +} diff --git a/src/commands/organization/dependencies-filters.test.mts b/src/commands/organization/dependencies-filters.test.mts new file mode 100644 index 0000000000..f15ff37484 --- /dev/null +++ b/src/commands/organization/dependencies-filters.test.mts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' + +import { + matchesConfirmedPurls, + parseDependenciesPurls, + parsePurlsOption, +} from './dependencies-filters.mts' + +describe('parsePurlsOption', () => { + it('treats an omitted multi-value flag as unfiltered', () => { + expect(parsePurlsOption([], [])).toEqual({ ok: true, purls: undefined }) + }) + + it('rejects a bare multi-value flag', () => { + expect(parsePurlsOption(['--purls'], undefined)).toMatchObject({ + ok: false, + }) + }) + + it('rejects non-array values from CLI flags', () => { + expect(parsePurlsOption(['--purls'], 'pkg:npm/minimist')).toMatchObject({ + ok: false, + }) + }) +}) + +describe('parseDependenciesPurls', () => { + it('leaves an omitted filter undefined', () => { + expect(parseDependenciesPurls(undefined)).toEqual({ + ok: true, + purls: undefined, + }) + }) + + it('trims and deduplicates complete PURLs in first-seen order', () => { + expect( + parseDependenciesPurls([ + ' pkg:npm/%40types/node ', + 'pkg:pypi/requests@2.32.3', + 'pkg:npm/%40types/node', + ]), + ).toEqual({ + ok: true, + purls: ['pkg:npm/%40types/node', 'pkg:pypi/requests@2.32.3'], + }) + }) + + it('accepts versionless and punctuation package identities', () => { + expect( + parseDependenciesPurls(['pkg:npm/example.package', 'pkg:pypi/requests']), + ).toEqual({ + ok: true, + purls: ['pkg:npm/example.package', 'pkg:pypi/requests'], + }) + }) + + it('does not impose an ecosystem allowlist', () => { + expect(parseDependenciesPurls(['pkg:fixture-ecosystem/example'])).toEqual({ + ok: true, + purls: ['pkg:fixture-ecosystem/example'], + }) + }) + + it('rejects bare, blank, and malformed PURLs', () => { + expect(parseDependenciesPurls(['minimist'])).toMatchObject({ ok: false }) + expect(parseDependenciesPurls([' '])).toMatchObject({ ok: false }) + expect(parseDependenciesPurls([])).toMatchObject({ ok: false }) + expect(parseDependenciesPurls(['pkg:npm/'])).toMatchObject({ ok: false }) + }) + + it('rejects qualifiers and subpaths', () => { + expect(parseDependenciesPurls(['pkg:npm/minimist?arch=x64'])).toMatchObject( + { ok: false }, + ) + expect(parseDependenciesPurls(['pkg:npm/minimist#nested'])).toMatchObject({ + ok: false, + }) + }) +}) + +describe('matchesConfirmedPurls', () => { + it('requires exact server confirmation with no invalid filters', () => { + expect( + matchesConfirmedPurls(['pkg:npm/minimist'], { + valid: ['pkg:npm/minimist'], + invalid: [], + }), + ).toBe(true) + expect( + matchesConfirmedPurls(['pkg:npm/minimist'], { + valid: [], + invalid: ['pkg:npm/minimist'], + }), + ).toBe(false) + expect( + matchesConfirmedPurls(['pkg:npm/minimist'], { + valid: ['pkg:npm/minimist'], + invalid: ['pkg:pypi/requests'], + }), + ).toBe(false) + expect( + matchesConfirmedPurls(['pkg:npm/minimist'], { + valid: ['pkg:npm/minimist'], + }), + ).toBe(false) + expect( + matchesConfirmedPurls(['pkg:npm/minimist'], { + valid: ['pkg:npm/other'], + invalid: [], + }), + ).toBe(false) + }) +}) diff --git a/src/commands/organization/fetch-dependencies.mts b/src/commands/organization/fetch-dependencies.mts index 7b39f7fd80..63d39267cc 100644 --- a/src/commands/organization/fetch-dependencies.mts +++ b/src/commands/organization/fetch-dependencies.mts @@ -1,5 +1,6 @@ import { handleApiCall } from '../../utils/api.mts' import { setupSdk } from '../../utils/sdk.mts' +import { matchesConfirmedPurls } from './dependencies-filters.mts' import type { CResult } from '../../types.mts' import type { SetupSdkOptions } from '../../utils/sdk.mts' @@ -8,6 +9,7 @@ import type { SocketSdkSuccessResult } from '@socketsecurity/sdk' export type FetchDependenciesConfig = { limit: number offset: number + purls?: string[] | undefined } export type FetchDependenciesOptions = { @@ -29,12 +31,29 @@ export async function fetchDependencies( } const sockSdk = sockSdkCResult.data - const { limit, offset } = { - __proto__: null, - ...config, - } as FetchDependenciesConfig + const { limit, offset, purls } = config + + const result = await handleApiCall( + sockSdk.searchDependencies({ + limit, + offset, + ...(purls === undefined ? {} : { purls }), + }), + { description: 'organization dependencies' }, + ) + + if ( + result.ok && + purls !== undefined && + !matchesConfirmedPurls(purls, result.data.purlFilters) + ) { + return { + ok: false, + message: 'The server did not confirm every requested PURL filter.', + cause: + 'Review the PURLs and try again. No dependency results were displayed because the filters may not have been applied.', + } + } - return await handleApiCall(sockSdk.searchDependencies({ limit, offset }), { - description: 'organization dependencies', - }) + return result } diff --git a/src/commands/organization/handle-dependencies.mts b/src/commands/organization/handle-dependencies.mts index 34142112c6..8c6286a7ff 100644 --- a/src/commands/organization/handle-dependencies.mts +++ b/src/commands/organization/handle-dependencies.mts @@ -9,18 +9,20 @@ export async function handleDependencies({ limit, offset, outputKind, + purls, }: { limit: number offset: number + purls: string[] | undefined outputKind: OutputKind }): Promise { debugFn( 'notice', `Fetching dependencies with limit=${limit}, offset=${offset}`, ) - debugDir('inspect', { limit, offset, outputKind }) + debugDir('inspect', { limit, offset, purls, outputKind }) - const result = await fetchDependencies({ limit, offset }) + const result = await fetchDependencies({ limit, offset, purls }) debugFn( 'notice',