@ulisesgascon/openjs-cna-tools 0.0.0-stage → 1.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +312 -2
- package/bin/openjs-cna.js +128 -0
- package/components/config.js +92 -0
- package/components/convert.js +81 -0
- package/components/get.js +35 -0
- package/components/health.js +30 -0
- package/components/list.js +36 -0
- package/components/llm-review-prompt.js +84 -0
- package/components/preview.js +42 -0
- package/components/publish.js +238 -0
- package/components/reserve.js +48 -0
- package/components/review.js +170 -0
- package/components/update.js +104 -0
- package/lib/auth.js +20 -0
- package/lib/batch.js +141 -0
- package/lib/cli.js +81 -0
- package/lib/client.js +244 -0
- package/lib/component-utils.js +74 -0
- package/lib/config.js +121 -0
- package/lib/convert/ghsa.js +450 -0
- package/lib/convert/version-range.js +251 -0
- package/lib/cvss.js +113 -0
- package/lib/errors.js +44 -0
- package/lib/index.js +9 -0
- package/lib/preview.js +145 -0
- package/lib/prompt.js +170 -0
- package/lib/record.js +123 -0
- package/lib/review/index.js +78 -0
- package/lib/review/rules.js +433 -0
- package/lib/review/schema.js +99 -0
- package/lib/review/schemas/cve-record-format.json +1362 -0
- package/lib/review/schemas/imports/cvss/cvss-v2.0.json +195 -0
- package/lib/review/schemas/imports/cvss/cvss-v3.0.json +577 -0
- package/lib/review/schemas/imports/cvss/cvss-v3.1.json +577 -0
- package/lib/review/schemas/imports/cvss/cvss-v4.0.json +784 -0
- package/lib/review/schemas/tags/adp-tags.json +7 -0
- package/lib/review/schemas/tags/cna-tags.json +7 -0
- package/lib/review/schemas/tags/reference-tags.json +27 -0
- package/package.json +55 -4
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// openjs-cna-experimental health
|
|
2
|
+
// Unauthenticated liveness check against the configured CNA API.
|
|
3
|
+
|
|
4
|
+
import { parseComponentArgs, buildClient, buildCli } from '../lib/component-utils.js'
|
|
5
|
+
|
|
6
|
+
export const command = 'health'
|
|
7
|
+
export const describe = 'Check that the CNA API is reachable.'
|
|
8
|
+
|
|
9
|
+
export async function handler (argv) {
|
|
10
|
+
const parsed = parseComponentArgs(argv)
|
|
11
|
+
if (parsed.values.help) return printHelp()
|
|
12
|
+
const cli = buildCli(parsed)
|
|
13
|
+
const client = buildClient(parsed)
|
|
14
|
+
const status = await client.health()
|
|
15
|
+
cli.ok(`API is reachable (${status?.status ?? 'ok'})`)
|
|
16
|
+
process.stdout.write(JSON.stringify(status, null, 2) + '\n')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function printHelp () {
|
|
20
|
+
process.stderr.write(
|
|
21
|
+
`openjs-cna-experimental health
|
|
22
|
+
|
|
23
|
+
Probe the configured CNA API's /health endpoint. Useful to catch base-url
|
|
24
|
+
misconfiguration before burning a workflow dispatch.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
openjs-cna-experimental health [global flags]
|
|
28
|
+
`
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// openjs-cna-experimental list
|
|
2
|
+
// Lists all CVE ids in the bucket. One id per line by default; --json for array.
|
|
3
|
+
|
|
4
|
+
import { parseComponentArgs, buildClient, buildCli } from '../lib/component-utils.js'
|
|
5
|
+
|
|
6
|
+
export const command = 'list'
|
|
7
|
+
export const describe = 'List all CVEs in the bucket.'
|
|
8
|
+
|
|
9
|
+
export async function handler (argv) {
|
|
10
|
+
const parsed = parseComponentArgs(argv)
|
|
11
|
+
if (parsed.values.help) return printHelp()
|
|
12
|
+
const cli = buildCli(parsed)
|
|
13
|
+
const client = buildClient(parsed)
|
|
14
|
+
cli.info('Listing CVEs…')
|
|
15
|
+
const ids = await client.listCves({
|
|
16
|
+
onPoll: (run) => { if (run.status !== 'completed') cli.info(`run status: ${run.status}`) }
|
|
17
|
+
})
|
|
18
|
+
cli.ok(`Found ${ids.length} CVE id(s)`)
|
|
19
|
+
if (parsed.values.json) {
|
|
20
|
+
process.stdout.write(JSON.stringify(ids, null, 2) + '\n')
|
|
21
|
+
} else {
|
|
22
|
+
for (const id of ids) process.stdout.write(`${id}\n`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printHelp () {
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
`openjs-cna-experimental list
|
|
29
|
+
|
|
30
|
+
List every CVE id in the bucket. Default output: one id per line.
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--json emit a JSON array instead
|
|
34
|
+
`
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// openjs-cna-experimental llm-review-prompt <record.json>
|
|
2
|
+
//
|
|
3
|
+
// Print a prompt for an LLM-driven editorial review of the record's
|
|
4
|
+
// natural-language fields (title + description). The tool does not call
|
|
5
|
+
// an LLM itself. Paste the output into the LLM of your choice. Pure
|
|
6
|
+
// read; no network.
|
|
7
|
+
|
|
8
|
+
import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
|
|
9
|
+
import { buildAuditPrompt } from '../lib/prompt.js'
|
|
10
|
+
|
|
11
|
+
export const command = 'llm-review-prompt'
|
|
12
|
+
export const describe = 'Print a prompt for an LLM editorial review of the record\'s title + description.'
|
|
13
|
+
|
|
14
|
+
export async function handler (argv) {
|
|
15
|
+
const parsed = parseComponentArgs(argv, {}, { allowPositionals: true })
|
|
16
|
+
if (parsed.values.help) return printHelp()
|
|
17
|
+
|
|
18
|
+
// Keep buildCli for help/diagnostic plumbing even though we don't
|
|
19
|
+
// print status. Keeps the component shape consistent with peers.
|
|
20
|
+
buildCli(parsed)
|
|
21
|
+
|
|
22
|
+
const file = parsed.positionals[0]
|
|
23
|
+
const raw = await readFileOrStdin(file)
|
|
24
|
+
let record
|
|
25
|
+
try {
|
|
26
|
+
record = JSON.parse(raw)
|
|
27
|
+
} catch (e) {
|
|
28
|
+
process.stderr.write(`✖ Record is not valid JSON: ${e.message}\n`)
|
|
29
|
+
process.exit(64)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Banner goes to stderr so a pipe like `| pbcopy` or `| llm` only
|
|
33
|
+
// captures the prompt itself, not the human-facing how-to.
|
|
34
|
+
process.stderr.write(buildBanner())
|
|
35
|
+
process.stdout.write(buildAuditPrompt(record))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildBanner () {
|
|
39
|
+
const lines = [
|
|
40
|
+
'How to use this prompt',
|
|
41
|
+
'',
|
|
42
|
+
'1. Paste everything that follows into an LLM (Claude, ChatGPT,',
|
|
43
|
+
' Gemini, etc.).',
|
|
44
|
+
'2. Also attach the full CVE Record JSON so the LLM can cross-check',
|
|
45
|
+
' the natural-language fields against the rest of the record.',
|
|
46
|
+
'3. The LLM returns a Markdown review you can act on.'
|
|
47
|
+
]
|
|
48
|
+
const width = Math.max(...lines.map(l => l.length))
|
|
49
|
+
const top = '┌' + '─'.repeat(width + 2) + '┐'
|
|
50
|
+
const bottom = '└' + '─'.repeat(width + 2) + '┘'
|
|
51
|
+
const body = lines.map(l => '│ ' + l.padEnd(width) + ' │').join('\n')
|
|
52
|
+
return top + '\n' + body + '\n' + bottom + '\n'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function printHelp () {
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
`openjs-cna-experimental llm-review-prompt <record.json>
|
|
58
|
+
|
|
59
|
+
Build a prompt that asks an LLM for an editorial review of the record's
|
|
60
|
+
title and description. The prompt includes the OpenJS naming and tone
|
|
61
|
+
guidelines, the texts to audit, and a short facts block (affected,
|
|
62
|
+
CVSS, CWE) so the LLM can ground its suggestions.
|
|
63
|
+
|
|
64
|
+
The tool does not call any LLM. Paste the output into the LLM of your
|
|
65
|
+
choice and read the reply. Attach the full CVE Record JSON in the same
|
|
66
|
+
turn so the LLM can cross-check the prose against the structured data.
|
|
67
|
+
|
|
68
|
+
Reads the record from a file (positional) or from stdin if the
|
|
69
|
+
positional is '-' or absent.
|
|
70
|
+
|
|
71
|
+
A how-to banner is written to stderr so a pipe like \`| pbcopy\` only
|
|
72
|
+
captures the prompt itself.
|
|
73
|
+
|
|
74
|
+
Pipeline use (audit a freshly converted draft before publish):
|
|
75
|
+
|
|
76
|
+
openjs-cna-experimental convert https://github.com/<owner>/<repo>/security/advisories/<GHSA> \\
|
|
77
|
+
| tee draft.json \\
|
|
78
|
+
| openjs-cna-experimental llm-review-prompt - | pbcopy
|
|
79
|
+
|
|
80
|
+
Open the LLM, paste, attach \`draft.json\`, then iterate before
|
|
81
|
+
\`openjs-cna-experimental publish\`.
|
|
82
|
+
`
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// openjs-cna-experimental preview <container.json>
|
|
2
|
+
// Compact terminal view of a v5.2 CNA Container. Pure read; no network.
|
|
3
|
+
|
|
4
|
+
import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
|
|
5
|
+
import { renderPreview } from '../lib/preview.js'
|
|
6
|
+
|
|
7
|
+
export const command = 'preview'
|
|
8
|
+
export const describe = 'Print a compact preview of a CNA Container.'
|
|
9
|
+
|
|
10
|
+
export async function handler (argv) {
|
|
11
|
+
const parsed = parseComponentArgs(argv, {}, { allowPositionals: true })
|
|
12
|
+
if (parsed.values.help) return printHelp()
|
|
13
|
+
|
|
14
|
+
const cli = buildCli(parsed)
|
|
15
|
+
const file = parsed.positionals[0]
|
|
16
|
+
const raw = await readFileOrStdin(file)
|
|
17
|
+
let container
|
|
18
|
+
try {
|
|
19
|
+
container = JSON.parse(raw)
|
|
20
|
+
} catch (e) {
|
|
21
|
+
process.stderr.write(`✖ Container is not valid JSON: ${e.message}\n`)
|
|
22
|
+
process.exit(64)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
renderPreview(container, cli)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printHelp () {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
`openjs-cna-experimental preview <container.json>
|
|
31
|
+
|
|
32
|
+
Print a compact summary of the container — title, description, affected
|
|
33
|
+
products, CVSS, CWE, credits, references, and the x_generator engine.
|
|
34
|
+
|
|
35
|
+
Useful as a quick sanity check before publish, or as a step in a pipeline
|
|
36
|
+
('openjs-cna-experimental get CVE-... | openjs-cna-experimental preview -').
|
|
37
|
+
|
|
38
|
+
Reads the container from a file (positional) or from stdin if the
|
|
39
|
+
positional is '-' or absent.
|
|
40
|
+
`
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// openjs-cna-experimental publish <cve-id> [-f record.json] [--skip-review] [--no-strict]
|
|
2
|
+
// openjs-cna-experimental publish -f a.json -f b.json … [--skip-published] [--json]
|
|
3
|
+
//
|
|
4
|
+
// Single record: cross-checks the CLI <cve-id> against `record.cveMetadata.cveId`
|
|
5
|
+
// if present (hard-error on mismatch); the CLI arg is the sole source of
|
|
6
|
+
// truth when the record has none. Runs the reviewer (strict by default —
|
|
7
|
+
// warnings block too). On a clean review (or `--skip-review`), prompts for
|
|
8
|
+
// confirmation and publishes. Surfaces `correlation_id` + `run_id` after
|
|
9
|
+
// publish so the operator can forward them to a CNA coordinator if needed.
|
|
10
|
+
//
|
|
11
|
+
// Batch (no <cve-id>, one or more -f): every record must carry
|
|
12
|
+
// cveMetadata.cveId. All records are reviewed first and nothing is sent
|
|
13
|
+
// unless all of them pass; then each one is published in order, a failure
|
|
14
|
+
// is recorded and the rest still go out. One confirmation covers the whole
|
|
15
|
+
// batch. --skip-published asks the CNA first and skips ids that already
|
|
16
|
+
// have a published record, so reruns after a partial failure stay green.
|
|
17
|
+
// --json prints one result per record on stdout. Exit code is 1 when the
|
|
18
|
+
// review blocked or any record failed.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
parseComponentArgs,
|
|
22
|
+
buildClient,
|
|
23
|
+
buildCli,
|
|
24
|
+
readFileOrStdin
|
|
25
|
+
} from '../lib/component-utils.js'
|
|
26
|
+
import { reviewRecord } from '../lib/review/index.js'
|
|
27
|
+
import { publishRecords } from '../lib/batch.js'
|
|
28
|
+
import { CnaReviewBlockedError } from '../lib/errors.js'
|
|
29
|
+
|
|
30
|
+
export const command = 'publish'
|
|
31
|
+
export const describe = 'Publish one or more v5.2 CVE Records for reserved CVE ids.'
|
|
32
|
+
|
|
33
|
+
export async function handler (argv) {
|
|
34
|
+
const parsed = parseComponentArgs(argv, {
|
|
35
|
+
file: { type: 'string', short: 'f', multiple: true },
|
|
36
|
+
'skip-review': { type: 'boolean' },
|
|
37
|
+
'no-strict': { type: 'boolean' },
|
|
38
|
+
'skip-published': { type: 'boolean' }
|
|
39
|
+
}, { allowPositionals: true })
|
|
40
|
+
if (parsed.values.help) return printHelp()
|
|
41
|
+
|
|
42
|
+
const cveId = parsed.positionals[0]
|
|
43
|
+
const files = parsed.values.file ?? []
|
|
44
|
+
const strict = !parsed.values['no-strict']
|
|
45
|
+
const skipReview = parsed.values['skip-review']
|
|
46
|
+
|
|
47
|
+
if (!cveId) {
|
|
48
|
+
if (files.length === 0) {
|
|
49
|
+
process.stderr.write('✖ Missing CVE id. Usage: openjs-cna-experimental publish <CVE-...> -f record.json\n' +
|
|
50
|
+
' or, for a batch: openjs-cna-experimental publish -f a.json -f b.json\n')
|
|
51
|
+
process.exit(64)
|
|
52
|
+
}
|
|
53
|
+
return publishMany(files, {
|
|
54
|
+
parsed, strict, skipReview, skipPublished: parsed.values['skip-published']
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
if (files.length > 1) {
|
|
58
|
+
process.stderr.write('✖ Several -f files need batch mode: drop the <cve-id> argument.\n')
|
|
59
|
+
process.exit(64)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cli = buildCli(parsed)
|
|
63
|
+
const client = buildClient(parsed)
|
|
64
|
+
const record = await readRecord(files[0])
|
|
65
|
+
|
|
66
|
+
// CVE id consistency check. The Record's cveMetadata.cveId must match
|
|
67
|
+
// the CLI arg; mismatch is a hard error (operator was probably about to
|
|
68
|
+
// publish the wrong record). If cveMetadata is missing, the CLI arg is
|
|
69
|
+
// the sole source of truth.
|
|
70
|
+
const recordCveId = record?.cveMetadata?.cveId
|
|
71
|
+
if (recordCveId && recordCveId !== cveId) {
|
|
72
|
+
process.stderr.write(
|
|
73
|
+
`✖ CVE id mismatch: CLI argument is ${cveId}, record.cveMetadata.cveId is ${recordCveId}.\n` +
|
|
74
|
+
' Aborting before publish to prevent updating the wrong record.\n'
|
|
75
|
+
)
|
|
76
|
+
process.exit(64)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Extract the inner cna container — the workflow expects just that,
|
|
80
|
+
// not the full Record (it builds the Record server-side with the
|
|
81
|
+
// assignerOrgId and dateUpdated MITRE injects).
|
|
82
|
+
const container = record.containers.cna
|
|
83
|
+
|
|
84
|
+
if (!skipReview) {
|
|
85
|
+
const report = reviewRecord(record, { strict })
|
|
86
|
+
// Strict mode promotes warnings to blockers inside reviewRecord, so
|
|
87
|
+
// the `summary.blockers` count already reflects strictness. We just
|
|
88
|
+
// need to check that single number.
|
|
89
|
+
if (report.summary.blockers > 0) {
|
|
90
|
+
cli.error(
|
|
91
|
+
`Review blocked publish: ${report.summary.blockers} blocker(s)`
|
|
92
|
+
)
|
|
93
|
+
printIssues(cli, report.issues)
|
|
94
|
+
cli.log('')
|
|
95
|
+
cli.log(' Re-run with --skip-review to bypass.')
|
|
96
|
+
throw new CnaReviewBlockedError(
|
|
97
|
+
`Review blocked publish for ${cveId}`,
|
|
98
|
+
{ report }
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
if (report.summary.warnings) {
|
|
102
|
+
cli.warn(`Review surfaced ${report.summary.warnings} warning(s); proceeding.`)
|
|
103
|
+
} else {
|
|
104
|
+
cli.ok(`Review clean (${report.summary.suggestions} suggestion(s)).`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Confirmation prompt. Defaults to "yes" only when the review was clean
|
|
109
|
+
// (and we got here without --skip-review). Honors --yes / OPENJS_CNA_YES.
|
|
110
|
+
const confirmed = await cli.prompt(
|
|
111
|
+
`Publish ${cveId}? This is irreversible.`,
|
|
112
|
+
{ defaultAnswer: !skipReview }
|
|
113
|
+
)
|
|
114
|
+
if (!confirmed) {
|
|
115
|
+
cli.info('Aborted.')
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
cli.info(`Publishing ${cveId}…`)
|
|
120
|
+
const { correlationId, runId, result } = await client.publishCve(cveId, container, {
|
|
121
|
+
onPoll: (run) => {
|
|
122
|
+
if (run.status && run.status !== 'completed') cli.info(`run status: ${run.status}`)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
cli.ok(`Published ${cveId}`)
|
|
126
|
+
if (correlationId || runId) {
|
|
127
|
+
cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
|
|
128
|
+
}
|
|
129
|
+
if (result) process.stdout.write(JSON.stringify(result, null, 2) + '\n')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function publishMany (files, { parsed, strict, skipReview, skipPublished }) {
|
|
133
|
+
const cli = buildCli(parsed)
|
|
134
|
+
const entries = []
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
entries.push({ file, record: await readRecord(file) })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The reviewer runs inside publishRecords too; this early pass exists so
|
|
140
|
+
// the operator sees the ids before confirming, and so a missing id is
|
|
141
|
+
// caught before credentials are even resolved.
|
|
142
|
+
const ids = entries.map((entry) => entry.record?.cveMetadata?.cveId ?? `${entry.file} (no cveMetadata.cveId)`)
|
|
143
|
+
cli.info(`Publishing ${entries.length} record(s): ${ids.join(', ')}`)
|
|
144
|
+
const confirmed = await cli.prompt(
|
|
145
|
+
`Publish ${entries.length} record(s)? This is irreversible.`,
|
|
146
|
+
{ defaultAnswer: !skipReview }
|
|
147
|
+
)
|
|
148
|
+
if (!confirmed) {
|
|
149
|
+
cli.info('Aborted.')
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const client = buildClient(parsed)
|
|
154
|
+
const outcome = await publishRecords(entries, {
|
|
155
|
+
client,
|
|
156
|
+
strict,
|
|
157
|
+
skipReview,
|
|
158
|
+
skipPublished,
|
|
159
|
+
onProgress: (event) => {
|
|
160
|
+
const trace = traceOf(event)
|
|
161
|
+
if (event.status === 'published') cli.ok(`Published ${event.cveId}${trace}`)
|
|
162
|
+
else if (event.status === 'skipped') cli.info(`Skipped ${event.cveId}: ${event.reason}`)
|
|
163
|
+
else cli.error(`Failed ${event.cveId}: ${event.error}${trace}`)
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
if (outcome.blocked) {
|
|
168
|
+
const blocked = outcome.review.filter((item) => !item.ready)
|
|
169
|
+
cli.error(`Review blocked publish: ${blocked.length} of ${outcome.review.length} record(s) have blockers. Nothing was sent.`)
|
|
170
|
+
for (const item of blocked) {
|
|
171
|
+
cli.log(` ${item.file}${item.cveId ? ` (${item.cveId})` : ''}`)
|
|
172
|
+
printIssues(cli, item.issues.filter((issue) => issue.severity === 'blocker'), ' ')
|
|
173
|
+
}
|
|
174
|
+
if (parsed.values.json) process.stdout.write(JSON.stringify(outcome.review, null, 2) + '\n')
|
|
175
|
+
process.exit(1)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (parsed.values.json) process.stdout.write(JSON.stringify(outcome.results, null, 2) + '\n')
|
|
179
|
+
const failed = outcome.results.filter((item) => item.status === 'failed')
|
|
180
|
+
const published = outcome.results.filter((item) => item.status === 'published').length
|
|
181
|
+
const skipped = outcome.results.filter((item) => item.status === 'skipped').length
|
|
182
|
+
cli.log('')
|
|
183
|
+
cli.info(`${published} published · ${skipped} skipped · ${failed.length} failed`)
|
|
184
|
+
if (failed.length) {
|
|
185
|
+
cli.info('Forward the correlation_id / run_id pairs above to your CNA coordinator.')
|
|
186
|
+
process.exit(1)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function traceOf (event) {
|
|
191
|
+
const parts = []
|
|
192
|
+
if (event.correlationId) parts.push(`correlation_id=${event.correlationId}`)
|
|
193
|
+
if (event.runId) parts.push(`run_id=${event.runId}`)
|
|
194
|
+
return parts.length ? ` (${parts.join(', ')})` : ''
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function printIssues (cli, issues, indent = ' ') {
|
|
198
|
+
for (const issue of issues) {
|
|
199
|
+
const tag = issue.severity === 'blocker' ? '✖' : issue.severity === 'warning' ? '⚠' : '·'
|
|
200
|
+
cli.log(`${indent}${tag} [${issue.rule}] ${issue.message}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function readRecord (file) {
|
|
205
|
+
const raw = await readFileOrStdin(file)
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(raw)
|
|
208
|
+
} catch (e) {
|
|
209
|
+
process.stderr.write(`✖ ${file ?? 'stdin'} is not valid JSON: ${e.message}\n`)
|
|
210
|
+
process.exit(64)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function printHelp () {
|
|
215
|
+
process.stderr.write(
|
|
216
|
+
`openjs-cna-experimental publish <cve-id> [-f record.json]
|
|
217
|
+
openjs-cna-experimental publish -f a.json -f b.json … [--skip-published]
|
|
218
|
+
|
|
219
|
+
Publish v5.2 CVE Records against already-reserved CVE ids. Default
|
|
220
|
+
behavior runs the reviewer first (strict — warnings block too) and aborts
|
|
221
|
+
on any issue.
|
|
222
|
+
|
|
223
|
+
Without <cve-id> every -f record is published as a batch: each record must
|
|
224
|
+
carry cveMetadata.cveId, all records are reviewed before anything is sent,
|
|
225
|
+
then each is published in order (a failure does not stop the others). One
|
|
226
|
+
confirmation covers the batch. Exit code is 1 if the review blocked or any
|
|
227
|
+
record failed.
|
|
228
|
+
|
|
229
|
+
Options:
|
|
230
|
+
-f, --file <path> read a record from a file (default: stdin); repeatable
|
|
231
|
+
--skip-review do not run the reviewer
|
|
232
|
+
--no-strict let warnings pass through (only true blockers abort)
|
|
233
|
+
--skip-published batch only: skip ids the CNA already has published
|
|
234
|
+
--json batch only: print one result per record on stdout
|
|
235
|
+
[global flags]
|
|
236
|
+
`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// openjs-cna-experimental reserve
|
|
2
|
+
// Reserves a CVE id via the CNA API and prints it on stdout.
|
|
3
|
+
|
|
4
|
+
import { parseComponentArgs, buildClient, buildCli } from '../lib/component-utils.js'
|
|
5
|
+
|
|
6
|
+
export const command = 'reserve'
|
|
7
|
+
export const describe = 'Reserve a CVE id via the OpenJS Foundation CNA.'
|
|
8
|
+
|
|
9
|
+
export async function handler (argv) {
|
|
10
|
+
const parsed = parseComponentArgs(argv)
|
|
11
|
+
if (parsed.values.help) return printHelp()
|
|
12
|
+
const cli = buildCli(parsed)
|
|
13
|
+
const client = buildClient(parsed)
|
|
14
|
+
|
|
15
|
+
cli.info('Reserving CVE…')
|
|
16
|
+
const { cveId, correlationId, runId } = await client.reserveCve({
|
|
17
|
+
onPoll: (run) => {
|
|
18
|
+
if (run.status && run.status !== 'completed') {
|
|
19
|
+
// Honest progress — one line per poll cycle, no animation.
|
|
20
|
+
cli.info(`run status: ${run.status}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
cli.ok(`Reserved ${cveId}`)
|
|
25
|
+
if (correlationId || runId) {
|
|
26
|
+
cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Data on stdout, no decoration — `CVE=$(openjs-cna-experimental reserve)` Just Works.
|
|
30
|
+
process.stdout.write(`${cveId}\n`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printHelp () {
|
|
34
|
+
process.stderr.write(
|
|
35
|
+
`openjs-cna-experimental reserve
|
|
36
|
+
|
|
37
|
+
Reserve a CVE id from the configured bucket. Prints the CVE id on stdout
|
|
38
|
+
once the workflow completes.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
openjs-cna-experimental reserve [global flags]
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
CVE=$(openjs-cna-experimental reserve)
|
|
45
|
+
openjs-cna-experimental publish "$CVE" -f container.json
|
|
46
|
+
`
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// openjs-cna-experimental review <record.json> [<record.json>…] [--no-strict] [--no-preview] [--tier <name>] [--json]
|
|
2
|
+
//
|
|
3
|
+
// Runs the offline reviewer against one or more CVE Records. Strict by
|
|
4
|
+
// default (warnings block); --no-strict surfaces them as warnings. Renders
|
|
5
|
+
// the preview before the issues list unless --no-preview. --tier filters
|
|
6
|
+
// the printed issues to a single severity tier (mitre-common / openjs-extra)
|
|
7
|
+
// without changing the exit code, which always reflects the full review.
|
|
8
|
+
//
|
|
9
|
+
// With several files every record is reviewed (no early stop), each block
|
|
10
|
+
// is headed by the file and CVE id, --json emits an array, and the exit
|
|
11
|
+
// code is 1 when any record has a blocker. A record without
|
|
12
|
+
// cveMetadata.cveId is a blocker in batch mode, since batch publish needs
|
|
13
|
+
// the id from the record.
|
|
14
|
+
|
|
15
|
+
import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
|
|
16
|
+
import { reviewRecord } from '../lib/review/index.js'
|
|
17
|
+
import { reviewRecords } from '../lib/batch.js'
|
|
18
|
+
import { renderPreview } from '../lib/preview.js'
|
|
19
|
+
|
|
20
|
+
export const command = 'review'
|
|
21
|
+
export const describe = 'Review one or more v5.2 CVE Records against best-practice rules.'
|
|
22
|
+
|
|
23
|
+
const VALID_TIERS = new Set(['mitre-common', 'openjs-extra'])
|
|
24
|
+
|
|
25
|
+
export async function handler (argv) {
|
|
26
|
+
const parsed = parseComponentArgs(argv, {
|
|
27
|
+
'no-strict': { type: 'boolean' },
|
|
28
|
+
'no-preview': { type: 'boolean' },
|
|
29
|
+
tier: { type: 'string' }
|
|
30
|
+
}, { allowPositionals: true })
|
|
31
|
+
if (parsed.values.help) return printHelp()
|
|
32
|
+
|
|
33
|
+
const cli = buildCli(parsed)
|
|
34
|
+
const files = parsed.positionals
|
|
35
|
+
|
|
36
|
+
// Validate the --tier filter early so a typo doesn't silently match
|
|
37
|
+
// nothing and look like a clean review.
|
|
38
|
+
const tierFilter = parsed.values.tier
|
|
39
|
+
if (tierFilter && !VALID_TIERS.has(tierFilter)) {
|
|
40
|
+
process.stderr.write(
|
|
41
|
+
`✖ Unknown --tier '${tierFilter}'. Expected one of: ${[...VALID_TIERS].join(', ')}\n`
|
|
42
|
+
)
|
|
43
|
+
process.exit(64)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const strict = !parsed.values['no-strict']
|
|
47
|
+
const showPreview = !parsed.values['no-preview']
|
|
48
|
+
|
|
49
|
+
if (files.length > 1) {
|
|
50
|
+
return reviewMany(files, { cli, strict, showPreview, tierFilter, json: parsed.values.json })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const container = await readRecord(files[0])
|
|
54
|
+
const report = reviewRecord(container, { strict })
|
|
55
|
+
|
|
56
|
+
if (parsed.values.json) {
|
|
57
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
58
|
+
} else {
|
|
59
|
+
// Preview goes first so the operator sees what they're about to ship
|
|
60
|
+
// alongside what the reviewer caught. --no-preview drops it for
|
|
61
|
+
// CI / pipeline use.
|
|
62
|
+
if (showPreview) renderPreview(container, cli)
|
|
63
|
+
printHumanReport(cli, report, { tierFilter })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Exit code: 0 if ready, 1 if blockers (or strict + warnings).
|
|
67
|
+
if (report.summary.blockers > 0) process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function reviewMany (files, { cli, strict, showPreview, tierFilter, json }) {
|
|
71
|
+
const entries = []
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
entries.push({ file, record: await readRecord(file) })
|
|
74
|
+
}
|
|
75
|
+
const review = reviewRecords(entries, { strict })
|
|
76
|
+
|
|
77
|
+
if (json) {
|
|
78
|
+
process.stdout.write(JSON.stringify(review, null, 2) + '\n')
|
|
79
|
+
} else {
|
|
80
|
+
review.forEach((item, index) => {
|
|
81
|
+
cli.log('')
|
|
82
|
+
cli.log(`── ${item.file}${item.cveId ? ` (${item.cveId})` : ''}`)
|
|
83
|
+
if (showPreview) renderPreview(entries[index].record, cli)
|
|
84
|
+
printHumanReport(cli, item, { tierFilter })
|
|
85
|
+
})
|
|
86
|
+
const blocked = review.filter((item) => !item.ready).length
|
|
87
|
+
cli.log('')
|
|
88
|
+
if (blocked) {
|
|
89
|
+
cli.error(`${blocked} of ${review.length} record(s) blocked`)
|
|
90
|
+
} else {
|
|
91
|
+
cli.ok(`${review.length} record(s) ready`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (review.some((item) => !item.ready)) process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readRecord (file) {
|
|
99
|
+
const raw = await readFileOrStdin(file)
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(raw)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
process.stderr.write(`✖ ${file ?? 'stdin'} is not valid JSON: ${e.message}\n`)
|
|
104
|
+
process.exit(64)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function printHumanReport (cli, report, { tierFilter } = {}) {
|
|
109
|
+
const { summary, issues } = report
|
|
110
|
+
const ratingLabel = {
|
|
111
|
+
ready: '✔ ready',
|
|
112
|
+
'needs-work': '⚠ needs work',
|
|
113
|
+
blocked: '✖ blocked'
|
|
114
|
+
}[summary.rating]
|
|
115
|
+
cli.log('')
|
|
116
|
+
cli.log(`Review: ${ratingLabel}`)
|
|
117
|
+
cli.log(` ${summary.blockers} blocker(s) · ${summary.warnings} warning(s) · ${summary.suggestions} suggestion(s)`)
|
|
118
|
+
if (tierFilter) {
|
|
119
|
+
cli.log(` (filtered to tier: ${tierFilter})`)
|
|
120
|
+
}
|
|
121
|
+
cli.log('')
|
|
122
|
+
// Filter at print time so the summary counts above still reflect the full
|
|
123
|
+
// review. This keeps `--tier mitre-common` from hiding the broader picture
|
|
124
|
+
// while still letting operators focus on the must-fix items.
|
|
125
|
+
const shown = tierFilter
|
|
126
|
+
? issues.filter(i => i.tier === tierFilter)
|
|
127
|
+
: issues
|
|
128
|
+
if (shown.length === 0) {
|
|
129
|
+
if (tierFilter && issues.length > 0) {
|
|
130
|
+
cli.log(` No ${tierFilter} issues found.`)
|
|
131
|
+
} else {
|
|
132
|
+
cli.log(' No issues found.')
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
for (const issue of shown) {
|
|
137
|
+
const tag = issue.severity === 'blocker'
|
|
138
|
+
? '✖'
|
|
139
|
+
: issue.severity === 'warning'
|
|
140
|
+
? '⚠'
|
|
141
|
+
: '·'
|
|
142
|
+
// Tier rides inline with the rule id so a glance at the report tells
|
|
143
|
+
// the operator whether MITRE would have caught the issue too.
|
|
144
|
+
const tierLabel = issue.tier ? `${issue.tier}: ` : ''
|
|
145
|
+
cli.log(` ${tag} [${tierLabel}${issue.rule}] ${issue.path}`)
|
|
146
|
+
cli.log(` ${issue.message}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function printHelp () {
|
|
151
|
+
process.stderr.write(
|
|
152
|
+
`openjs-cna-experimental review <record.json> [<record.json>…]
|
|
153
|
+
|
|
154
|
+
Run the offline reviewer against one or more CVE Records. Strict by
|
|
155
|
+
default — warnings are treated as blockers because publishing to MITRE is
|
|
156
|
+
irreversible. Pass --no-strict to surface warnings as warnings.
|
|
157
|
+
|
|
158
|
+
With several files every record is reviewed, --json emits an array with
|
|
159
|
+
one entry per file, and the exit code is 1 when any record has a blocker.
|
|
160
|
+
|
|
161
|
+
Options:
|
|
162
|
+
--no-strict surface warnings as warnings (default treats them as blockers)
|
|
163
|
+
--json emit a structured JSON report instead of the human view
|
|
164
|
+
--no-preview skip the record preview (for CI / pipelines)
|
|
165
|
+
--tier <tier> filter the printed issue list to a single tier
|
|
166
|
+
(mitre-common | openjs-extra). Summary counts still
|
|
167
|
+
reflect the full review; exit code is unaffected.
|
|
168
|
+
`
|
|
169
|
+
)
|
|
170
|
+
}
|