Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 31 additions & 4 deletions docs/organization-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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.
16 changes: 15 additions & 1 deletion src/commands/organization/cmd-organization-dependencies.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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 {
getFlagApiRequirementsOutput,
getFlagListOutput,
} from '../../utils/output-formatting.mts'
import { hasDefaultApiToken } from '../../utils/sdk.mts'
import { parsePurlsOption } from './dependencies-filters.mts'

import type {
CliCommandConfig,
Expand Down Expand Up @@ -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,
},
Expand All @@ -66,6 +73,7 @@ async function run(
Examples
${command}
${command} --limit 20 --offset 10
${command} --purls 'pkg:npm/minimist@0.0.4'
`,
}

Expand All @@ -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']

Expand Down Expand Up @@ -111,6 +124,7 @@ async function run(
await handleDependencies({
limit: Number(limit || 0) || 0,
offset: Number(offset || 0) || 0,
purls,
outputKind,
})
}
79 changes: 77 additions & 2 deletions src/commands/organization/cmd-organization-dependencies.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down Expand Up @@ -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')
},
)
})
115 changes: 115 additions & 0 deletions src/commands/organization/dependencies-filters.mts
Original file line number Diff line number Diff line change
@@ -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<string>()

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))
)
}
Loading
Loading