Skip to content

Commit 6202df4

Browse files
committed
feat(security): support OpenJS Foundation CNA as CVE source
HackerOne acts as Node.js' CNA today via /cve_requests. The OpenJS Foundation runs its own CNA and exposes an API at https://cna.openjsf.org/api.html that can issue CVEs through that channel without leaving NCU. Add an opt-in path that routes reservation and publication through the OpenJS CNA, keeping HackerOne as the source of truth for the bug-bounty workflow. Setting \`cve_source: 'openjs-cna'\` in \`.ncurc\` switches the source. The default (\`hackerone\`) preserves the existing behaviour. The CVE id is pushed back to the HackerOne report either way so the report still carries it. * \`cna\` lazy auth accessor reading \`openjs_cna_token\` and \`openjs_cna_worker_url\` from \`.ncurc\`. Both treated as encrypted secrets (\`-x\`). * Five Request methods (\`cnaDispatch\`, \`cnaPoll\`, \`cnaWaitForCompletion\`, \`cnaReserveCve\`, \`cnaPublishCve\`). * New \`git node security --publish-cve\` subcommand, scoped to the OpenJS CNA path, that POSTs a v5.2 CNA Container per reserved CVE to MITRE. Run after the release ships on nodejs.org. * \`vulnerabilities.json\` -> v5.2 CNA Container mapper. The reference URL is derived from \`releaseDate\` so the MITRE record points at the same blog-post slug \`SecurityBlog#getSlug\` produces. * Prerequisites section in \`docs/git-node.md\`. * Unit tests for transport, slug helper, and mapper. Signed-off-by: Ulises Gascon <ulisesgascongonzalez@gmail.com>
1 parent 4ea22b8 commit 6202df4

7 files changed

Lines changed: 692 additions & 1 deletion

File tree

components/git/security.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ const securityOptions = {
4040
describe: 'Request CVEs for a security release',
4141
type: 'boolean'
4242
},
43+
'publish-cve': {
44+
describe:
45+
'Publish reserved CVEs to MITRE via the OpenJS Foundation CNA',
46+
type: 'boolean'
47+
},
4348
'post-release': {
4449
describe: 'Create the post-release announcement to the given nodejs.org folder',
4550
type: 'string'
@@ -88,6 +93,9 @@ export function builder(yargs) {
8893
).example(
8994
'git node security --cleanup',
9095
'Cleanup the security release. Merge the PR and close H1 reports'
96+
).example(
97+
'git node security --publish-cve',
98+
'Publish reserved CVEs to MITRE via the OpenJS Foundation CNA'
9199
);
92100
}
93101

