diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea783ff58..c2dcff6fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,15 +5,12 @@ name: 'ci: native package' run-name: 'ci: native package' -# PUSH, never pull_request. The fleet takes no outside contributions and -# lands on main directly, so a pull_request trigger adds no coverage a push -# trigger does not already give — and it is the fragile half: GitHub has -# narrowed pull_request defaults for security, and a silently non-firing -# trigger reads as a green repo with no CI at all. on: push: branches: [main] tags: ['*'] + pull_request: + branches: [main] workflow_dispatch: permissions: diff --git a/README.md b/README.md index 396f555a7..267cee9e4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ protection is no longer npm-only. Browse the [command index](docs/commands.md) for every command and alias, then open a focused guide from its tables. +See the [organization dependency inventory guide](docs/organization-dependencies.md) +for listing dependencies already known to your organization. + ### MCP connections For a local stdio connection, run `socket login` once, then configure your MCP diff --git a/docs/organization-dependencies.md b/docs/organization-dependencies.md index 4e0e026b8..61b883222 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/command/organization/cmd-organization-dependencies.mts b/src/command/organization/cmd-organization-dependencies.mts index 4d1f3b297..179b22363 100644 --- a/src/command/organization/cmd-organization-dependencies.mts +++ b/src/command/organization/cmd-organization-dependencies.mts @@ -12,6 +12,7 @@ import { import { getOutputKind } from '../../util/output/mode.mjs' import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' import { checkCommandInput } from '../../util/validation/input-check.mts' +import { parsePurlsOption } from './dependencies-filters.mts' import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' import type { MeowFlags } from '../../flags.mts' @@ -48,7 +49,12 @@ export 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, }), @@ -65,6 +71,7 @@ export async function run( Examples ${command} ${command} --limit 20 --offset 10 + ${command} --purls 'pkg:npm/minimist@0.0.4' `, } @@ -75,7 +82,12 @@ export async function run( importMeta, }) - const { json, limit, markdown, offset } = cli.flags + const { json, limit, markdown, offset, purls: suppliedPurls } = cli.flags + const parsedPurls = parsePurlsOption(argv, suppliedPurls) + if (!parsedPurls.ok) { + throw new InputError(parsedPurls.message) + } + const { purls } = parsedPurls const dryRun = cli.flags['dryRun'] @@ -110,6 +122,7 @@ export async function run( outputDryRunFetch('organization dependencies', { limit: validatedLimit || 50, offset: validatedOffset, + ...(purls ? { purls: JSON.stringify(purls) } : {}), }) return } @@ -128,6 +141,7 @@ export async function run( await handleDependencies({ limit: validatedLimit, offset: validatedOffset, + purls, outputKind, }) } diff --git a/src/command/organization/dependencies-command.mts b/src/command/organization/dependencies-command.mts index 23c651c8a..34d9b6311 100644 --- a/src/command/organization/dependencies-command.mts +++ b/src/command/organization/dependencies-command.mts @@ -8,17 +8,19 @@ import type { OutputKind } from '../../types.mts' export async function handleDependencies({ limit, offset, + purls, outputKind, }: { limit: number offset: number + purls: string[] | undefined outputKind: OutputKind }): Promise { debug(`Fetching dependencies with limit=${limit}, offset=${offset}`) - debugDir({ limit, offset, outputKind }) + debugDir({ limit, offset, purls, outputKind }) const result = await fetchDependencies( - { limit, offset }, + { limit, offset, purls }, { commandPath: 'socket organization dependencies', }, diff --git a/src/command/organization/dependencies-data.mts b/src/command/organization/dependencies-data.mts index 196d002ad..5e8457230 100644 --- a/src/command/organization/dependencies-data.mts +++ b/src/command/organization/dependencies-data.mts @@ -1,5 +1,6 @@ import { handleApiCall } from '../../util/socket/api.mjs' import { setupSdk } from '../../util/socket/sdk.mjs' +import { matchesConfirmedPurls } from './dependencies-filters.mts' import type { CResult } from '../../types.mts' import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' @@ -8,6 +9,7 @@ import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' export type FetchDependenciesConfig = { limit: number offset: number + purls?: string[] | undefined } export type FetchDependenciesOptions = { @@ -30,16 +32,32 @@ export async function fetchDependencies( } const sockSdk = sockSdkCResult.data - const { limit, offset } = { - __proto__: null, - ...config, - } as FetchDependenciesConfig + const { limit, offset, purls } = config - return await handleApiCall<'searchDependencies'>( - sockSdk.searchDependencies({ limit, offset }), + const result = await handleApiCall<'searchDependencies'>( + sockSdk.searchDependencies({ + limit, + offset, + ...(purls === undefined ? {} : { purls }), + }), { commandPath, 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 result } diff --git a/src/command/organization/dependencies-filters.mts b/src/command/organization/dependencies-filters.mts new file mode 100644 index 000000000..89d75cb07 --- /dev/null +++ b/src/command/organization/dependencies-filters.mts @@ -0,0 +1,97 @@ +import { getPurlObject } from '../../util/purl/parse.mts' + +export type DependenciesPurlsResult = + | { ok: true; purls: string[] | undefined } + | { ok: false; message: string } + +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 { valid, invalid } = 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)) + ) +} + +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 parsePurlsOption( + argv: readonly string[], + suppliedPurls: readonly string[] | undefined, +): DependenciesPurlsResult { + const hasPurlsOption = argv.some( + argument => argument === '--purls' || argument.startsWith('--purls='), + ) + if (!hasPurlsOption && suppliedPurls?.length === 0) { + return { ok: true, purls: undefined } + } + return parseDependenciesPurls( + hasPurlsOption && suppliedPurls === undefined ? [] : suppliedPurls, + ) +} diff --git a/test/integration/cli/cmd-organization-dependencies.test.mts b/test/integration/cli/cmd-organization-dependencies.test.mts index 812484d4c..bd28d5345 100644 --- a/test/integration/cli/cmd-organization-dependencies.test.mts +++ b/test/integration/cli/cmd-organization-dependencies.test.mts @@ -47,12 +47,14 @@ 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 --quiet Route non-essential output (status, progress, warnings) to stderr so stdout carries only the payload. Implied by --json and --markdown. 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(` " @@ -102,4 +104,86 @@ 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', + '--purls', + 'pkg:generic/example.package', + '--json', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should preview repeated PURL filters without emitting JSON payload data', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const expectedPurls = JSON.stringify([ + 'pkg:npm/minimist@0.0.4', + 'pkg:pypi/requests', + 'pkg:generic/example.package', + ]) + expect(code).toBe(0) + expect(stdout).toBe('') + expect(stderr).toContain(`purls: ${expectedPurls}`) + }, + ) + + 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 } = await spawnSocketCli(binCliPath, cmd) + expect(code).not.toBe(0) + expect(stderr).toContain('Invalid PURL "minimist"') + expect(stderr).not.toContain('Would fetch organization dependencies') + }, + ) + + cmdit( + [ + 'organization', + 'dependencies', + FLAG_DRY_RUN, + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + '--purls', + ], + 'should reject a bare PURL flag instead of searching all dependencies', + async cmd => { + const { code, stderr } = await spawnSocketCli(binCliPath, cmd) + expect(code).not.toBe(0) + expect(stderr).not.toContain('Would fetch organization dependencies') + }, + ) + + cmdit( + [ + 'organization', + 'dependencies', + FLAG_DRY_RUN, + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + '--purls=', + ], + 'should reject an explicitly empty PURL filter', + async cmd => { + const { code, stderr } = await spawnSocketCli(binCliPath, cmd) + expect(code).not.toBe(0) + expect(stderr).not.toContain('Would fetch organization dependencies') + }, + ) }) diff --git a/test/unit/command/organization/dependencies-filters.test.mts b/test/unit/command/organization/dependencies-filters.test.mts new file mode 100644 index 000000000..311e1ca14 --- /dev/null +++ b/test/unit/command/organization/dependencies-filters.test.mts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' + +import { + matchesConfirmedPurls, + parseDependenciesPurls, + parsePurlsOption, +} from '../../../../src/command/organization/dependencies-filters.mts' + +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 input', () => { + expect(parseDependenciesPurls(['minimist'])).toMatchObject({ ok: false }) + }) + + it('rejects blank input', () => { + expect(parseDependenciesPurls([' '])).toMatchObject({ ok: false }) + expect(parseDependenciesPurls([])).toMatchObject({ ok: false }) + }) + + it('rejects malformed PURLs', () => { + 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('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, + }) + }) +}) + +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) + }) +})