Skip to content

Commit 8f2d0ba

Browse files
authored
Search organization dependencies by PURL (#1612)
* feat(organization): search dependencies by PURL * fix(ci): verify dependency search stack * fix: sort dependency filter bindings
1 parent be04e96 commit 8f2d0ba

8 files changed

Lines changed: 382 additions & 17 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches: [main, v1.x]
66
pull_request:
7-
branches: [main, v1.x]
7+
branches: [main, v1.x, docs/organization-dependencies-v1x]
88

99
permissions:
1010
contents: read

‎docs/organization-dependencies.md‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@ artifact can have more than one repository link, so the displayed row count
3535
can be greater than the limit. Each invocation makes one request; the command
3636
does not paginate automatically.
3737

38+
## Filter by package
39+
40+
Use `--purls` to find dependency artifacts by package URL (PURL). Supply one
41+
complete PURL per flag; repeat the flag to search for multiple packages:
42+
43+
```sh
44+
socket organization dependencies --purls 'pkg:npm/minimist@0.0.4'
45+
socket organization dependencies \
46+
--purls 'pkg:npm/minimist' \
47+
--purls 'pkg:pypi/requests@2.32.3' \
48+
--json
49+
```
50+
51+
A PURL can include an exact version or omit the version to match all indexed
52+
versions of that package. Package type, namespace, and name are matched as
53+
provided; for example, `pkg:npm/%40types/node` keeps the scoped npm namespace.
54+
Searches do not support substring, wildcard, or version-range matching.
55+
Qualifiers and subpaths are rejected because this search endpoint filters only
56+
package identity and version. If the server does not confirm every requested
57+
PURL, the CLI reports a failure and does not display dependency rows.
58+
59+
Each request searches one page, so combine `--purls` with `--limit` and
60+
`--offset` to continue through matching artifacts. The rows include the
61+
organization repositories and branches currently linked to those artifacts.
62+
3863
## Choose an output format
3964

4065
The default output and `--markdown` display a text table with ecosystem,
@@ -49,8 +74,10 @@ socket organization dependencies --json > dependencies.json
4974
```
5075

5176
On success, the JSON envelope contains `ok` and `data`. Read dependency rows
52-
from `data.rows`; `data` also includes `end`, `limit`, and `offset`. The
53-
`--json` and `--markdown` options cannot be used together.
77+
from `data.rows`; `data` also includes `end`, `limit`, and `offset`. When you
78+
search with `--purls`, it also includes the server's filter confirmation in
79+
`data.purlFilters`. The `--json` and `--markdown` options cannot be used
80+
together.
5481

5582
## Help and dry run
5683

@@ -62,5 +89,5 @@ socket organization dependencies --help
6289

6390
A dry run does not retrieve inventory. It only reports what the command would
6491
request. On the 2.x prerelease, dry run requires authentication and reports the
65-
selected limit and offset. On 1.x, it exits before making a request and prints
66-
a generic dry-run message.
92+
selected limit, offset, and PURL filters. On 1.x, it exits before making a
93+
request and prints a generic dry-run message.

‎src/commands/organization/cmd-organization-dependencies.mts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { handleDependencies } from './handle-dependencies.mts'
44
import constants, { FLAG_JSON, FLAG_MARKDOWN } from '../../constants.mts'
55
import { commonFlags, outputFlags } from '../../flags.mts'
66
import { checkCommandInput } from '../../utils/check-input.mts'
7+
import { InputError } from '../../utils/errors.mts'
78
import { getOutputKind } from '../../utils/get-output-kind.mts'
89
import { meowOrExit } from '../../utils/meow-with-subcommands.mts'
910
import {
1011
getFlagApiRequirementsOutput,
1112
getFlagListOutput,
1213
} from '../../utils/output-formatting.mts'
1314
import { hasDefaultApiToken } from '../../utils/sdk.mts'
15+
import { parsePurlsOption } from './dependencies-filters.mts'
1416

1517
import type {
1618
CliCommandConfig,
@@ -49,7 +51,12 @@ async function run(
4951
offset: {
5052
type: 'number',
5153
default: 0,
52-
description: 'Page number',
54+
description: 'Number of dependencies to skip',
55+
},
56+
purls: {
57+
type: 'string',
58+
isMultiple: true,
59+
description: 'Search for a full PURL; repeat this flag for more PURLs',
5360
},
5461
...outputFlags,
5562
},
@@ -66,6 +73,7 @@ async function run(
6673
Examples
6774
${command}
6875
${command} --limit 20 --offset 10
76+
${command} --purls 'pkg:npm/minimist@0.0.4'
6977
`,
7078
}
7179