@@ -119,6 +127,9 @@ export function handler(argv) {
119127
if (argv['request-cve']) {
120128
return requestCVEs(cli, argv);
121129
}
130+
if (argv['publish-cve']) {
131+
return publishCVEs(cli, argv);
132+
}
122133
if (argv['post-release']) {
123134
return createPostRelease(cli, argv);
124135
}
@@ -157,6 +168,11 @@ async function requestCVEs(cli) {
157168
return hackerOneCve.requestCVEs();
158169
}
159170

171+
async function publishCVEs(cli) {
172+
const release = new UpdateSecurityRelease(cli);
173+
return release.publishCVEs();
174+
}
175+
160176
async function createPostRelease(cli, argv) {
161177
const nodejsOrgFolder = argv['post-release'];
162178
const blog = new SecurityBlog(cli);

docs/git-node.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,29 @@ $ ncu-config --global set h1_username $H1_TOKEN
455455
access.
456456
- `h1_username`: HackerOne API Token username.
457457

458+
#### Optional: source CVEs from the OpenJS Foundation CNA
459+
460+
By default, `--request-cve` reserves CVE identifiers through HackerOne (which
461+
acts as Node.js' CNA). You can switch to the **OpenJS Foundation CNA** as the
462+
issuer by setting `cve_source: 'openjs-cna'` in `.ncurc`. HackerOne is still
463+
used for the bug-bounty workflow (triage, sync, disclosure) and is updated with
464+
the resulting CVE id at the end of the request flow, so reports stay in sync.
465+
466+
```console
467+
$ ncu-config --global set cve_source openjs-cna
468+
$ ncu-config --global set -x openjs_cna_token "<bucket>:<hex-secret>"
469+
$ ncu-config --global set -x openjs_cna_worker_url "https://<your-deployment>.workers.dev"
470+
```
471+
472+
- `cve_source`: `hackerone` (default) or `openjs-cna`.
473+
- `openjs_cna_token`: **secret**. Bearer in `bucket:secret` form (e.g.
474+
`prod_nodejs:<256 hex chars>`).
475+
- `openjs_cna_worker_url`: The Cloudflare Worker URL for your
476+
deployment.
477+
478+
To revert to HackerOne, either delete `cve_source` or set it back to
479+
`hackerone`.
480+
458481
### `git node security --start`
459482

460483
This command creates the Next Security Issue in Node.js private repository

lib/auth.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,30 @@ async function auth(
107107
const h1 = encode(h1_username, h1_token);
108108
setOwnProperty(result, 'h1', h1);
109109
return h1;
110+
},
111+
112+
get cna() {
113+
const { openjs_cna_token, openjs_cna_worker_url } = getMergedConfig();
114+
if (!openjs_cna_token || !openjs_cna_worker_url) {
115+
throw new Error(
116+
'OpenJS CNA credentials are not configured. Both values are secrets ' +
117+
'and must be stored encrypted with the `-x` flag. Run:\n' +
118+
' ncu-config --global set -x openjs_cna_token <bucket:secret>\n' +
119+
' ncu-config --global set -x openjs_cna_worker_url <https://...workers.dev>'
120+
);
121+
}
122+
if (!/^[a-z0-9_]+:[0-9a-f]+$/i.test(openjs_cna_token)) {
123+
throw new Error(
124+
'openjs_cna_token is misformatted; expected `<bucket>:<hex-secret>`'
125+
);
126+
}
127+
128+
const cna = {
129+
token: openjs_cna_token,
130+
worker_url: openjs_cna_worker_url.replace(/\/$/, '')
131+
};
132+
setOwnProperty(result, 'cna', cna);
133+
return cna;
110134
}
111135
};
112136
if (options.github) {

lib/request.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,4 +339,101 @@ export default class Request {
339339

340340
return all;
341341
}
342+
343+
// ---------------------------------------------------------------------------
344+
// OpenJS Foundation CNA — github.com/UlisesGascon/openjs-cna-api-poc
345+
//
346+
// The Worker is a thin edge in front of `workflow_dispatch`. POST /dispatch
347+
// returns a Worker-minted correlation_id; GET /runs/{id} polls until the
348+
// backing workflow run completes.
349+
// ---------------------------------------------------------------------------
350+
351+
async cnaDispatch(operation, inputs = {}) {
352+
const { worker_url, token } = this.credentials.cna;
353+
const url = `${worker_url}/dispatch`;
354+
const options = {
355+
method: 'POST',
356+
headers: {
357+
Authorization: `Bearer ${token}`,
358+
'User-Agent': 'node-core-utils',
359+
'Content-Type': 'application/json',
360+
Accept: 'application/json'
361+
},
362+
body: JSON.stringify({ operation, inputs })
363+
};
364+
const response = await this.json(url, options);
365+
if (response.error) {
366+
throw new Error(
367+
`OpenJS CNA dispatch failed (${operation}): ${response.error}`
368+
);
369+
}
370+
return response;
371+
}
372+
373+
async cnaPoll(correlationId) {
374+
const { worker_url, token } = this.credentials.cna;
375+
const url = `${worker_url}/runs/${correlationId}`;
376+
const options = {
377+
method: 'GET',
378+
headers: {
379+
Authorization: `Bearer ${token}`,
380+
'User-Agent': 'node-core-utils',
381+
Accept: 'application/json'
382+
}
383+
};
384+
return this.json(url, options);
385+
}
386+
387+
// Polls /runs/{id} until the workflow reports status === 'completed'.
388+
// Throws on timeout or on a `conclusion` other than 'success'. Default
389+
// timeout is generous (10 min) — publish-cve in particular has been seen
390+
// to take 3-4 min during MITRE staging slowdowns.
391+
async cnaWaitForCompletion(correlationId, { timeoutMs = 600_000, intervalMs = 5_000 } = {}) {
392+
const deadline = Date.now() + timeoutMs;
393+
while (Date.now() < deadline) {
394+
const run = await this.cnaPoll(correlationId);
395+
if (run.status === 'completed') {
396+
if (run.conclusion !== 'success') {
397+
throw new Error(
398+
`OpenJS CNA run ${correlationId} concluded with ` +
399+
`'${run.conclusion}'. See ${run.url} for details.`
400+
);
401+
}
402+
return run;
403+
}
404+
await new Promise(resolve => setTimeout(resolve, intervalMs));
405+
}
406+
throw new Error(
407+
`OpenJS CNA run ${correlationId} did not complete within ${timeoutMs}ms`
408+
);
409+
}
410+
411+
// Reserve a CVE id via the OpenJS CNA. /runs/{correlation_id} surfaces the
412+
// operation result (e.g. `{ cve_id: "CVE-2026-..." }`) on the same response
413+
// once the run completes, so the caller only needs to await this one method.
414+
async cnaReserveCve(opts = {}) {
415+
const dispatch = await this.cnaDispatch('reserve-cve', opts);
416+
const run = await this.cnaWaitForCompletion(dispatch.correlation_id, opts);
417+
return {
418+
correlation_id: dispatch.correlation_id,
419+
run_url: run.url,
420+
run_id: run.run_id,
421+
result: run.result // shape depends on the operation; reserve returns { cve_id }
422+
};
423+
}
424+
425+
// Publish a v5.2 CNA Container against an already-reserved CVE id.
426+
async cnaPublishCve(cveId, cnaContainer, opts = {}) {
427+
const dispatch = await this.cnaDispatch('publish-cve', {
428+
cve_id: cveId,
429+
cnaContainer
430+
});
431+
const run = await this.cnaWaitForCompletion(dispatch.correlation_id, opts);
432+
return {
433+
correlation_id: dispatch.correlation_id,
434+
run_url: run.url,
435+
run_id: run.run_id,
436+
result: run.result
437+
};
438+
}
342439
}

0 commit comments

Comments
 (0)