@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
package/lib/config.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Config layering for openjs-cna-tools.
|
|
2
|
+
//
|
|
3
|
+
// Precedence (highest → lowest):
|
|
4
|
+
// 1. CLI flag (resolved by each component; not in scope here)
|
|
5
|
+
// 2. Environment (OPENJS_CNA_TOKEN, OPENJS_CNA_BASE_URL, …)
|
|
6
|
+
// 3. Project config: ./.openjs-cna.json in CWD
|
|
7
|
+
// 4. Global config: ~/.config/openjs-cna/config.json (XDG-friendly)
|
|
8
|
+
//
|
|
9
|
+
// Encryption-at-rest for secrets is on the roadmap (matches NCU's `-x` flag
|
|
10
|
+
// shape via a `gpg` subprocess — no JS dep). Until that lands, secrets are
|
|
11
|
+
// stored as plaintext and `set -x` is a no-op with a warning. The merged
|
|
12
|
+
// config still uses property-descriptor merging so the encrypted-getter
|
|
13
|
+
// pattern can drop in later without a breaking change to callers.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
|
|
19
|
+
const ENV_PREFIX = 'OPENJS_CNA_'
|
|
20
|
+
|
|
21
|
+
const ENV_TO_KEY = {
|
|
22
|
+
OPENJS_CNA_TOKEN: 'openjs_cna_token',
|
|
23
|
+
OPENJS_CNA_BASE_URL: 'openjs_cna_base_url'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function globalConfigPath () {
|
|
27
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
|
|
28
|
+
return path.join(xdg, 'openjs-cna-experimental', 'config.json')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function projectConfigPath (cwd = process.cwd()) {
|
|
32
|
+
return path.join(cwd, '.openjs-cna.json')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeReadJson (filePath) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
38
|
+
return JSON.parse(raw)
|
|
39
|
+
} catch {
|
|
40
|
+
return {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function envOverrides () {
|
|
45
|
+
const out = {}
|
|
46
|
+
for (const [envName, key] of Object.entries(ENV_TO_KEY)) {
|
|
47
|
+
if (process.env[envName] != null) out[key] = process.env[envName]
|
|
48
|
+
}
|
|
49
|
+
// Pass-through for any future OPENJS_CNA_* env vars we haven't mapped yet.
|
|
50
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
51
|
+
if (!name.startsWith(ENV_PREFIX)) continue
|
|
52
|
+
if (ENV_TO_KEY[name]) continue
|
|
53
|
+
const key = name.slice(ENV_PREFIX.length).toLowerCase()
|
|
54
|
+
out[key] = value
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Merge configs using property descriptors so future encrypted-getter
|
|
60
|
+
// properties survive the merge without being prematurely evaluated. Today
|
|
61
|
+
// every value is a plain string; this is a no-op cost paid for future-
|
|
62
|
+
// proofing.
|
|
63
|
+
function mergeWithDescriptors (...sources) {
|
|
64
|
+
const out = Object.create(null)
|
|
65
|
+
for (const source of sources) {
|
|
66
|
+
if (!source) continue
|
|
67
|
+
Object.defineProperties(out, Object.getOwnPropertyDescriptors(source))
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getMergedConfig ({ cwd } = {}) {
|
|
73
|
+
const global = safeReadJson(globalConfigPath())
|
|
74
|
+
const project = safeReadJson(projectConfigPath(cwd))
|
|
75
|
+
const env = envOverrides()
|
|
76
|
+
return mergeWithDescriptors(global, project, env)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ensureDir (filePath) {
|
|
80
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function setConfigValue (key, value, { scope = 'global', cwd } = {}) {
|
|
84
|
+
const target = scope === 'project' ? projectConfigPath(cwd) : globalConfigPath()
|
|
85
|
+
const current = safeReadJson(target)
|
|
86
|
+
current[key] = value
|
|
87
|
+
ensureDir(target)
|
|
88
|
+
fs.writeFileSync(target, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
|
|
89
|
+
return target
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getConfigValue (key, opts = {}) {
|
|
93
|
+
const merged = getMergedConfig(opts)
|
|
94
|
+
return merged[key]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function deleteConfigValue (key, { scope = 'global', cwd } = {}) {
|
|
98
|
+
const target = scope === 'project' ? projectConfigPath(cwd) : globalConfigPath()
|
|
99
|
+
const current = safeReadJson(target)
|
|
100
|
+
if (!(key in current)) return false
|
|
101
|
+
delete current[key]
|
|
102
|
+
ensureDir(target)
|
|
103
|
+
fs.writeFileSync(target, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Returns a redacted view of the merged config: secret-looking keys
|
|
108
|
+
// (anything containing 'token' or 'secret') are replaced with a fixed
|
|
109
|
+
// placeholder so `openjs-cna-experimental config list` can be piped without leaking.
|
|
110
|
+
export function listConfig (opts = {}) {
|
|
111
|
+
const merged = getMergedConfig(opts)
|
|
112
|
+
const out = {}
|
|
113
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
114
|
+
if (/token|secret|password/i.test(key)) {
|
|
115
|
+
out[key] = '<redacted>'
|
|
116
|
+
} else {
|
|
117
|
+
out[key] = value
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Convert a GitHub Security Advisory into a v5.2 CNA Container draft.
|
|
2
|
+
//
|
|
3
|
+
// Input shapes:
|
|
4
|
+
//
|
|
5
|
+
// 1. **Per-repository advisory URL** —
|
|
6
|
+
// `https://github.com/<owner>/<repo>/security/advisories/<GHSA>`.
|
|
7
|
+
// Routes to `GET https://api.github.com/repos/<owner>/<repo>/security-advisories/<GHSA>`.
|
|
8
|
+
// Public, no auth required for published advisories.
|
|
9
|
+
//
|
|
10
|
+
// 2. **Global database advisory URL** —
|
|
11
|
+
// `https://github.com/advisories/<GHSA>`.
|
|
12
|
+
// Routes to `GET https://api.github.com/advisories/<GHSA>`.
|
|
13
|
+
// Public, no auth required.
|
|
14
|
+
//
|
|
15
|
+
// 3. **Pre-fetched advisory object** — for callers that already have
|
|
16
|
+
// the advisory JSON in hand (webhook payloads, cached responses,
|
|
17
|
+
// tests, or `openjs-cna-experimental convert -` reading from stdin).
|
|
18
|
+
//
|
|
19
|
+
// This tool only reads PUBLIC published advisories. Private drafts are
|
|
20
|
+
// out of scope — operators with access can `gh api … > advisory.json &&
|
|
21
|
+
// openjs-cna-experimental convert -` to convert from stdin instead.
|
|
22
|
+
//
|
|
23
|
+
// The mapper (`mapAdvisoryToRecord`) is exported separately so the pure
|
|
24
|
+
// shape-translation logic is testable without touching the network.
|
|
25
|
+
|
|
26
|
+
import { USER_AGENT } from '../client.js'
|
|
27
|
+
import { buildCvssMetric, buildProblemType, buildRecordEnvelope } from '../record.js'
|
|
28
|
+
import { OPENJS_CNA_ORG_ID } from '../review/schema.js'
|
|
29
|
+
import { parseVersionRange } from './version-range.js'
|
|
30
|
+
|
|
31
|
+
// `ENGINE_ID` is the same string we send as `User-Agent` on every HTTP
|
|
32
|
+
// call (see lib/client.js). Re-exported here so callers that only need
|
|
33
|
+
// the converter don't have to reach into the transport module.
|
|
34
|
+
export const ENGINE_ID = USER_AGENT
|
|
35
|
+
|
|
36
|
+
// GHSA credit `type` values use underscores. MITRE's CVE Record Format
|
|
37
|
+
// schema (cnaPublishedContainer → credits → type) requires the
|
|
38
|
+
// space-separated forms. Map each known GHSA role to its MITRE spelling.
|
|
39
|
+
const CREDIT_TYPE_MAP = {
|
|
40
|
+
finder: 'finder',
|
|
41
|
+
reporter: 'reporter',
|
|
42
|
+
analyst: 'analyst',
|
|
43
|
+
coordinator: 'coordinator',
|
|
44
|
+
remediation_developer: 'remediation developer',
|
|
45
|
+
remediation_reviewer: 'remediation reviewer',
|
|
46
|
+
remediation_verifier: 'remediation verifier',
|
|
47
|
+
tool: 'tool',
|
|
48
|
+
sponsor: 'sponsor',
|
|
49
|
+
other: 'other'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const URL_REPO_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4})/i
|
|
53
|
+
const URL_GLOBAL_RE = /^https:\/\/github\.com\/advisories\/(GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4})/i
|
|
54
|
+
|
|
55
|
+
// Tag the canonical GitHub advisory URL (the GHSA we are converting) as
|
|
56
|
+
// `vendor-advisory`. Everything else is left untagged — the operator
|
|
57
|
+
// can hand-tag in Vulnogram or by editing the JSON before publish.
|
|
58
|
+
//
|
|
59
|
+
// Why: heuristics for CVE / commit / blog URLs accumulate edge cases
|
|
60
|
+
// fast (different hosts, different URL shapes, redirects) and the cost
|
|
61
|
+
// of a wrong tag at publish time is real. Better to ship one tag we can
|
|
62
|
+
// be sure of than several that drift.
|
|
63
|
+
function tagForUrl (url, { selfGhsaId } = {}) {
|
|
64
|
+
if (!selfGhsaId) return null
|
|
65
|
+
const selfRe = new RegExp(
|
|
66
|
+
`(github\\.com/.*/security/advisories/${selfGhsaId}\\b|github\\.com/advisories/${selfGhsaId}\\b)`,
|
|
67
|
+
'i'
|
|
68
|
+
)
|
|
69
|
+
return selfRe.test(url) ? 'vendor-advisory' : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Parse a github.com advisory URL into a structured descriptor or return
|
|
73
|
+
// null if the input doesn't look like one. The `kind` field tells the
|
|
74
|
+
// fetcher which GitHub REST endpoint to hit.
|
|
75
|
+
//
|
|
76
|
+
// { kind: 'repo', owner, repo, ghsaId } → /repos/{owner}/{repo}/security-advisories/{ghsaId}
|
|
77
|
+
// { kind: 'global', ghsaId } → /advisories/{ghsaId}
|
|
78
|
+
//
|
|
79
|
+
// Exported so tests and other callers can use the same parser.
|
|
80
|
+
export function parseAdvisoryUrl (url) {
|
|
81
|
+
if (typeof url !== 'string') return null
|
|
82
|
+
const repoMatch = url.match(URL_REPO_RE)
|
|
83
|
+
if (repoMatch) {
|
|
84
|
+
return { kind: 'repo', owner: repoMatch[1], repo: repoMatch[2], ghsaId: repoMatch[3] }
|
|
85
|
+
}
|
|
86
|
+
const globalMatch = url.match(URL_GLOBAL_RE)
|
|
87
|
+
if (globalMatch) {
|
|
88
|
+
return { kind: 'global', ghsaId: globalMatch[1] }
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// GitHub's REST API for published advisories is public. We pass
|
|
94
|
+
// `User-Agent` and `Accept` headers per GitHub's docs, but no auth.
|
|
95
|
+
// Injectable `fetchImpl` for tests.
|
|
96
|
+
async function ghFetch (url, { fetchImpl = globalThis.fetch } = {}) {
|
|
97
|
+
const res = await fetchImpl(url, {
|
|
98
|
+
headers: {
|
|
99
|
+
Accept: 'application/vnd.github+json',
|
|
100
|
+
'User-Agent': USER_AGENT,
|
|
101
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`GitHub returned ${res.status} for ${url}. ` +
|
|
107
|
+
'The advisory may be private or unpublished — this tool only supports public advisories.'
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return res.json()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchAdvisory (source, opts = {}) {
|
|
114
|
+
if (source && typeof source === 'object') return source
|
|
115
|
+
if (typeof source !== 'string') {
|
|
116
|
+
throw new TypeError(`source must be a string or object, got ${typeof source}`)
|
|
117
|
+
}
|
|
118
|
+
const parsed = parseAdvisoryUrl(source)
|
|
119
|
+
if (parsed?.kind === 'repo') {
|
|
120
|
+
return ghFetch(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/security-advisories/${parsed.ghsaId}`, opts)
|
|
121
|
+
}
|
|
122
|
+
if (parsed?.kind === 'global') {
|
|
123
|
+
return ghFetch(`https://api.github.com/advisories/${parsed.ghsaId}`, opts)
|
|
124
|
+
}
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Cannot parse advisory source: '${source}'. Pass a public advisory URL:\n` +
|
|
127
|
+
' - per-repository: https://github.com/<owner>/<repo>/security/advisories/<GHSA>\n' +
|
|
128
|
+
' - global database: https://github.com/advisories/<GHSA>'
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ----- Mappers --------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
// Drop the References section (and everything after it) from a Markdown
|
|
135
|
+
// description. The URLs are already structured in `cna.references[]`,
|
|
136
|
+
// so leaving them in the description would duplicate the data. We strip
|
|
137
|
+
// this from BOTH the plain-text value and the raw Markdown that travels
|
|
138
|
+
// in supportingMedia, so neither representation carries the duplicate.
|
|
139
|
+
function stripReferencesSection (md) {
|
|
140
|
+
if (typeof md !== 'string') return ''
|
|
141
|
+
const normalized = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
142
|
+
// Case-insensitive (trailing /i flag), so "References", "references",
|
|
143
|
+
// and "REFERENCES" all match across the three shapes we accept:
|
|
144
|
+
// ATX heading (`### References`), bold (`**References**`), and
|
|
145
|
+
// plain label (`References:`).
|
|
146
|
+
const refIdx = normalized.search(
|
|
147
|
+
/(?:^|\n)\s*(?:#{1,6}\s+References\b|\*\*References\*\*|References\s*:)\s*\n/i
|
|
148
|
+
)
|
|
149
|
+
return refIdx >= 0 ? normalized.slice(0, refIdx).trimEnd() : normalized
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Strip Markdown formatting down to readable plain text. Generic — no
|
|
153
|
+
// hard-coded section names beyond the References block (which is dropped
|
|
154
|
+
// upstream by stripReferencesSection because cna.references[] is its
|
|
155
|
+
// structured home). Headers become "Label:" lines (matches the published
|
|
156
|
+
// OpenJS CVE convention), emphasis/code/links collapse to their text
|
|
157
|
+
// content, list bullets drop the bullet marker, blockquote markers drop.
|
|
158
|
+
function stripMarkdown (md) {
|
|
159
|
+
if (typeof md !== 'string') return ''
|
|
160
|
+
// Normalize line endings — GitHub serves CRLF, every regex below uses
|
|
161
|
+
// `\n` as the line boundary.
|
|
162
|
+
let text = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
163
|
+
// Headers → "Label:" (any ATX heading, level 1–6, generic). We use
|
|
164
|
+
// [ \t]* (not \s*) for leading/trailing whitespace so we don't eat the
|
|
165
|
+
// preceding/following blank lines — those are what visually separate
|
|
166
|
+
// the heading label from neighboring paragraphs.
|
|
167
|
+
text = text.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*$/gm, '$1:')
|
|
168
|
+
// **bold** / __bold__ / *italic* / _italic_ → inner text
|
|
169
|
+
text = text.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
170
|
+
text = text.replace(/__(.+?)__/g, '$1')
|
|
171
|
+
text = text.replace(/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/g, '$1')
|
|
172
|
+
text = text.replace(/(?<![_\w])_(?!\s)(.+?)(?<!\s)_(?![_\w])/g, '$1')
|
|
173
|
+
// `inline code` → inner text
|
|
174
|
+
text = text.replace(/`(.+?)`/g, '$1')
|
|
175
|
+
// [link](url) → link
|
|
176
|
+
text = text.replace(/\[(.+?)\]\([^)]+\)/g, '$1')
|
|
177
|
+
// Bullet / ordered-list markers at line start → strip the marker
|
|
178
|
+
text = text.replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
|
|
179
|
+
// Blockquote markers
|
|
180
|
+
text = text.replace(/^\s*>\s?/gm, '')
|
|
181
|
+
// Collapse 3+ blank lines to a single blank line.
|
|
182
|
+
text = text.replace(/\n{3,}/g, '\n\n')
|
|
183
|
+
return text.trim()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildDescriptions (advisory) {
|
|
187
|
+
// GHSA advisory descriptions are authored in Markdown. MITRE's
|
|
188
|
+
// descriptions[0].value field is plain-text in every published OpenJS
|
|
189
|
+
// CVE on record, so we strip the Markdown for the value and put the
|
|
190
|
+
// (de-duplicated) original Markdown into supportingMedia[0]. The
|
|
191
|
+
// References block is removed from both representations because its
|
|
192
|
+
// URLs already live in cna.references[] as structured data.
|
|
193
|
+
const original = (advisory.description ?? '').trim() || (advisory.summary ?? '').trim()
|
|
194
|
+
if (!original) return []
|
|
195
|
+
const raw = stripReferencesSection(original).trim()
|
|
196
|
+
if (!raw) return []
|
|
197
|
+
const plain = stripMarkdown(raw) || raw
|
|
198
|
+
const description = {
|
|
199
|
+
lang: 'en',
|
|
200
|
+
value: plain
|
|
201
|
+
}
|
|
202
|
+
// Only attach supportingMedia when the raw Markdown still carries
|
|
203
|
+
// formatting after the References strip. If plain === raw the markdown
|
|
204
|
+
// form has nothing extra to offer.
|
|
205
|
+
if (raw !== plain) {
|
|
206
|
+
description.supportingMedia = [{ type: 'text/markdown', base64: false, value: raw }]
|
|
207
|
+
}
|
|
208
|
+
return [description]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildAffected (advisory) {
|
|
212
|
+
const vulnerabilities = advisory.vulnerabilities ?? []
|
|
213
|
+
return vulnerabilities.map((v) => {
|
|
214
|
+
// parseVersionRange expands a GHSA range string into MITRE-shaped
|
|
215
|
+
// entries (version + lessThan/lessThanOrEqual + versionType). Multi-
|
|
216
|
+
// segment inputs (e.g. "< 6.24.0; 7.0.0 < 7.24.0") become multiple
|
|
217
|
+
// entries; empty input becomes []. See lib/convert/version-range.js
|
|
218
|
+
// and the matrix at test/fixtures/version-range-matrix.js for the
|
|
219
|
+
// full shape coverage.
|
|
220
|
+
const versions = parseVersionRange(v.vulnerable_version_range ?? '')
|
|
221
|
+
return {
|
|
222
|
+
vendor: v.package?.ecosystem === 'npm' ? 'npm' : 'unknown',
|
|
223
|
+
product: v.package?.name ?? 'unknown',
|
|
224
|
+
defaultStatus: 'unaffected',
|
|
225
|
+
versions
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildProblemTypes (advisory) {
|
|
231
|
+
const cwes = advisory.cwes ?? []
|
|
232
|
+
if (cwes.length === 0) return undefined
|
|
233
|
+
// GHSA lists every CWE of the advisory; MITRE groups them as the
|
|
234
|
+
// descriptions of one problemType entry.
|
|
235
|
+
return [buildProblemType(cwes.map((cwe) => ({ cweId: cwe.cwe_id, name: cwe.name })))]
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildMetrics (advisory) {
|
|
239
|
+
// GHSA serves CVSS in two parallel places, depending on endpoint and
|
|
240
|
+
// age of the advisory:
|
|
241
|
+
//
|
|
242
|
+
// * Top-level `cvss` / `cvss_v3` — older v3.1-only shape, may be
|
|
243
|
+
// null when the reporter only filed a v4 vector.
|
|
244
|
+
// * `cvss_severities.cvss_v3` / `cvss_severities.cvss_v4` — newer
|
|
245
|
+
// shape; v4 can be the only populated entry when the reporter
|
|
246
|
+
// used the v4 calculator.
|
|
247
|
+
//
|
|
248
|
+
// Emit a `cvssV3_1` block whenever a v3.1 vector exists, and a
|
|
249
|
+
// `cvssV4_0` block whenever a v4 vector exists. Both can appear; we
|
|
250
|
+
// emit each in its own metric entry so the MITRE schema (which gates
|
|
251
|
+
// additional properties on cvssVx blocks) is happy. GHSA only issues
|
|
252
|
+
// 3.1 and 4.0 vectors; `buildCvssMetric` computes a missing v3.1 score
|
|
253
|
+
// and passes through a vector it cannot parse for the reviewer to flag.
|
|
254
|
+
const metrics = []
|
|
255
|
+
const v3Vector = advisory.cvss?.vector_string ??
|
|
256
|
+
advisory.cvss_v3?.vector_string ??
|
|
257
|
+
advisory.cvss_severities?.cvss_v3?.vector_string
|
|
258
|
+
const v3Score = advisory.cvss?.score ??
|
|
259
|
+
advisory.cvss_v3?.score ??
|
|
260
|
+
advisory.cvss_severities?.cvss_v3?.score
|
|
261
|
+
if (v3Vector) {
|
|
262
|
+
metrics.push(buildCvssMetric(v3Vector, { baseScore: v3Score }))
|
|
263
|
+
}
|
|
264
|
+
const v4Vector = advisory.cvss_v4?.vector_string ??
|
|
265
|
+
advisory.cvss_severities?.cvss_v4?.vector_string
|
|
266
|
+
const v4Score = advisory.cvss_v4?.score ??
|
|
267
|
+
advisory.cvss_severities?.cvss_v4?.score
|
|
268
|
+
if (v4Vector) {
|
|
269
|
+
metrics.push(buildCvssMetric(v4Vector, { baseScore: v4Score }))
|
|
270
|
+
}
|
|
271
|
+
return metrics.length > 0 ? metrics : undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildCredits (advisory) {
|
|
275
|
+
// GitHub's two advisory endpoints return credits in different shapes:
|
|
276
|
+
//
|
|
277
|
+
// * Global `/advisories/{id}` returns `credits: [{ user: { login }, type }]`
|
|
278
|
+
// * Per-repo `/repos/.../security-advisories/{id}` returns
|
|
279
|
+
// `credits: [{ login, type }]` (login at the top level, no `user`
|
|
280
|
+
// wrapper) AND `credits_detailed: [{ user: { login }, ... }]`.
|
|
281
|
+
//
|
|
282
|
+
// Prefer `credits_detailed` when it has entries (richer per-repo shape);
|
|
283
|
+
// otherwise fall back to `credits`. The login extractor below tolerates
|
|
284
|
+
// both shapes so a hand-fed advisory object works either way.
|
|
285
|
+
const sourceCredits = advisory.credits_detailed?.length
|
|
286
|
+
? advisory.credits_detailed
|
|
287
|
+
: (advisory.credits ?? [])
|
|
288
|
+
if (sourceCredits.length === 0) return undefined
|
|
289
|
+
return sourceCredits.map((c) => {
|
|
290
|
+
// Try every shape we know about: { login }, { user: 'string' },
|
|
291
|
+
// { user: { login } }, falling through to 'unknown'.
|
|
292
|
+
const login = c.login ??
|
|
293
|
+
(typeof c.user === 'string' ? c.user : c.user?.login) ??
|
|
294
|
+
'unknown'
|
|
295
|
+
const ghsaType = c.type ?? 'finder'
|
|
296
|
+
// MITRE's schema uses space-separated values for multi-word types
|
|
297
|
+
// (e.g. 'remediation developer'). GHSA uses underscores. Map across.
|
|
298
|
+
const mitreType = CREDIT_TYPE_MAP[ghsaType] ?? 'other'
|
|
299
|
+
// We do NOT set `user` on the MITRE credit (the schema requires a
|
|
300
|
+
// UUID there and we don't have one). `value` carries the GitHub login
|
|
301
|
+
// verbatim, which is the human-readable identity MITRE expects on
|
|
302
|
+
// free-form attribution.
|
|
303
|
+
return {
|
|
304
|
+
lang: 'en',
|
|
305
|
+
type: mitreType,
|
|
306
|
+
value: login
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Pull URLs out of the description's "References" section. Per-repo
|
|
312
|
+
// advisories return `references: null` from the REST API but operators
|
|
313
|
+
// usually list the URLs in the Markdown body under a heading like
|
|
314
|
+
// `### References` or `**References**`. From the first such header to
|
|
315
|
+
// the end of the description (or the next heading), grab every URL.
|
|
316
|
+
function extractReferencesFromDescription (text) {
|
|
317
|
+
if (typeof text !== 'string') return []
|
|
318
|
+
// Match the "References" header. We accept Markdown headings (`#+`),
|
|
319
|
+
// bold (`**References**`), and bare `References:` followed by colon
|
|
320
|
+
// or newline. Case-insensitive.
|
|
321
|
+
const headerRe = /(?:^|\n)\s*(?:#{1,6}\s+References\b|\*\*References\*\*|References\s*:)\s*\n/i
|
|
322
|
+
const headerMatch = text.match(headerRe)
|
|
323
|
+
if (!headerMatch) return []
|
|
324
|
+
const start = headerMatch.index + headerMatch[0].length
|
|
325
|
+
// Cut off at the next Markdown heading so we don't pull URLs from a
|
|
326
|
+
// following section. If there is none, take the rest of the text.
|
|
327
|
+
const after = text.slice(start)
|
|
328
|
+
const nextHeading = after.match(/\n\s*#{1,6}\s+\S/)
|
|
329
|
+
const section = nextHeading ? after.slice(0, nextHeading.index) : after
|
|
330
|
+
// Pull every http(s) URL; let the dedupe in buildReferences handle
|
|
331
|
+
// duplicates if the same URL appears twice in the section.
|
|
332
|
+
const urls = section.match(/https?:\/\/\S+/g) ?? []
|
|
333
|
+
// Strip trailing punctuation common in Markdown bullets/sentences.
|
|
334
|
+
return urls.map(u => u.replace(/[).,;]+$/, ''))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Walk GHSA's `references[]` and emit MITRE references. When the API
|
|
338
|
+
// returns an empty/null `references` (per-repo endpoint does this), fall
|
|
339
|
+
// back to scraping the description's "References" section. The
|
|
340
|
+
// `vendor-advisory` tag is heuristic — see tagForUrl. URLs that don't
|
|
341
|
+
// match a known pattern get no tag (MITRE accepts untagged references).
|
|
342
|
+
function buildReferences (advisory) {
|
|
343
|
+
const out = []
|
|
344
|
+
const seen = new Set()
|
|
345
|
+
// Pass the advisory's own GHSA id into tagForUrl so the self-reference
|
|
346
|
+
// (the canonical GitHub advisory page for the same GHSA) doesn't get
|
|
347
|
+
// mislabeled `third-party-advisory`.
|
|
348
|
+
const selfGhsaId = advisory.ghsa_id
|
|
349
|
+
const push = (url) => {
|
|
350
|
+
if (!url || seen.has(url)) return
|
|
351
|
+
seen.add(url)
|
|
352
|
+
const tag = tagForUrl(url, { selfGhsaId })
|
|
353
|
+
out.push(tag ? { url, tags: [tag] } : { url })
|
|
354
|
+
}
|
|
355
|
+
// Primary source: the API's structured `references[]`.
|
|
356
|
+
for (const ref of advisory.references ?? []) {
|
|
357
|
+
const url = typeof ref === 'string' ? ref : ref?.url
|
|
358
|
+
push(url)
|
|
359
|
+
}
|
|
360
|
+
// Fallback: scrape the description's References section. Only kicks
|
|
361
|
+
// in when the structured list was empty.
|
|
362
|
+
if (out.length === 0) {
|
|
363
|
+
for (const url of extractReferencesFromDescription(advisory.description)) {
|
|
364
|
+
push(url)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return out
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ----- Public API -----------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
// Pure mapper: GHSA advisory JSON → full v5.2 **CVE Record**.
|
|
373
|
+
//
|
|
374
|
+
// Output shape (Vulnogram-compatible, matches every published OpenJS CVE
|
|
375
|
+
// on MITRE):
|
|
376
|
+
//
|
|
377
|
+
// {
|
|
378
|
+
// dataType: 'CVE_RECORD',
|
|
379
|
+
// dataVersion: '5.2',
|
|
380
|
+
// cveMetadata: { cveId, assignerOrgId, state: 'PUBLISHED' },
|
|
381
|
+
// containers: { cna: { ...the actual container... } }
|
|
382
|
+
// }
|
|
383
|
+
//
|
|
384
|
+
// CVE id resolution:
|
|
385
|
+
// - If the advisory carries `cve_id`, the mapper stamps it into
|
|
386
|
+
// `cveMetadata.cveId` automatically.
|
|
387
|
+
// - The caller can also pass an explicit `cveId` option as a safety
|
|
388
|
+
// belt. If both are present and they disagree, that's a hard error.
|
|
389
|
+
// - If neither side has a CVE id, the record carries no `cveMetadata`;
|
|
390
|
+
// the publisher's CLI arg becomes the sole source of truth.
|
|
391
|
+
//
|
|
392
|
+
// Inputs:
|
|
393
|
+
// advisory — the GHSA advisory object (REST API shape)
|
|
394
|
+
// cveId — optional explicit CVE id. Cross-checked against
|
|
395
|
+
// `advisory.cve_id` when both are present.
|
|
396
|
+
// engineId — override the x_generator.engine string (default:
|
|
397
|
+
// this package + version)
|
|
398
|
+
// assignerOrgId — override the CNA org UUID (default: OpenJS Foundation)
|
|
399
|
+
//
|
|
400
|
+
// References are derived from `advisory.references[]` with a fallback
|
|
401
|
+
// to scraping the description's "References" section for per-repo
|
|
402
|
+
// advisories where the API returns null.
|
|
403
|
+
//
|
|
404
|
+
// Exposed for tests and for callers that already have the advisory in hand.
|
|
405
|
+
export function mapAdvisoryToRecord (advisory, {
|
|
406
|
+
cveId,
|
|
407
|
+
engineId = ENGINE_ID,
|
|
408
|
+
assignerOrgId = OPENJS_CNA_ORG_ID
|
|
409
|
+
} = {}) {
|
|
410
|
+
if (!advisory || typeof advisory !== 'object') {
|
|
411
|
+
throw new TypeError('mapAdvisoryToRecord: `advisory` must be the GHSA object')
|
|
412
|
+
}
|
|
413
|
+
// Reconcile the two possible CVE-id sources. Both API endpoints
|
|
414
|
+
// (per-repo and global) expose `cve_id` when one's been assigned.
|
|
415
|
+
const advisoryCveId = advisory.cve_id || undefined
|
|
416
|
+
if (cveId && advisoryCveId && cveId !== advisoryCveId) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`CVE id mismatch: caller passed ${cveId}, advisory reports ${advisoryCveId}. ` +
|
|
419
|
+
'Refusing to publish — re-check which CVE you meant to associate.'
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
const resolvedCveId = cveId ?? advisoryCveId
|
|
423
|
+
|
|
424
|
+
// The envelope, providerMetadata and x_generator come from
|
|
425
|
+
// buildRecordEnvelope so every converter emits them the same way.
|
|
426
|
+
return buildRecordEnvelope({
|
|
427
|
+
cveId: resolvedCveId,
|
|
428
|
+
assignerOrgId,
|
|
429
|
+
engineId,
|
|
430
|
+
cna: {
|
|
431
|
+
title: advisory.summary,
|
|
432
|
+
descriptions: buildDescriptions(advisory),
|
|
433
|
+
affected: buildAffected(advisory),
|
|
434
|
+
problemTypes: buildProblemTypes(advisory),
|
|
435
|
+
metrics: buildMetrics(advisory),
|
|
436
|
+
credits: buildCredits(advisory),
|
|
437
|
+
references: buildReferences(advisory)
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Fetch + map convenience. Returns the full CVE Record (Vulnogram-
|
|
443
|
+
// compatible). `fetchImpl` is injectable for tests.
|
|
444
|
+
export async function convertGhsaToRecord ({
|
|
445
|
+
source, cveId, engineId, assignerOrgId, fetchImpl
|
|
446
|
+
} = {}) {
|
|
447
|
+
if (!source) throw new Error('convertGhsaToRecord: `source` is required')
|
|
448
|
+
const advisory = await fetchAdvisory(source, { fetchImpl })
|
|
449
|
+
return mapAdvisoryToRecord(advisory, { cveId, engineId, assignerOrgId })
|
|
450
|
+
}
|