@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,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/batch.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Review and publish several CVE Records in one go.
|
|
2
|
+
//
|
|
3
|
+
// This is the "all or none" flow a release pipeline wants: every record is
|
|
4
|
+
// reviewed first and nothing is sent unless all of them pass. Publication
|
|
5
|
+
// itself is best effort per record, since MITRE has no batch operation and
|
|
6
|
+
// the records are independent; a failed id is reported and the rest still
|
|
7
|
+
// go out. `--skip-published` style reruns are supported by asking the CNA
|
|
8
|
+
// whether an id already has a published container before sending it.
|
|
9
|
+
//
|
|
10
|
+
// Both functions take `entries`: an array of `{ record, file? }` where
|
|
11
|
+
// `record` is a full v5.2 CVE Record (`containers.cna` plus `cveMetadata`)
|
|
12
|
+
// and `file` is only used to label output. The CLI `review` and `publish`
|
|
13
|
+
// commands are thin wrappers over them.
|
|
14
|
+
|
|
15
|
+
import { reviewRecord } from './review/index.js'
|
|
16
|
+
import { CnaOperationError } from './errors.js'
|
|
17
|
+
|
|
18
|
+
function labelOf (entry) {
|
|
19
|
+
return entry.record?.cveMetadata?.cveId ?? entry.file ?? '<record>'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Review every entry. Never stops early, so the caller sees all problems at
|
|
24
|
+
* once. A record without `cveMetadata.cveId` gets an extra blocker: in a
|
|
25
|
+
* batch there is no CLI argument to fall back on for the id.
|
|
26
|
+
*
|
|
27
|
+
* Returns `[{ file, cveId, ready, summary, issues }]` in input order.
|
|
28
|
+
*/
|
|
29
|
+
export function reviewRecords (entries, { strict = true } = {}) {
|
|
30
|
+
return entries.map((entry) => {
|
|
31
|
+
const report = reviewRecord(entry.record, { strict })
|
|
32
|
+
const cveId = entry.record?.cveMetadata?.cveId
|
|
33
|
+
const issues = [...report.issues]
|
|
34
|
+
const summary = { ...report.summary }
|
|
35
|
+
if (!cveId) {
|
|
36
|
+
issues.unshift({
|
|
37
|
+
rule: 'cve-id-present',
|
|
38
|
+
tier: 'mitre-common',
|
|
39
|
+
severity: 'blocker',
|
|
40
|
+
path: 'cveMetadata.cveId',
|
|
41
|
+
message: 'Record carries no cveMetadata.cveId; batch publish needs one per record'
|
|
42
|
+
})
|
|
43
|
+
summary.blockers += 1
|
|
44
|
+
summary.rating = 'blocked'
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
file: entry.file,
|
|
48
|
+
cveId,
|
|
49
|
+
ready: summary.blockers === 0,
|
|
50
|
+
summary,
|
|
51
|
+
issues
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `getCve` resolves with the container when MITRE has a published record.
|
|
57
|
+
// For a RESERVED id there is nothing to fetch and the CNA either fails the
|
|
58
|
+
// run or completes it with an empty result; neither is pinned down, so
|
|
59
|
+
// only positive evidence counts: an operational failure, or a result that
|
|
60
|
+
// does not look like a record, means "not published" and the id is sent.
|
|
61
|
+
// Erring towards publishing is the safe side, since the CNA rejects a
|
|
62
|
+
// second publish loudly while a wrong skip would exit 0 having sent
|
|
63
|
+
// nothing. Auth and timeout errors propagate so a broken setup does not
|
|
64
|
+
// look like a clean slate.
|
|
65
|
+
async function isPublished (client, cveId) {
|
|
66
|
+
let result
|
|
67
|
+
try {
|
|
68
|
+
result = await client.getCve(cveId)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof CnaOperationError) return false
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
const cna = result?.containers?.cna ?? result
|
|
74
|
+
return result?.cveMetadata?.state === 'PUBLISHED' ||
|
|
75
|
+
Array.isArray(cna?.descriptions) ||
|
|
76
|
+
Array.isArray(cna?.affected)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Review every entry, then publish each one through `client.publishCve`.
|
|
81
|
+
*
|
|
82
|
+
* Options:
|
|
83
|
+
* client — a CnaClient (required unless every record is skipped)
|
|
84
|
+
* strict — reviewer strictness (default true, warnings block)
|
|
85
|
+
* skipReview — publish without reviewing (discouraged)
|
|
86
|
+
* skipPublished — ask the CNA first and skip ids already published
|
|
87
|
+
* onProgress — called with `{ phase, cveId, file, status, ... }` as
|
|
88
|
+
* each step completes, for CLI progress lines
|
|
89
|
+
*
|
|
90
|
+
* Returns `{ blocked, review, results }`. When `blocked` is true the review
|
|
91
|
+
* found blockers, `results` is empty and nothing was sent. Otherwise
|
|
92
|
+
* `results` has one `{ file, cveId, status, ... }` per entry, with status
|
|
93
|
+
* `published`, `skipped` or `failed`, plus `correlationId` / `runId` when
|
|
94
|
+
* the CNA returned them and `error` for failures.
|
|
95
|
+
*/
|
|
96
|
+
export async function publishRecords (entries, {
|
|
97
|
+
client,
|
|
98
|
+
strict = true,
|
|
99
|
+
skipReview = false,
|
|
100
|
+
skipPublished = false,
|
|
101
|
+
onProgress = () => {}
|
|
102
|
+
} = {}) {
|
|
103
|
+
const review = skipReview ? [] : reviewRecords(entries, { strict })
|
|
104
|
+
if (review.some((item) => !item.ready)) {
|
|
105
|
+
return { blocked: true, review, results: [] }
|
|
106
|
+
}
|
|
107
|
+
if (skipReview && entries.some((entry) => !entry.record?.cveMetadata?.cveId)) {
|
|
108
|
+
throw new Error('Every record needs cveMetadata.cveId to be published in a batch')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const results = []
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
const cveId = labelOf(entry)
|
|
114
|
+
const base = { file: entry.file, cveId }
|
|
115
|
+
let result
|
|
116
|
+
if (skipPublished && await isPublished(client, cveId)) {
|
|
117
|
+
result = { ...base, status: 'skipped', reason: 'already published' }
|
|
118
|
+
} else {
|
|
119
|
+
try {
|
|
120
|
+
const response = await client.publishCve(cveId, entry.record.containers.cna)
|
|
121
|
+
result = {
|
|
122
|
+
...base,
|
|
123
|
+
status: 'published',
|
|
124
|
+
correlationId: response.correlationId,
|
|
125
|
+
runId: response.runId
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
result = {
|
|
129
|
+
...base,
|
|
130
|
+
status: 'failed',
|
|
131
|
+
error: error.message,
|
|
132
|
+
correlationId: error.correlationId,
|
|
133
|
+
runId: error.runId
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
results.push(result)
|
|
138
|
+
onProgress({ phase: 'publish', ...result })
|
|
139
|
+
}
|
|
140
|
+
return { blocked: false, review, results }
|
|
141
|
+
}
|
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
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// CnaClient — the transport layer for the OpenJS Foundation CNA API.
|
|
2
|
+
//
|
|
3
|
+
// The CNA API is a thin edge in front of a `workflow_dispatch`-backed GitHub
|
|
4
|
+
// Actions workflow. POST /dispatch returns a Worker-minted correlation_id;
|
|
5
|
+
// GET /runs/{id} polls the backing run and, once it completes, returns the
|
|
6
|
+
// operation's structured result extracted from the job log.
|
|
7
|
+
//
|
|
8
|
+
// Two layers:
|
|
9
|
+
//
|
|
10
|
+
// * High-level methods (`reserveCve`, `publishCve`, `getCve`, `listCves`,
|
|
11
|
+
// `updateCve`, `health`) dispatch, wait for completion, throw on failure,
|
|
12
|
+
// and return the operation result.
|
|
13
|
+
//
|
|
14
|
+
// * Low-level methods (`dispatch`, `poll`, `waitForCompletion`) expose the
|
|
15
|
+
// three primitives for callers that want to bridge polling externally
|
|
16
|
+
// (UIs, queues, retries, custom progress reporting).
|
|
17
|
+
//
|
|
18
|
+
// The constructor is non-allocating — no network, no fs, no subprocess. Token
|
|
19
|
+
// and base-url validation is deferred until the first operation (the
|
|
20
|
+
// self-replacing-getter pattern from node-core-utils).
|
|
21
|
+
|
|
22
|
+
import { createRequire } from 'node:module'
|
|
23
|
+
import {
|
|
24
|
+
CnaAuthError,
|
|
25
|
+
CnaTimeoutError,
|
|
26
|
+
CnaOperationError
|
|
27
|
+
} from './errors.js'
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url)
|
|
30
|
+
const pkg = require('../package.json')
|
|
31
|
+
|
|
32
|
+
// Single identity string used as the User-Agent on every outbound HTTP
|
|
33
|
+
// request AND as `cna.x_generator.engine` on every container we publish.
|
|
34
|
+
// Pinning both to the same string means a downstream reader of a CVE
|
|
35
|
+
// record can find the exact `openjs-cna-tools <version>` that produced
|
|
36
|
+
// the call.
|
|
37
|
+
export const USER_AGENT = `${pkg.name} ${pkg.version}`
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TIMEOUT_MS = 600_000 // 10 minutes; matches NCU's choice
|
|
40
|
+
const DEFAULT_POLL_INTERVAL_MS = 5_000
|
|
41
|
+
|
|
42
|
+
export class CnaClient {
|
|
43
|
+
constructor ({ token, baseUrl, timeoutMs, pollIntervalMs, fetch: fetchImpl } = {}) {
|
|
44
|
+
this._token = token
|
|
45
|
+
this._baseUrl = baseUrl
|
|
46
|
+
this._timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
47
|
+
this._pollIntervalMs = pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
48
|
+
// Injectable for tests; defaults to the runtime's global fetch.
|
|
49
|
+
this._fetch = fetchImpl ?? globalThis.fetch
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Lazy validation. Reading `client.token` once is the only time we check
|
|
53
|
+
// the configuration. Re-reads pay no cost; missing-token failures throw at
|
|
54
|
+
// the exact moment the caller is about to make a network call, which is
|
|
55
|
+
// also the moment the operator can act on the error.
|
|
56
|
+
get token () {
|
|
57
|
+
if (!this._token) {
|
|
58
|
+
throw new CnaAuthError(
|
|
59
|
+
'OPENJS_CNA_TOKEN is not configured. Run:\n' +
|
|
60
|
+
' openjs-cna-experimental config set -x openjs_cna_token "<bucket>:<hex-secret>"\n' +
|
|
61
|
+
'or pass --token / set OPENJS_CNA_TOKEN in the environment.'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
return this._token
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get baseUrl () {
|
|
68
|
+
if (!this._baseUrl) {
|
|
69
|
+
throw new CnaAuthError(
|
|
70
|
+
'OPENJS_CNA_BASE_URL is not configured. Run:\n' +
|
|
71
|
+
' openjs-cna-experimental config set openjs_cna_base_url "https://cna.openjsf.org"\n' +
|
|
72
|
+
'or pass --base-url / set OPENJS_CNA_BASE_URL in the environment.'
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return this._baseUrl.replace(/\/$/, '')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_authHeaders () {
|
|
79
|
+
return {
|
|
80
|
+
Authorization: `Bearer ${this.token}`,
|
|
81
|
+
Accept: 'application/json',
|
|
82
|
+
'User-Agent': USER_AGENT
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Unauthenticated liveness probe. Useful for `openjs-cna-experimental health` and for
|
|
87
|
+
// catching base-url misconfiguration without burning a workflow dispatch.
|
|
88
|
+
async health () {
|
|
89
|
+
const res = await this._fetch(`${this.baseUrl}/health`, {
|
|
90
|
+
headers: { 'User-Agent': USER_AGENT }
|
|
91
|
+
})
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
throw new CnaOperationError(`Health check failed: ${res.status} ${res.statusText}`)
|
|
94
|
+
}
|
|
95
|
+
return res.json()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Low-level: POST /dispatch. Returns { correlation_id, status } from the
|
|
99
|
+
// Worker. Throws CnaAuthError on 401 so the CLI can map to exit 2.
|
|
100
|
+
async dispatch (operation, inputs = {}) {
|
|
101
|
+
const res = await this._fetch(`${this.baseUrl}/dispatch`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { ...this._authHeaders(), 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ operation, inputs })
|
|
105
|
+
})
|
|
106
|
+
if (res.status === 401) {
|
|
107
|
+
throw new CnaAuthError(
|
|
108
|
+
'The CNA API rejected the bearer token (401). Check that ' +
|
|
109
|
+
'`openjs_cna_token` matches the bucket your CNA coordinator provisioned.',
|
|
110
|
+
{ status: 401 }
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
let body
|
|
114
|
+
try {
|
|
115
|
+
body = await res.json()
|
|
116
|
+
} catch (e) {
|
|
117
|
+
throw new CnaOperationError(
|
|
118
|
+
`Dispatch returned non-JSON response (${res.status}): ${e.message}`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
if (body?.error) {
|
|
122
|
+
throw new CnaOperationError(`Dispatch failed (${operation}): ${body.error}`)
|
|
123
|
+
}
|
|
124
|
+
return body
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Low-level: GET /runs/{id}. Returns the run object, possibly with a
|
|
128
|
+
// `result` field when the workflow has completed successfully.
|
|
129
|
+
async poll (correlationId) {
|
|
130
|
+
const res = await this._fetch(`${this.baseUrl}/runs/${correlationId}`, {
|
|
131
|
+
headers: this._authHeaders()
|
|
132
|
+
})
|
|
133
|
+
if (res.status === 401) {
|
|
134
|
+
throw new CnaAuthError('CNA API rejected the bearer token (401)', { status: 401 })
|
|
135
|
+
}
|
|
136
|
+
// 502 run_result_unavailable: completed+success but the Worker could not
|
|
137
|
+
// extract the marker block. Surface as an operation error with the run
|
|
138
|
+
// metadata still attached so the CLI can suggest `openjs-cna-experimental runs <id>`.
|
|
139
|
+
if (res.status === 502) {
|
|
140
|
+
const body = await res.json().catch(() => ({}))
|
|
141
|
+
throw new CnaOperationError(
|
|
142
|
+
body?.message ?? `CNA API returned 502 for run ${correlationId}`,
|
|
143
|
+
{
|
|
144
|
+
correlationId,
|
|
145
|
+
runId: body?.run_id
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
return res.json()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Poll until the run completes. Throws on timeout, on a non-success
|
|
153
|
+
// conclusion, or on AbortSignal cancellation. `onPoll` lets callers update
|
|
154
|
+
// a progress UI without owning the polling loop.
|
|
155
|
+
async waitForCompletion (correlationId, opts = {}) {
|
|
156
|
+
const {
|
|
157
|
+
timeoutMs = this._timeoutMs,
|
|
158
|
+
intervalMs = this._pollIntervalMs,
|
|
159
|
+
signal,
|
|
160
|
+
onPoll
|
|
161
|
+
} = opts
|
|
162
|
+
const deadline = Date.now() + timeoutMs
|
|
163
|
+
while (Date.now() < deadline) {
|
|
164
|
+
if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
|
165
|
+
const run = await this.poll(correlationId)
|
|
166
|
+
if (typeof onPoll === 'function') onPoll(run)
|
|
167
|
+
if (run.status === 'completed') {
|
|
168
|
+
if (run.conclusion !== 'success') {
|
|
169
|
+
throw new CnaOperationError(
|
|
170
|
+
`Run ${correlationId} concluded with '${run.conclusion}'. ` +
|
|
171
|
+
'Forward correlation_id and run_id to your CNA coordinator for inspection.',
|
|
172
|
+
{
|
|
173
|
+
correlationId,
|
|
174
|
+
runId: run.run_id,
|
|
175
|
+
conclusion: run.conclusion,
|
|
176
|
+
result: run.result
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
return run
|
|
181
|
+
}
|
|
182
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs))
|
|
183
|
+
}
|
|
184
|
+
throw new CnaTimeoutError(
|
|
185
|
+
`Run ${correlationId} did not complete within ${timeoutMs}ms`,
|
|
186
|
+
{ correlationId, timeoutMs }
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Internal: dispatch + wait. Every high-level method funnels through here.
|
|
191
|
+
async _runAndWait (operation, inputs, opts) {
|
|
192
|
+
const dispatched = await this.dispatch(operation, inputs)
|
|
193
|
+
const run = await this.waitForCompletion(dispatched.correlation_id, opts)
|
|
194
|
+
return { ...run, correlationId: dispatched.correlation_id }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ----- High-level operations -----
|
|
198
|
+
|
|
199
|
+
async reserveCve (opts = {}) {
|
|
200
|
+
const run = await this._runAndWait('reserve-cve', {}, opts)
|
|
201
|
+
const cveId = run.result?.cve_id
|
|
202
|
+
if (!cveId) {
|
|
203
|
+
throw new CnaOperationError(
|
|
204
|
+
'reserve-cve completed but no cve_id in the result. ' +
|
|
205
|
+
'Forward correlation_id and run_id to your CNA coordinator.',
|
|
206
|
+
{
|
|
207
|
+
correlationId: run.correlationId,
|
|
208
|
+
runId: run.run_id,
|
|
209
|
+
result: run.result
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
return { cveId, correlationId: run.correlationId, runId: run.run_id, run }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async publishCve (cveId, container, opts = {}) {
|
|
217
|
+
const run = await this._runAndWait('publish-cve', {
|
|
218
|
+
cve_id: cveId,
|
|
219
|
+
cnaContainer: container
|
|
220
|
+
}, opts)
|
|
221
|
+
return { correlationId: run.correlationId, runId: run.run_id, run, result: run.result }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async updateCve (cveId, container, opts = {}) {
|
|
225
|
+
const run = await this._runAndWait('update-cve', {
|
|
226
|
+
cve_id: cveId,
|
|
227
|
+
cnaContainer: container
|
|
228
|
+
}, opts)
|
|
229
|
+
return { correlationId: run.correlationId, runId: run.run_id, run, result: run.result }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async getCve (cveId, opts = {}) {
|
|
233
|
+
const run = await this._runAndWait('get-cve', { cve_id: cveId }, opts)
|
|
234
|
+
return run.result
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async listCves (opts = {}) {
|
|
238
|
+
const run = await this._runAndWait('list-cve', {}, opts)
|
|
239
|
+
// Two acceptable result shapes:
|
|
240
|
+
// * { cve_ids: [...] } — what the workflow currently emits
|
|
241
|
+
// * [ ... ] — direct array, in case the API evolves
|
|
242
|
+
return run.result?.cve_ids ?? (Array.isArray(run.result) ? run.result : [])
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Shared bootstrap for CLI components.
|
|
2
|
+
//
|
|
3
|
+
// Every component runs the same opening sequence: parse argv (mixing global
|
|
4
|
+
// flags with its own), resolve credentials, build a CLI + CnaClient. This
|
|
5
|
+
// module factors that out so each component file in components/ can stay
|
|
6
|
+
// at ~30 lines.
|
|
7
|
+
|
|
8
|
+
import { parseArgs } from 'node:util'
|
|
9
|
+
import { CLI } from './cli.js'
|
|
10
|
+
import { CnaClient } from './client.js'
|
|
11
|
+
import { resolveCredentials } from './auth.js'
|
|
12
|
+
|
|
13
|
+
const GLOBAL_OPTIONS = {
|
|
14
|
+
token: { type: 'string' },
|
|
15
|
+
'base-url': { type: 'string' },
|
|
16
|
+
timeout: { type: 'string' },
|
|
17
|
+
'poll-interval': { type: 'string' },
|
|
18
|
+
json: { type: 'boolean' },
|
|
19
|
+
quiet: { type: 'boolean' },
|
|
20
|
+
yes: { type: 'boolean', short: 'y' },
|
|
21
|
+
'no-color': { type: 'boolean' },
|
|
22
|
+
help: { type: 'boolean', short: 'h' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseComponentArgs (argv, componentOptions = {}, { allowPositionals = false } = {}) {
|
|
26
|
+
const options = { ...GLOBAL_OPTIONS, ...componentOptions }
|
|
27
|
+
let parsed
|
|
28
|
+
try {
|
|
29
|
+
parsed = parseArgs({ args: argv, options, allowPositionals })
|
|
30
|
+
} catch (e) {
|
|
31
|
+
process.stderr.write(`✖ ${e.message}\n`)
|
|
32
|
+
process.exit(64) // EX_USAGE
|
|
33
|
+
}
|
|
34
|
+
return parsed
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildCli ({ values } = { values: {} }) {
|
|
38
|
+
return new CLI(process.stderr, {
|
|
39
|
+
color: values['no-color'] ? false : undefined,
|
|
40
|
+
assumeYes: values.yes || process.env.OPENJS_CNA_YES === '1'
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildClient ({ values } = { values: {} }) {
|
|
45
|
+
const creds = resolveCredentials({
|
|
46
|
+
overrides: {
|
|
47
|
+
token: values.token,
|
|
48
|
+
baseUrl: values['base-url']
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
return new CnaClient({
|
|
52
|
+
token: creds.token,
|
|
53
|
+
baseUrl: creds.baseUrl,
|
|
54
|
+
timeoutMs: values.timeout ? Number(values.timeout) * 1000 : undefined,
|
|
55
|
+
pollIntervalMs: values['poll-interval'] ? Number(values['poll-interval']) * 1000 : undefined
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Convenience: read a file or stdin to text. Used by publish/update/review/
|
|
60
|
+
// convert when -f is omitted.
|
|
61
|
+
export async function readFileOrStdin (filePath) {
|
|
62
|
+
const fs = await import('node:fs/promises')
|
|
63
|
+
if (filePath && filePath !== '-') {
|
|
64
|
+
return fs.readFile(filePath, 'utf8')
|
|
65
|
+
}
|
|
66
|
+
// Read all of stdin.
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
let data = ''
|
|
69
|
+
process.stdin.setEncoding('utf8')
|
|
70
|
+
process.stdin.on('data', (chunk) => { data += chunk })
|
|
71
|
+
process.stdin.on('end', () => resolve(data))
|
|
72
|
+
process.stdin.on('error', reject)
|
|
73
|
+
})
|
|
74
|
+
}
|