@@ -77,6 +85,11 @@ async function run(
7785
})
7886

7987
const { json, limit, markdown, offset } = cli.flags
88+
const parsedPurls = parsePurlsOption(argv, cli.flags['purls'])
89+
if (!parsedPurls.ok) {
90+
throw new InputError(parsedPurls.message)
91+
}
92+
const { purls } = parsedPurls
8093

8194
const dryRun = !!cli.flags['dryRun']
8295

@@ -111,6 +124,7 @@ async function run(
111124
await handleDependencies({
112125
limit: Number(limit || 0) || 0,
113126
offset: Number(offset || 0) || 0,
127+
purls,
114128
outputKind,
115129
})
116130
}

‎src/commands/organization/cmd-organization-dependencies.test.mts‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ describe('socket organization dependencies', async () => {
2929
--json Output as JSON
3030
--limit Maximum number of dependencies returned
3131
--markdown Output as Markdown
32-
--offset Page number
32+
--offset Number of dependencies to skip
33+
--purls Search for a full PURL; repeat this flag for more PURLs
3334
3435
Examples
3536
socket organization dependencies
36-
socket organization dependencies --limit 20 --offset 10"
37+
socket organization dependencies --limit 20 --offset 10
38+
socket organization dependencies --purls 'pkg:npm/minimist@0.0.4'"
3739
`,
3840
)
3941
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
@@ -74,4 +76,77 @@ describe('socket organization dependencies', async () => {
7476
expect(code, 'dry-run should exit with code 0 if input ok').toBe(0)
7577
},
7678
)
79+
80+
cmdit(
81+
[
82+
'organization',
83+
'dependencies',
84+
FLAG_DRY_RUN,
85+
'--purls',
86+
'pkg:npm/minimist@0.0.4',
87+
'--purls',
88+
'pkg:pypi/requests',
89+
FLAG_CONFIG,
90+
'{"apiToken":"fakeToken"}',
91+
],
92+
'should accept repeated PURL filters in a dry run',
93+
async cmd => {
94+
const { code, stdout } = await spawnSocketCli(binCliPath, cmd)
95+
expect(code).toBe(0)
96+
expect(stdout).toContain('[DryRun]: Bailing now')
97+
},
98+
)
99+
100+
cmdit(
101+
[
102+
'organization',
103+
'dependencies',
104+
FLAG_DRY_RUN,
105+
'--purls',
106+
'minimist',
107+
FLAG_CONFIG,
108+
'{"apiToken":"fakeToken"}',
109+
],
110+
'should reject invalid PURLs before dry-run success',
111+
async cmd => {
112+
const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd)
113+
expect(code).not.toBe(0)
114+
expect(`${stdout}\n${stderr}`).toContain('Invalid PURL "minimist"')
115+
expect(stdout).not.toContain('[DryRun]: Bailing now')
116+
},
117+
)
118+
119+
cmdit(
120+
[
121+
'organization',
122+
'dependencies',
123+
FLAG_DRY_RUN,
124+
FLAG_CONFIG,
125+
'{"apiToken":"fakeToken"}',
126+
'--purls',
127+
],
128+
'should reject a bare PURL flag',
129+
async cmd => {
130+
const { code, stdout } = await spawnSocketCli(binCliPath, cmd)
131+
expect(code).not.toBe(0)
132+
expect(stdout).not.toContain('[DryRun]: Bailing now')
133+
},
134+
)
135+
136+
cmdit(
137+
[
138+
'organization',
139+
'dependencies',
140+
FLAG_DRY_RUN,
141+
FLAG_CONFIG,
142+
'{"apiToken":"fakeToken"}',
143+
'--purls=',
144+
],
145+
'should reject an explicitly empty PURL filter',
146+
async cmd => {
147+
const { code, stdout } = await spawnSocketCli(binCliPath, cmd)
148+
expect(code).not.toBe(0)
149+
expect(stdout).not.toContain('[DryRun]: Bailing now')
150+
},
151+
)
77152
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { getPurlObject } from '../../utils/purl.mts'
2+
3+
export type DependenciesPurlsResult =
4+
| { ok: true; purls: string[] | undefined }
5+
| { ok: false; message: string }
6+
7+
export function parsePurlsOption(
8+
argv: readonly string[],
9+
suppliedPurls: unknown,
10+
): DependenciesPurlsResult {
11+
const hasPurlsOption = argv.some(
12+
argument => argument === '--purls' || argument.startsWith('--purls='),
13+
)
14+
if (suppliedPurls === undefined) {
15+
return hasPurlsOption
16+
? parseDependenciesPurls([])
17+
: { ok: true, purls: undefined }
18+
}
19+
if (
20+
!hasPurlsOption &&
21+
Array.isArray(suppliedPurls) &&
22+
suppliedPurls.length === 0
23+
) {
24+
return { ok: true, purls: undefined }
25+
}
26+
if (
27+
!Array.isArray(suppliedPurls) ||
28+
!suppliedPurls.every(
29+
(value: unknown): value is string => typeof value === 'string',
30+
)
31+
) {
32+
return {
33+
ok: false,
34+
message: 'Provide at least one non-empty PURL with --purls.',
35+
}
36+
}
37+
return parseDependenciesPurls(suppliedPurls)
38+
}
39+
40+
export function parseDependenciesPurls(
41+
values: readonly string[] | undefined,
42+
): DependenciesPurlsResult {
43+
if (values === undefined) {
44+
return { ok: true, purls: undefined }
45+
}
46+
47+
const purls: string[] = []
48+
const seen = new Set<string>()
49+
50+
for (const value of values) {
51+
const purl = value.trim()
52+
const parsed =
53+
purl.startsWith('pkg:') && purl.length > 4
54+
? getPurlObject(purl, { throws: false })
55+
: undefined
56+
57+
if (!parsed) {
58+
return {
59+
ok: false,
60+
message: `Invalid PURL "${value}". Use a full PURL such as pkg:npm/minimist@1.2.3.`,
61+
}
62+
}
63+
64+
if (Object.keys(parsed.qualifiers ?? {}).length > 0 || parsed.subpath) {
65+
return {
66+
ok: false,
67+
message: `PURL "${value}" includes qualifiers or a subpath, which this search does not support. Search by package identity and version only.`,
68+
}
69+
}
70+
71+
if (!seen.has(purl)) {
72+
seen.add(purl)
73+
purls.push(purl)
74+
}
75+
}
76+
77+
if (purls.length === 0) {
78+
return {
79+
ok: false,
80+
message: 'Provide at least one non-empty PURL with --purls.',
81+
}
82+
}
83+
84+
return { ok: true, purls }
85+
}
86+
87+
export function matchesConfirmedPurls(
88+
requested: readonly string[],
89+
filters: unknown,
90+
): boolean {
91+
if (typeof filters !== 'object' || filters === null) {
92+
return false
93+
}
94+
if (!('valid' in filters) || !('invalid' in filters)) {
95+
return false
96+
}
97+
const { invalid, valid } = filters
98+
if (
99+
!Array.isArray(valid) ||
100+
!valid.every(value => typeof value === 'string') ||
101+
!Array.isArray(invalid) ||
102+
!invalid.every(value => typeof value === 'string') ||
103+
invalid.length > 0
104+
) {
105+
return false
106+
}
107+
const expected = new Set(requested)
108+
const actual = new Set(valid)
109+
return (
110+
expected.size === requested.length &&
111+
actual.size === valid.length &&
112+
expected.size === actual.size &&
113+
[...expected].every(value => actual.has(value))
114+
)
115+
}

0 commit comments

Comments
 (0)