@ulisesgascon/openjs-cna-tools 0.0.0-stage → 1.0.0-alpha.1
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 +203 -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 +133 -0
- package/components/reserve.js +48 -0
- package/components/review.js +123 -0
- package/components/update.js +104 -0
- package/lib/auth.js +20 -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 +493 -0
- package/lib/convert/version-range.js +251 -0
- package/lib/errors.js +44 -0
- package/lib/index.js +9 -0
- package/lib/preview.js +144 -0
- package/lib/prompt.js +165 -0
- package/lib/review/index.js +78 -0
- package/lib/review/rules.js +382 -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 +52 -4
|
@@ -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,133 @@
|
|
|
1
|
+
// openjs-cna-experimental publish <cve-id> [-f container.json] [--skip-review] [--no-strict]
|
|
2
|
+
//
|
|
3
|
+
// Cross-checks the CLI <cve-id> against `container.cveMetadata.cveId` if
|
|
4
|
+
// present (hard-error on mismatch); fills cveMetadata in when absent.
|
|
5
|
+
// Then runs the reviewer (strict by default — warnings block too). On a
|
|
6
|
+
// clean review (or `--skip-review`), prompts for confirmation and
|
|
7
|
+
// publishes. Surfaces `correlation_id` + `run_id` after publish so the
|
|
8
|
+
// operator can forward to a CNA coordinator if needed.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
parseComponentArgs,
|
|
12
|
+
buildClient,
|
|
13
|
+
buildCli,
|
|
14
|
+
readFileOrStdin
|
|
15
|
+
} from '../lib/component-utils.js'
|
|
16
|
+
import { reviewRecord } from '../lib/review/index.js'
|
|
17
|
+
import { CnaReviewBlockedError } from '../lib/errors.js'
|
|
18
|
+
|
|
19
|
+
export const command = 'publish'
|
|
20
|
+
export const describe = 'Publish a v5.2 CNA Container for a reserved CVE.'
|
|
21
|
+
|
|
22
|
+
export async function handler (argv) {
|
|
23
|
+
const parsed = parseComponentArgs(argv, {
|
|
24
|
+
file: { type: 'string', short: 'f' },
|
|
25
|
+
'skip-review': { type: 'boolean' },
|
|
26
|
+
'no-strict': { type: 'boolean' }
|
|
27
|
+
}, { allowPositionals: true })
|
|
28
|
+
if (parsed.values.help) return printHelp()
|
|
29
|
+
|
|
30
|
+
const cveId = parsed.positionals[0]
|
|
31
|
+
if (!cveId) {
|
|
32
|
+
process.stderr.write('✖ Missing CVE id. Usage: openjs-cna-experimental publish <CVE-...> -f container.json\n')
|
|
33
|
+
process.exit(64)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cli = buildCli(parsed)
|
|
37
|
+
const client = buildClient(parsed)
|
|
38
|
+
|
|
39
|
+
const raw = await readFileOrStdin(parsed.values.file)
|
|
40
|
+
let record
|
|
41
|
+
try {
|
|
42
|
+
record = JSON.parse(raw)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
process.stderr.write(`✖ File is not valid JSON: ${e.message}\n`)
|
|
45
|
+
process.exit(64)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// CVE id consistency check. The Record's cveMetadata.cveId must match
|
|
49
|
+
// the CLI arg; mismatch is a hard error (operator was probably about to
|
|
50
|
+
// publish the wrong record). If cveMetadata is missing, the CLI arg is
|
|
51
|
+
// the sole source of truth.
|
|
52
|
+
const recordCveId = record?.cveMetadata?.cveId
|
|
53
|
+
if (recordCveId && recordCveId !== cveId) {
|
|
54
|
+
process.stderr.write(
|
|
55
|
+
`✖ CVE id mismatch: CLI argument is ${cveId}, record.cveMetadata.cveId is ${recordCveId}.\n` +
|
|
56
|
+
' Aborting before publish to prevent updating the wrong record.\n'
|
|
57
|
+
)
|
|
58
|
+
process.exit(64)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Extract the inner cna container — the workflow expects just that,
|
|
62
|
+
// not the full Record (it builds the Record server-side with the
|
|
63
|
+
// assignerOrgId and dateUpdated MITRE injects).
|
|
64
|
+
const container = record.containers.cna
|
|
65
|
+
|
|
66
|
+
if (!parsed.values['skip-review']) {
|
|
67
|
+
const strict = !parsed.values['no-strict']
|
|
68
|
+
const report = reviewRecord(record, { strict })
|
|
69
|
+
// Strict mode promotes warnings to blockers inside reviewRecord, so
|
|
70
|
+
// the `summary.blockers` count already reflects strictness. We just
|
|
71
|
+
// need to check that single number.
|
|
72
|
+
if (report.summary.blockers > 0) {
|
|
73
|
+
cli.error(
|
|
74
|
+
`Review blocked publish: ${report.summary.blockers} blocker(s)`
|
|
75
|
+
)
|
|
76
|
+
for (const issue of report.issues) {
|
|
77
|
+
const tag = issue.severity === 'blocker' ? '✖' : issue.severity === 'warning' ? '⚠' : '·'
|
|
78
|
+
cli.log(` ${tag} [${issue.rule}] ${issue.message}`)
|
|
79
|
+
}
|
|
80
|
+
cli.log('')
|
|
81
|
+
cli.log(' Re-run with --skip-review to bypass.')
|
|
82
|
+
throw new CnaReviewBlockedError(
|
|
83
|
+
`Review blocked publish for ${cveId}`,
|
|
84
|
+
{ report }
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
if (report.summary.warnings) {
|
|
88
|
+
cli.warn(`Review surfaced ${report.summary.warnings} warning(s); proceeding.`)
|
|
89
|
+
} else {
|
|
90
|
+
cli.ok(`Review clean (${report.summary.suggestions} suggestion(s)).`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Confirmation prompt. Defaults to "yes" only when the review was clean
|
|
95
|
+
// (and we got here without --skip-review). Honors --yes / OPENJS_CNA_YES.
|
|
96
|
+
const confirmed = await cli.prompt(
|
|
97
|
+
`Publish ${cveId}? This is irreversible.`,
|
|
98
|
+
{ defaultAnswer: !parsed.values['skip-review'] }
|
|
99
|
+
)
|
|
100
|
+
if (!confirmed) {
|
|
101
|
+
cli.info('Aborted.')
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
cli.info(`Publishing ${cveId}…`)
|
|
106
|
+
const { correlationId, runId, result } = await client.publishCve(cveId, container, {
|
|
107
|
+
onPoll: (run) => {
|
|
108
|
+
if (run.status && run.status !== 'completed') cli.info(`run status: ${run.status}`)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
cli.ok(`Published ${cveId}`)
|
|
112
|
+
if (correlationId || runId) {
|
|
113
|
+
cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
|
|
114
|
+
}
|
|
115
|
+
if (result) process.stdout.write(JSON.stringify(result, null, 2) + '\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function printHelp () {
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
`openjs-cna-experimental publish <cve-id> [-f container.json]
|
|
121
|
+
|
|
122
|
+
Publish a v5.2 CNA Container against an already-reserved CVE id. Default
|
|
123
|
+
behavior runs the reviewer first (strict — warnings block too) and aborts
|
|
124
|
+
on any issue.
|
|
125
|
+
|
|
126
|
+
Options:
|
|
127
|
+
-f, --file <path> read the container from a file (default: stdin)
|
|
128
|
+
--skip-review do not run the reviewer
|
|
129
|
+
--no-strict let warnings pass through (only true blockers abort)
|
|
130
|
+
[global flags]
|
|
131
|
+
`
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -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,123 @@
|
|
|
1
|
+
// openjs-cna-experimental review <container.json> [--no-strict] [--no-preview] [--tier <name>] [--json]
|
|
2
|
+
//
|
|
3
|
+
// Runs the offline reviewer against a CNA Container. Strict by default
|
|
4
|
+
// (warnings block); --no-strict surfaces them as warnings. Renders the
|
|
5
|
+
// preview before the issues list unless --no-preview. --tier filters the
|
|
6
|
+
// 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
|
+
import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
|
|
10
|
+
import { reviewRecord } from '../lib/review/index.js'
|
|
11
|
+
import { renderPreview } from '../lib/preview.js'
|
|
12
|
+
|
|
13
|
+
export const command = 'review'
|
|
14
|
+
export const describe = 'Review a v5.2 CNA Container against best-practice rules.'
|
|
15
|
+
|
|
16
|
+
const VALID_TIERS = new Set(['mitre-common', 'openjs-extra'])
|
|
17
|
+
|
|
18
|
+
export async function handler (argv) {
|
|
19
|
+
const parsed = parseComponentArgs(argv, {
|
|
20
|
+
'no-strict': { type: 'boolean' },
|
|
21
|
+
'no-preview': { type: 'boolean' },
|
|
22
|
+
tier: { type: 'string' }
|
|
23
|
+
}, { allowPositionals: true })
|
|
24
|
+
if (parsed.values.help) return printHelp()
|
|
25
|
+
|
|
26
|
+
const cli = buildCli(parsed)
|
|
27
|
+
const file = parsed.positionals[0]
|
|
28
|
+
|
|
29
|
+
// Validate the --tier filter early so a typo doesn't silently match
|
|
30
|
+
// nothing and look like a clean review.
|
|
31
|
+
const tierFilter = parsed.values.tier
|
|
32
|
+
if (tierFilter && !VALID_TIERS.has(tierFilter)) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`✖ Unknown --tier '${tierFilter}'. Expected one of: ${[...VALID_TIERS].join(', ')}\n`
|
|
35
|
+
)
|
|
36
|
+
process.exit(64)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const raw = await readFileOrStdin(file)
|
|
40
|
+
let container
|
|
41
|
+
try {
|
|
42
|
+
container = JSON.parse(raw)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
process.stderr.write(`✖ Container is not valid JSON: ${e.message}\n`)
|
|
45
|
+
process.exit(64)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const report = reviewRecord(container, { strict: !parsed.values['no-strict'] })
|
|
49
|
+
|
|
50
|
+
if (parsed.values.json) {
|
|
51
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
52
|
+
} else {
|
|
53
|
+
// Preview goes first so the operator sees what they're about to ship
|
|
54
|
+
// alongside what the reviewer caught. --no-preview drops it for
|
|
55
|
+
// CI / pipeline use.
|
|
56
|
+
if (!parsed.values['no-preview']) renderPreview(container, cli)
|
|
57
|
+
printHumanReport(cli, report, { tierFilter })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Exit code: 0 if ready, 1 if blockers (or strict + warnings).
|
|
61
|
+
if (report.summary.blockers > 0) process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printHumanReport (cli, report, { tierFilter } = {}) {
|
|
65
|
+
const { summary, issues } = report
|
|
66
|
+
const ratingLabel = {
|
|
67
|
+
ready: '✔ ready',
|
|
68
|
+
'needs-work': '⚠ needs work',
|
|
69
|
+
blocked: '✖ blocked'
|
|
70
|
+
}[summary.rating]
|
|
71
|
+
cli.log('')
|
|
72
|
+
cli.log(`Review: ${ratingLabel}`)
|
|
73
|
+
cli.log(` ${summary.blockers} blocker(s) · ${summary.warnings} warning(s) · ${summary.suggestions} suggestion(s)`)
|
|
74
|
+
if (tierFilter) {
|
|
75
|
+
cli.log(` (filtered to tier: ${tierFilter})`)
|
|
76
|
+
}
|
|
77
|
+
cli.log('')
|
|
78
|
+
// Filter at print time so the summary counts above still reflect the full
|
|
79
|
+
// review. This keeps `--tier mitre-common` from hiding the broader picture
|
|
80
|
+
// while still letting operators focus on the must-fix items.
|
|
81
|
+
const shown = tierFilter
|
|
82
|
+
? issues.filter(i => i.tier === tierFilter)
|
|
83
|
+
: issues
|
|
84
|
+
if (shown.length === 0) {
|
|
85
|
+
if (tierFilter && issues.length > 0) {
|
|
86
|
+
cli.log(` No ${tierFilter} issues found.`)
|
|
87
|
+
} else {
|
|
88
|
+
cli.log(' No issues found.')
|
|
89
|
+
}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
for (const issue of shown) {
|
|
93
|
+
const tag = issue.severity === 'blocker'
|
|
94
|
+
? '✖'
|
|
95
|
+
: issue.severity === 'warning'
|
|
96
|
+
? '⚠'
|
|
97
|
+
: '·'
|
|
98
|
+
// Tier rides inline with the rule id so a glance at the report tells
|
|
99
|
+
// the operator whether MITRE would have caught the issue too.
|
|
100
|
+
const tierLabel = issue.tier ? `${issue.tier}: ` : ''
|
|
101
|
+
cli.log(` ${tag} [${tierLabel}${issue.rule}] ${issue.path}`)
|
|
102
|
+
cli.log(` ${issue.message}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function printHelp () {
|
|
107
|
+
process.stderr.write(
|
|
108
|
+
`openjs-cna-experimental review <container.json>
|
|
109
|
+
|
|
110
|
+
Run the offline reviewer against a CNA Container. Strict by default —
|
|
111
|
+
warnings are treated as blockers because publishing to MITRE is
|
|
112
|
+
irreversible. Pass --no-strict to surface warnings as warnings.
|
|
113
|
+
|
|
114
|
+
Options:
|
|
115
|
+
--no-strict surface warnings as warnings (default treats them as blockers)
|
|
116
|
+
--json emit a structured JSON report instead of the human view
|
|
117
|
+
--no-preview skip the container preview (for CI / pipelines)
|
|
118
|
+
--tier <tier> filter the printed issue list to a single tier
|
|
119
|
+
(mitre-common | openjs-extra). Summary counts still
|
|
120
|
+
reflect the full review; exit code is unaffected.
|
|
121
|
+
`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// openjs-cna-experimental update <cve-id> [-f container.json] [--skip-review] [--no-strict]
|
|
2
|
+
//
|
|
3
|
+
// Same shape as `publish`, but issues the update-cve operation. Same
|
|
4
|
+
// cveMetadata.cveId cross-check, same strict-by-default reviewer, same
|
|
5
|
+
// correlation_id + run_id surfacing on success.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseComponentArgs,
|
|
9
|
+
buildClient,
|
|
10
|
+
buildCli,
|
|
11
|
+
readFileOrStdin
|
|
12
|
+
} from '../lib/component-utils.js'
|
|
13
|
+
import { reviewRecord } from '../lib/review/index.js'
|
|
14
|
+
import { CnaReviewBlockedError } from '../lib/errors.js'
|
|
15
|
+
|
|
16
|
+
export const command = 'update'
|
|
17
|
+
export const describe = 'Update an already-published CVE record.'
|
|
18
|
+
|
|
19
|
+
export async function handler (argv) {
|
|
20
|
+
const parsed = parseComponentArgs(argv, {
|
|
21
|
+
file: { type: 'string', short: 'f' },
|
|
22
|
+
'skip-review': { type: 'boolean' },
|
|
23
|
+
'no-strict': { type: 'boolean' }
|
|
24
|
+
}, { allowPositionals: true })
|
|
25
|
+
if (parsed.values.help) return printHelp()
|
|
26
|
+
|
|
27
|
+
const cveId = parsed.positionals[0]
|
|
28
|
+
if (!cveId) {
|
|
29
|
+
process.stderr.write('✖ Missing CVE id. Usage: openjs-cna-experimental update <CVE-...> -f container.json\n')
|
|
30
|
+
process.exit(64)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cli = buildCli(parsed)
|
|
34
|
+
const client = buildClient(parsed)
|
|
35
|
+
|
|
36
|
+
const raw = await readFileOrStdin(parsed.values.file)
|
|
37
|
+
let record
|
|
38
|
+
try {
|
|
39
|
+
record = JSON.parse(raw)
|
|
40
|
+
} catch (e) {
|
|
41
|
+
process.stderr.write(`✖ File is not valid JSON: ${e.message}\n`)
|
|
42
|
+
process.exit(64)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// CVE id consistency check (same logic as publish).
|
|
46
|
+
const recordCveId = record?.cveMetadata?.cveId
|
|
47
|
+
if (recordCveId && recordCveId !== cveId) {
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
`✖ CVE id mismatch: CLI argument is ${cveId}, record.cveMetadata.cveId is ${recordCveId}.\n` +
|
|
50
|
+
' Aborting before update to prevent overwriting the wrong record.\n'
|
|
51
|
+
)
|
|
52
|
+
process.exit(64)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Workflow expects just the inner cna container.
|
|
56
|
+
const container = record.containers.cna
|
|
57
|
+
|
|
58
|
+
if (!parsed.values['skip-review']) {
|
|
59
|
+
const strict = !parsed.values['no-strict']
|
|
60
|
+
const report = reviewRecord(record, { strict })
|
|
61
|
+
if (report.summary.blockers > 0) {
|
|
62
|
+
cli.error(`Review blocked update: ${report.summary.blockers} blocker(s)`)
|
|
63
|
+
for (const issue of report.issues) {
|
|
64
|
+
const tag = issue.severity === 'blocker' ? '✖' : '⚠'
|
|
65
|
+
cli.log(` ${tag} [${issue.rule}] ${issue.message}`)
|
|
66
|
+
}
|
|
67
|
+
throw new CnaReviewBlockedError(`Review blocked update for ${cveId}`, { report })
|
|
68
|
+
}
|
|
69
|
+
cli.ok('Review clean.')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const confirmed = await cli.prompt(
|
|
73
|
+
`Update ${cveId}? This will replace the public MITRE record.`,
|
|
74
|
+
{ defaultAnswer: !parsed.values['skip-review'] }
|
|
75
|
+
)
|
|
76
|
+
if (!confirmed) {
|
|
77
|
+
cli.info('Aborted.')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
cli.info(`Updating ${cveId}…`)
|
|
82
|
+
const { correlationId, runId } = await client.updateCve(cveId, container, {
|
|
83
|
+
onPoll: (run) => { if (run.status !== 'completed') cli.info(`run status: ${run.status}`) }
|
|
84
|
+
})
|
|
85
|
+
cli.ok(`Updated ${cveId}`)
|
|
86
|
+
if (correlationId || runId) {
|
|
87
|
+
cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printHelp () {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`openjs-cna-experimental update <cve-id> [-f container.json]
|
|
94
|
+
|
|
95
|
+
Update a published CVE record. Same review semantics as publish (strict
|
|
96
|
+
by default — warnings block too).
|
|
97
|
+
|
|
98
|
+
Options:
|
|
99
|
+
-f, --file <path> read the container from a file (default: stdin)
|
|
100
|
+
--skip-review do not run the reviewer
|
|
101
|
+
--no-strict let warnings pass through (only true blockers abort)
|
|
102
|
+
`
|
|
103
|
+
)
|
|
104
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Resolve token + base URL from the precedence chain (flags > env > project >
|
|
2
|
+
// global) and feed them to a CnaClient.
|
|
3
|
+
//
|
|
4
|
+
// The CLI passes any explicit --token / --base-url flags as `overrides`.
|
|
5
|
+
// Everything else comes from the merged config (see ./config.js).
|
|
6
|
+
//
|
|
7
|
+
// Validation here is permissive: we surface what we have, the CnaClient
|
|
8
|
+
// throws lazy CnaAuthError on the first operation that actually needs a
|
|
9
|
+
// credential. That keeps `openjs-cna-experimental --help` and `openjs-cna-experimental config list`
|
|
10
|
+
// usable without any auth at all.
|
|
11
|
+
|
|
12
|
+
import { getMergedConfig } from './config.js'
|
|
13
|
+
|
|
14
|
+
export function resolveCredentials ({ overrides = {}, cwd } = {}) {
|
|
15
|
+
const config = getMergedConfig({ cwd })
|
|
16
|
+
return {
|
|
17
|
+
token: overrides.token ?? config.openjs_cna_token,
|
|
18
|
+
baseUrl: overrides.baseUrl ?? config.openjs_cna_base_url
|
|
19
|
+
}
|
|
20
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Minimal CLI: a stream wrapper, four log levels, a non-animated spinner,
|
|
2
|
+
// and a confirm/input prompt. Zero deps — ANSI escapes inline, gated by
|
|
3
|
+
// stream.isTTY so piped output stays clean.
|
|
4
|
+
//
|
|
5
|
+
// Output discipline: this class always writes to its `stream` (defaulting
|
|
6
|
+
// to stderr). The CLI verbs are responsible for writing data (CVE ids,
|
|
7
|
+
// JSON records) to stdout directly. That keeps `CVE=$(openjs-cna-experimental reserve)`
|
|
8
|
+
// pipeline-safe by construction.
|
|
9
|
+
|
|
10
|
+
const ANSI = {
|
|
11
|
+
reset: '\x1b[0m',
|
|
12
|
+
bold: '\x1b[1m',
|
|
13
|
+
red: '\x1b[31m',
|
|
14
|
+
yellow: '\x1b[33m',
|
|
15
|
+
green: '\x1b[32m',
|
|
16
|
+
blue: '\x1b[34m',
|
|
17
|
+
gray: '\x1b[90m'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class CLI {
|
|
21
|
+
constructor (stream = process.stderr, { color, assumeYes } = {}) {
|
|
22
|
+
this.stream = stream
|
|
23
|
+
// Default to color when stderr is a TTY and the user didn't opt out.
|
|
24
|
+
this.color = color ?? (stream.isTTY === true && !process.env.NO_COLOR)
|
|
25
|
+
this.assumeYes = assumeYes ?? false
|
|
26
|
+
this._exitCode = 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setAssumeYes (value = true) { this.assumeYes = value }
|
|
30
|
+
setExitCode (code) { this._exitCode = code }
|
|
31
|
+
exitCode () { return this._exitCode }
|
|
32
|
+
|
|
33
|
+
_paint (code, text) { return this.color ? `${code}${text}${ANSI.reset}` : text }
|
|
34
|
+
_write (line) { this.stream.write(line + '\n') }
|
|
35
|
+
|
|
36
|
+
// Severity-leveled log lines. Match NCU's vocabulary (ok / info / warn /
|
|
37
|
+
// error) so a developer moving between the two codebases doesn't have to
|
|
38
|
+
// re-learn the lexicon.
|
|
39
|
+
ok (msg) { this._write(this._paint(ANSI.green, '✔ ') + msg) }
|
|
40
|
+
info (msg) { this._write(this._paint(ANSI.blue, 'ℹ ') + msg) }
|
|
41
|
+
warn (msg) { this._write(this._paint(ANSI.yellow, '⚠ ') + msg) }
|
|
42
|
+
error (msg) { this._write(this._paint(ANSI.red, '✖ ') + msg) }
|
|
43
|
+
log (msg) { this._write(msg) }
|
|
44
|
+
separator () { this._write('') }
|
|
45
|
+
|
|
46
|
+
// Non-animated spinner replacement. Prints a "starting…" line on start
|
|
47
|
+
// and a "done" line on stop. Honest about progress without leaning on
|
|
48
|
+
// ora's TTY animation. Future: rewrite-in-place when stream.isTTY, fall
|
|
49
|
+
// back to discrete lines when piped.
|
|
50
|
+
startSpinner (text) {
|
|
51
|
+
this._write(this._paint(ANSI.gray, '… ') + text)
|
|
52
|
+
return { stop: () => {} }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
stopSpinner (text) {
|
|
56
|
+
if (text) this._write(this._paint(ANSI.green, '✔ ') + text)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Confirm or input prompt. Honors --yes (or assumeYes flag) by returning
|
|
60
|
+
// the defaultAnswer without rendering. Non-TTY stdin also defaults to
|
|
61
|
+
// the default so the CLI is safe to drive from CI without explicit --yes.
|
|
62
|
+
async prompt (question, { defaultAnswer = false, questionType = 'confirm' } = {}) {
|
|
63
|
+
if (this.assumeYes) return defaultAnswer
|
|
64
|
+
if (!process.stdin.isTTY) return defaultAnswer
|
|
65
|
+
const { createInterface } = await import('node:readline/promises')
|
|
66
|
+
const rl = createInterface({ input: process.stdin, output: this.stream })
|
|
67
|
+
try {
|
|
68
|
+
const hint = questionType === 'confirm'
|
|
69
|
+
? (defaultAnswer ? '[Y/n]' : '[y/N]')
|
|
70
|
+
: ''
|
|
71
|
+
const raw = (await rl.question(`${question} ${hint} `.trimEnd() + ' ')).trim()
|
|
72
|
+
if (questionType === 'confirm') {
|
|
73
|
+
if (!raw) return defaultAnswer
|
|
74
|
+
return /^y(es)?$/i.test(raw)
|
|
75
|
+
}
|
|
76
|
+
return raw || defaultAnswer
|
|
77
|
+
} finally {
|
|
78
|
+
rl.close()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|