diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57f9c45b06..11adca8e2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [main, v1.x] pull_request: - branches: [main, v1.x] + branches: [main, v1.x, docs/organization-dependencies-v1x] permissions: contents: read diff --git a/docs/organization-dependencies.md b/docs/organization-dependencies.md index ca85657f9c..9a9f636fb3 100644 --- a/docs/organization-dependencies.md +++ b/docs/organization-dependencies.md @@ -35,6 +35,31 @@ artifact can have more than one repository link, so the displayed row count can be greater than the limit. Each invocation makes one request; the command does not paginate automatically. +## Filter by package + +Use `--purls` to find dependency artifacts by package URL (PURL). Supply one +complete PURL per flag; repeat the flag to search for multiple packages: + +```sh +socket organization dependencies --purls 'pkg:npm/minimist@0.0.4' +socket organization dependencies \ + --purls 'pkg:npm/minimist' \ + --purls 'pkg:pypi/requests@2.32.3' \ + --json +``` + +A PURL can include an exact version or omit the version to match all indexed +versions of that package. Package type, namespace, and name are matched as +provided; for example, `pkg:npm/%40types/node` keeps the scoped npm namespace. +Searches do not support substring, wildcard, or version-range matching. +Qualifiers and subpaths are rejected because this search endpoint filters only +package identity and version. If the server does not confirm every requested +PURL, the CLI reports a failure and does not display dependency rows. + +Each request searches one page, so combine `--purls` with `--limit` and +`--offset` to continue through matching artifacts. The rows include the +organization repositories and branches currently linked to those artifacts. + ## Choose an output format The default output and `--markdown` display a text table with ecosystem, @@ -49,8 +74,10 @@ socket organization dependencies --json > dependencies.json ``` On success, the JSON envelope contains `ok` and `data`. Read dependency rows -from `data.rows`; `data` also includes `end`, `limit`, and `offset`. The -`--json` and `--markdown` options cannot be used together. +from `data.rows`; `data` also includes `end`, `limit`, and `offset`. When you +search with `--purls`, it also includes the server's filter confirmation in +`data.purlFilters`. The `--json` and `--markdown` options cannot be used +together. ## Help and dry run @@ -62,5 +89,5 @@ socket organization dependencies --help A dry run does not retrieve inventory. It only reports what the command would request. On the 2.x prerelease, dry run requires authentication and reports the -selected limit and offset. On 1.x, it exits before making a request and prints -a generic dry-run message. +selected limit, offset, and PURL filters. On 1.x, it exits before making a +request and prints a generic dry-run message. 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',