@secureport/core 0.2.1 → 0.4.0

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.
Files changed (100) hide show
  1. package/README.md +146 -33
  2. package/dist/coverage.d.ts +21 -0
  3. package/dist/coverage.d.ts.map +1 -0
  4. package/dist/coverage.js +65 -0
  5. package/dist/coverage.js.map +1 -0
  6. package/dist/finding.d.ts +89 -0
  7. package/dist/finding.d.ts.map +1 -0
  8. package/dist/finding.js +2 -0
  9. package/dist/finding.js.map +1 -0
  10. package/dist/fingerprint.d.ts +185 -0
  11. package/dist/fingerprint.d.ts.map +1 -0
  12. package/dist/fingerprint.js +247 -0
  13. package/dist/fingerprint.js.map +1 -0
  14. package/dist/import/burp.d.ts +19 -0
  15. package/dist/import/burp.d.ts.map +1 -0
  16. package/dist/import/burp.js +114 -0
  17. package/dist/import/burp.js.map +1 -0
  18. package/dist/import/generic.d.ts +90 -0
  19. package/dist/import/generic.d.ts.map +1 -0
  20. package/dist/import/generic.js +159 -0
  21. package/dist/import/generic.js.map +1 -0
  22. package/dist/import/nessus.d.ts +32 -0
  23. package/dist/import/nessus.d.ts.map +1 -0
  24. package/dist/import/nessus.js +125 -0
  25. package/dist/import/nessus.js.map +1 -0
  26. package/dist/import/nuclei.d.ts +39 -0
  27. package/dist/import/nuclei.d.ts.map +1 -0
  28. package/dist/import/nuclei.js +115 -0
  29. package/dist/import/nuclei.js.map +1 -0
  30. package/dist/import/xml.d.ts +47 -0
  31. package/dist/import/xml.d.ts.map +1 -0
  32. package/dist/import/xml.js +157 -0
  33. package/dist/import/xml.js.map +1 -0
  34. package/dist/import/zap.d.ts +26 -0
  35. package/dist/import/zap.d.ts.map +1 -0
  36. package/dist/import/zap.js +119 -0
  37. package/dist/import/zap.js.map +1 -0
  38. package/dist/index.d.ts +41 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +29 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/issue.d.ts +190 -11
  43. package/dist/issue.d.ts.map +1 -1
  44. package/dist/reconcile.d.ts +115 -10
  45. package/dist/reconcile.d.ts.map +1 -1
  46. package/dist/reconcile.js +306 -12
  47. package/dist/reconcile.js.map +1 -1
  48. package/dist/report/html.d.ts +21 -0
  49. package/dist/report/html.d.ts.map +1 -0
  50. package/dist/report/html.js +324 -0
  51. package/dist/report/html.js.map +1 -0
  52. package/dist/report/json.d.ts +81 -0
  53. package/dist/report/json.d.ts.map +1 -0
  54. package/dist/report/json.js +47 -0
  55. package/dist/report/json.js.map +1 -0
  56. package/dist/report/markdown.d.ts +24 -0
  57. package/dist/report/markdown.d.ts.map +1 -0
  58. package/dist/report/markdown.js +304 -0
  59. package/dist/report/markdown.js.map +1 -0
  60. package/dist/report/model.d.ts +215 -0
  61. package/dist/report/model.d.ts.map +1 -0
  62. package/dist/report/model.js +197 -0
  63. package/dist/report/model.js.map +1 -0
  64. package/dist/run.d.ts +134 -0
  65. package/dist/run.d.ts.map +1 -0
  66. package/dist/run.js +2 -0
  67. package/dist/run.js.map +1 -0
  68. package/dist/severity.d.ts +191 -0
  69. package/dist/severity.d.ts.map +1 -0
  70. package/dist/severity.js +171 -0
  71. package/dist/severity.js.map +1 -0
  72. package/dist/snapshot-builder.d.ts +78 -0
  73. package/dist/snapshot-builder.d.ts.map +1 -0
  74. package/dist/snapshot-builder.js +172 -0
  75. package/dist/snapshot-builder.js.map +1 -0
  76. package/dist/snapshot.d.ts +125 -0
  77. package/dist/snapshot.d.ts.map +1 -0
  78. package/dist/snapshot.js +2 -0
  79. package/dist/snapshot.js.map +1 -0
  80. package/package.json +5 -4
  81. package/src/coverage.ts +65 -0
  82. package/src/finding.ts +112 -0
  83. package/src/fingerprint.ts +315 -0
  84. package/src/import/burp.ts +126 -0
  85. package/src/import/generic.ts +258 -0
  86. package/src/import/nessus.ts +136 -0
  87. package/src/import/nuclei.ts +173 -0
  88. package/src/import/xml.ts +187 -0
  89. package/src/import/zap.ts +161 -0
  90. package/src/index.ts +75 -2
  91. package/src/issue.ts +244 -11
  92. package/src/reconcile.ts +421 -17
  93. package/src/report/html.ts +449 -0
  94. package/src/report/json.ts +134 -0
  95. package/src/report/markdown.ts +435 -0
  96. package/src/report/model.ts +462 -0
  97. package/src/run.ts +163 -0
  98. package/src/severity.ts +250 -0
  99. package/src/snapshot-builder.ts +225 -0
  100. package/src/snapshot.ts +146 -0
@@ -0,0 +1,258 @@
1
+ import type { Finding } from '../finding.js';
2
+ import type { Severity, SeveritySource } from '../severity.js';
3
+ import { SEVERITY_ORDER, severityFromCvss } from '../severity.js';
4
+ import { fingerprint, vulnKey, FINGERPRINT_VERSION } from '../fingerprint.js';
5
+ import type { ImportOptions } from './nuclei.js';
6
+
7
+ /**
8
+ * One finding in the generic Secureport format.
9
+ *
10
+ * **Written last, on purpose.** This is the shape the four scanner importers
11
+ * turned out to have in common, rather than a format designed in advance and
12
+ * then argued with. Everything an engine reliably supplies is here; everything
13
+ * only one of them had is not.
14
+ *
15
+ * Only `title` and `location` are required. A tool that knows nothing but "this
16
+ * is wrong, and it is here" can still produce a trackable finding — which is the
17
+ * point of having a generic format at all.
18
+ */
19
+ export interface GenericFinding {
20
+ /** What is wrong. */
21
+ readonly title: string;
22
+
23
+ /** Where it is — a URL, a host, a `host:port`, or a file path. */
24
+ readonly location: string;
25
+
26
+ /** Fuller explanation. */
27
+ readonly description?: string;
28
+
29
+ /**
30
+ * Severity, if the tool rates it.
31
+ *
32
+ * Ignored when `cvssScore` is present: a score is more precise than a word,
33
+ * and `severitySource` records which was used.
34
+ */
35
+ readonly severity?: Severity;
36
+
37
+ /** CVSS base score, 0–10. Preferred over `severity` when both are given. */
38
+ readonly cvssScore?: number;
39
+
40
+ /** CVSS vector string. */
41
+ readonly cvssVector?: string;
42
+
43
+ /** CWE identifier, with or without the `CWE-` prefix. */
44
+ readonly cwe?: string;
45
+
46
+ /** CVE identifier. */
47
+ readonly cve?: string;
48
+
49
+ /** Broad grouping, e.g. `injection`. */
50
+ readonly category?: string;
51
+
52
+ /** The parameter implicated, where the weakness has one. */
53
+ readonly parameter?: string;
54
+
55
+ /** The port, for findings about a service rather than a path. */
56
+ readonly port?: number;
57
+
58
+ /** What produced it. Defaults to `generic`. */
59
+ readonly engine?: string;
60
+
61
+ /** The producing tool's own identifier for the rule. */
62
+ readonly ruleId?: string;
63
+
64
+ /** What to do about it. */
65
+ readonly recommendation?: string;
66
+
67
+ /** Further reading. */
68
+ readonly references?: readonly string[];
69
+
70
+ /** Pointers to stored evidence. */
71
+ readonly evidenceUri?: readonly string[];
72
+
73
+ /** The tool's confidence, 0–1. */
74
+ readonly confidence?: number;
75
+ }
76
+
77
+ /** A generic findings document. */
78
+ export interface GenericDocument {
79
+ /**
80
+ * Format version.
81
+ *
82
+ * Present so this file can change shape later without guessing. An unknown
83
+ * version is refused rather than parsed optimistically.
84
+ */
85
+ readonly version: 1;
86
+
87
+ /** The findings. */
88
+ readonly findings: readonly GenericFinding[];
89
+ }
90
+
91
+ const SEVERITIES = new Set<string>(SEVERITY_ORDER);
92
+
93
+ /** A non-empty trimmed string, or nothing. */
94
+ const str = (v: unknown): string | undefined =>
95
+ typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
96
+
97
+ /** A finite number, or nothing. */
98
+ const num = (v: unknown): number | undefined =>
99
+ typeof v === 'number' && Number.isFinite(v) ? v : undefined;
100
+
101
+ /**
102
+ * A title reduced to something usable as a weakness key.
103
+ *
104
+ * The last resort, and only for the generic format. A tool that reports nothing
105
+ * but "this is wrong, and it is here" has no CWE, no category and no rule id —
106
+ * and `vulnKey` would rightly refuse, because there is nothing to key on.
107
+ * Refusing would mean the minimal case cannot be imported at all, which defeats
108
+ * the point of having a generic format.
109
+ *
110
+ * **Titles are a poor key and this is not pretending otherwise:** reword the
111
+ * title and the issue becomes a different issue, and two tools describing the
112
+ * same weakness differently never collide. Supplying a `ruleId`, a `cwe` or a
113
+ * `category` is strictly better and all three are preferred over this.
114
+ */
115
+ const titleKey = (title: string): string =>
116
+ title
117
+ .toLowerCase()
118
+ .replace(/[^a-z0-9]+/gu, '-')
119
+ .replace(/^-|-$/gu, '');
120
+
121
+ /** An array of non-empty strings, or nothing. */
122
+ const strArray = (v: unknown): string[] | undefined => {
123
+ if (!Array.isArray(v)) return undefined;
124
+ const items = (v as readonly unknown[]).map(str).filter((x): x is string => x !== undefined);
125
+ return items.length > 0 ? items : undefined;
126
+ };
127
+
128
+ /**
129
+ * Turns the generic Secureport format into {@link Finding}s.
130
+ *
131
+ * The escape hatch for everything with no importer of its own: a manual test, an
132
+ * internal tool, a scanner nobody has written a parser for. Fingerprinting,
133
+ * reconciliation and reports then work identically — a finding that arrives this
134
+ * way is not a second-class finding.
135
+ *
136
+ * Refuses the document rather than salvaging part of it. Unlike Nuclei's JSONL,
137
+ * where one bad line costs one record, this is a single document a human or a
138
+ * script wrote deliberately: a field in the wrong shape is a mistake worth
139
+ * hearing about, not one to route around silently.
140
+ *
141
+ * @param json - The contents of a generic findings document.
142
+ * @param options - Ownership, and the injected clock and id source.
143
+ * @returns One finding per entry, in document order.
144
+ * @throws SyntaxError If the text is not valid JSON.
145
+ * @throws TypeError If the document is not the expected shape, naming the entry
146
+ * and field at fault.
147
+ */
148
+ export function importGeneric(json: string, options: ImportOptions): Finding[] {
149
+ const doc: unknown = JSON.parse(json);
150
+ if (typeof doc !== 'object' || doc === null) {
151
+ throw new TypeError('generic findings document must be an object');
152
+ }
153
+ const record = doc as Record<string, unknown>;
154
+
155
+ if (record['version'] !== 1) {
156
+ throw new TypeError(`unsupported generic findings version: ${String(record['version'])}`);
157
+ }
158
+ const raw = record['findings'];
159
+ if (!Array.isArray(raw)) {
160
+ throw new TypeError('generic findings document has no `findings` array');
161
+ }
162
+
163
+ return (raw as readonly unknown[]).map((item, index) => {
164
+ const where = `findings[${String(index)}]`;
165
+ if (typeof item !== 'object' || item === null) {
166
+ throw new TypeError(`${where} is not an object`);
167
+ }
168
+ const entry = item as Record<string, unknown>;
169
+
170
+ const title = str(entry['title']);
171
+ const location = str(entry['location']);
172
+ if (title === undefined) throw new TypeError(`${where}.title is required`);
173
+ if (location === undefined) throw new TypeError(`${where}.location is required`);
174
+
175
+ const stated = str(entry['severity']);
176
+ if (stated !== undefined && !SEVERITIES.has(stated)) {
177
+ throw new TypeError(`${where}.severity is not a severity: ${stated}`);
178
+ }
179
+
180
+ const score = num(entry['cvssScore']);
181
+ let detectedSeverity: Severity;
182
+ let severitySource: SeveritySource;
183
+ if (score !== undefined && score >= 0 && score <= 10) {
184
+ detectedSeverity = severityFromCvss(score);
185
+ severitySource = 'cvss';
186
+ } else if (stated !== undefined) {
187
+ detectedSeverity = stated as Severity;
188
+ // Somebody stated it for this finding specifically, which is the
189
+ // strongest provenance there is.
190
+ severitySource = 'explicit';
191
+ } else {
192
+ detectedSeverity = 'advisory';
193
+ severitySource = 'engine_default';
194
+ }
195
+
196
+ const engine = str(entry['engine']) ?? 'generic';
197
+ const ruleId = str(entry['ruleId']);
198
+ const rawCwe = str(entry['cwe']);
199
+ const cwe = rawCwe === undefined ? undefined : `CWE-${rawCwe.replace(/^CWE-/iu, '')}`;
200
+ const category = str(entry['category']);
201
+ const parameter = str(entry['parameter']);
202
+ const port = num(entry['port']);
203
+
204
+ // Nothing classifies this finding, so key on the title rather than refuse
205
+ // it. `sourceRuleId` stays absent: the tool supplied none, and recording a
206
+ // slug as though it had would be a lie about provenance.
207
+ const key =
208
+ cwe === undefined && category === undefined && ruleId === undefined
209
+ ? `${engine}:${titleKey(title)}`
210
+ : vulnKey({
211
+ sourceEngine: engine,
212
+ ...(ruleId === undefined ? {} : { sourceRuleId: ruleId }),
213
+ ...(cwe === undefined ? {} : { cwe }),
214
+ ...(category === undefined ? {} : { category }),
215
+ });
216
+
217
+ return {
218
+ id: options.newId(),
219
+ orgId: options.orgId,
220
+ runId: options.runId,
221
+ fingerprint: fingerprint({
222
+ targetId: options.targetId,
223
+ vulnKey: key,
224
+ location,
225
+ ...(parameter === undefined ? {} : { parameter }),
226
+ ...(port === undefined ? {} : { port }),
227
+ }),
228
+ fingerprintVersion: FINGERPRINT_VERSION,
229
+ title,
230
+ ...(str(entry['description']) === undefined
231
+ ? {}
232
+ : { description: str(entry['description'])! }),
233
+ detectedSeverity,
234
+ severitySource,
235
+ ...(score === undefined ? {} : { cvssScore: score }),
236
+ ...(str(entry['cvssVector']) === undefined ? {} : { cvssVector: str(entry['cvssVector'])! }),
237
+ ...(cwe === undefined ? {} : { cwe }),
238
+ ...(str(entry['cve']) === undefined ? {} : { cve: str(entry['cve'])!.toUpperCase() }),
239
+ vulnKey: key,
240
+ ...(category === undefined ? {} : { category: category.toLowerCase() }),
241
+ location,
242
+ ...(parameter === undefined ? {} : { parameter }),
243
+ ...(strArray(entry['evidenceUri']) === undefined
244
+ ? {}
245
+ : { evidenceUri: strArray(entry['evidenceUri'])! }),
246
+ ...(str(entry['recommendation']) === undefined
247
+ ? {}
248
+ : { recommendation: str(entry['recommendation'])! }),
249
+ ...(strArray(entry['references']) === undefined
250
+ ? {}
251
+ : { references: strArray(entry['references'])! }),
252
+ sourceEngine: engine,
253
+ ...(ruleId === undefined ? {} : { sourceRuleId: ruleId }),
254
+ ...(num(entry['confidence']) === undefined ? {} : { confidence: num(entry['confidence'])! }),
255
+ createdAt: options.now,
256
+ } satisfies Finding;
257
+ });
258
+ }
@@ -0,0 +1,136 @@
1
+ import type { Finding } from '../finding.js';
2
+ import type { Severity, SeveritySource } from '../severity.js';
3
+ import { severityFromCvss } from '../severity.js';
4
+ import { fingerprint, vulnKey, FINGERPRINT_VERSION } from '../fingerprint.js';
5
+ import { childText, findAll, parseXml } from './xml.js';
6
+ import type { ImportOptions } from './nuclei.js';
7
+
8
+ /**
9
+ * Nessus reports severity as a number on the `ReportItem`.
10
+ *
11
+ * `0` is Info, which is the bottom of the scale here rather than a separate
12
+ * category. Nessus does reach `4` (Critical), unlike ZAP and Burp.
13
+ */
14
+ const NESSUS_SEVERITY: Readonly<Record<string, Severity>> = Object.freeze({
15
+ '4': 'critical',
16
+ '3': 'high',
17
+ '2': 'medium',
18
+ '1': 'low',
19
+ '0': 'advisory',
20
+ });
21
+
22
+ /**
23
+ * Turns a `.nessus` export into {@link Finding}s.
24
+ *
25
+ * Nessus is host-oriented: every `ReportItem` hangs off a `ReportHost`, and the
26
+ * port and protocol are attributes rather than part of a URL. The location is
27
+ * assembled as `host:port`, which is **the only place the port survives** —
28
+ * `00-DOMAIN.md` §3 defines no `port` on a finding, so anything not in the
29
+ * location is lost. A network finding with no path still fingerprints
30
+ * distinctly per service because the port is inside its location.
31
+ *
32
+ * A consequence worth knowing: the fingerprint's last component is
33
+ * `parameter ?? port ?? ''`, so a Nessus port is counted twice — once inside
34
+ * the location and once as that component. Harmless, but it means a port
35
+ * cannot be recovered from a finding except by parsing its location. Recorded
36
+ * as backlog **B42**.
37
+ *
38
+ * Severity comes from the CVSS v3 base score where Nessus supplies one, and from
39
+ * its own numeric rating otherwise.
40
+ *
41
+ * A `ReportItem` with severity `0` and no port — Nessus's host-inventory
42
+ * plugins — still imports: an advisory is still a finding, and dropping it here
43
+ * would make coverage look narrower than it was.
44
+ *
45
+ * @param xml - The contents of a `.nessus` file.
46
+ * @param options - Ownership, and the injected clock and id source.
47
+ * @returns One finding per report item, in document order.
48
+ * @throws SyntaxError If the document is not well-formed XML.
49
+ */
50
+ export function importNessus(xml: string, options: ImportOptions): Finding[] {
51
+ const root = parseXml(xml);
52
+ const findings: Finding[] = [];
53
+
54
+ for (const host of findAll(root, 'ReportHost')) {
55
+ const hostName = host.attrs['name'] ?? '';
56
+
57
+ for (const item of findAll(host, 'ReportItem')) {
58
+ const pluginId = item.attrs['pluginID'];
59
+ const title = item.attrs['pluginName'];
60
+ if (pluginId === undefined || title === undefined) continue;
61
+
62
+ const portText = item.attrs['port'];
63
+ const port = portText !== undefined && portText !== '0' ? Number(portText) : undefined;
64
+ const location = port === undefined ? hostName : `${hostName}:${String(port)}`;
65
+ if (location === '') continue;
66
+
67
+ const scoreText = childText(item, 'cvss3_base_score') ?? childText(item, 'cvss_base_score');
68
+ const score = scoreText === undefined ? undefined : Number(scoreText);
69
+
70
+ let detectedSeverity: Severity;
71
+ let severitySource: SeveritySource;
72
+ if (score !== undefined && Number.isFinite(score) && score >= 0 && score <= 10) {
73
+ detectedSeverity = severityFromCvss(score);
74
+ severitySource = 'cvss';
75
+ } else {
76
+ detectedSeverity = NESSUS_SEVERITY[item.attrs['severity'] ?? ''] ?? 'advisory';
77
+ severitySource = 'engine_default';
78
+ }
79
+
80
+ // Nessus writes the bare number, e.g. `79`.
81
+ const rawCwe = childText(item, 'cwe');
82
+ const cwe = rawCwe === undefined ? undefined : `CWE-${rawCwe.replace(/^CWE-/iu, '')}`;
83
+ const family = item.attrs['pluginFamily'];
84
+
85
+ const key = vulnKey({
86
+ sourceEngine: 'nessus',
87
+ sourceRuleId: pluginId,
88
+ ...(cwe === undefined ? {} : { cwe }),
89
+ ...(family === undefined ? {} : { category: family }),
90
+ });
91
+
92
+ const references = childText(item, 'see_also')
93
+ ?.split(/\s+/u)
94
+ .filter((token) => token.startsWith('http'));
95
+
96
+ findings.push({
97
+ id: options.newId(),
98
+ orgId: options.orgId,
99
+ runId: options.runId,
100
+ fingerprint: fingerprint({
101
+ targetId: options.targetId,
102
+ vulnKey: key,
103
+ location,
104
+ ...(port === undefined ? {} : { port }),
105
+ }),
106
+ fingerprintVersion: FINGERPRINT_VERSION,
107
+ title,
108
+ ...(childText(item, 'description') === undefined
109
+ ? {}
110
+ : { description: childText(item, 'description')! }),
111
+ detectedSeverity,
112
+ severitySource,
113
+ ...(score === undefined || !Number.isFinite(score) ? {} : { cvssScore: score }),
114
+ ...(childText(item, 'cvss3_vector') === undefined
115
+ ? {}
116
+ : { cvssVector: childText(item, 'cvss3_vector')! }),
117
+ ...(cwe === undefined ? {} : { cwe }),
118
+ ...(childText(item, 'cve') === undefined
119
+ ? {}
120
+ : { cve: childText(item, 'cve')!.toUpperCase() }),
121
+ vulnKey: key,
122
+ ...(family === undefined ? {} : { category: family.toLowerCase() }),
123
+ location,
124
+ ...(childText(item, 'solution') === undefined
125
+ ? {}
126
+ : { recommendation: childText(item, 'solution')! }),
127
+ ...(references === undefined || references.length === 0 ? {} : { references }),
128
+ sourceEngine: 'nessus',
129
+ sourceRuleId: pluginId,
130
+ createdAt: options.now,
131
+ });
132
+ }
133
+ }
134
+
135
+ return findings;
136
+ }
@@ -0,0 +1,173 @@
1
+ import type { Finding } from '../finding.js';
2
+ import type { Severity, SeveritySource } from '../severity.js';
3
+ import { severityFromCvss } from '../severity.js';
4
+ import { fingerprint, vulnKey, FINGERPRINT_VERSION } from '../fingerprint.js';
5
+
6
+ /**
7
+ * What an importer needs that a scanner's output cannot tell it.
8
+ *
9
+ * A scanner knows what it found; it does not know which organisation, run or
10
+ * target the result belongs to, and it must not invent ids. All of it is
11
+ * supplied, so importing stays pure and repeatable.
12
+ */
13
+ export interface ImportOptions {
14
+ /** Organisation the findings belong to. */
15
+ readonly orgId: string;
16
+
17
+ /** The run that produced them. */
18
+ readonly runId: string;
19
+
20
+ /** The target they are against. Part of every fingerprint. */
21
+ readonly targetId: string;
22
+
23
+ /** When the import happened. No importer reads the clock. */
24
+ readonly now: Date;
25
+
26
+ /** Supplies finding ids. Injected so importing is deterministic. */
27
+ readonly newId: () => string;
28
+ }
29
+
30
+ /**
31
+ * Nuclei's severity vocabulary, mapped onto {@link Severity}.
32
+ *
33
+ * `info` and `unknown` both become `advisory`: this model has no
34
+ * "informational" category separate from the bottom of the scale, and an
35
+ * unrated finding is not evidence of low risk — it is evidence of nothing, which
36
+ * is what `advisory` means here.
37
+ */
38
+ const NUCLEI_SEVERITY: Readonly<Record<string, Severity>> = Object.freeze({
39
+ critical: 'critical',
40
+ high: 'high',
41
+ medium: 'medium',
42
+ low: 'low',
43
+ info: 'advisory',
44
+ unknown: 'advisory',
45
+ });
46
+
47
+ /** The subset of a Nuclei JSONL record this importer reads. */
48
+ interface NucleiRecord {
49
+ 'template-id'?: unknown;
50
+ 'matched-at'?: unknown;
51
+ host?: unknown;
52
+ type?: unknown;
53
+ info?: {
54
+ name?: unknown;
55
+ description?: unknown;
56
+ severity?: unknown;
57
+ tags?: unknown;
58
+ reference?: unknown;
59
+ remediation?: unknown;
60
+ classification?: {
61
+ 'cve-id'?: unknown;
62
+ 'cwe-id'?: unknown;
63
+ 'cvss-score'?: unknown;
64
+ 'cvss-metrics'?: unknown;
65
+ };
66
+ };
67
+ }
68
+
69
+ const str = (v: unknown): string | undefined =>
70
+ typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
71
+
72
+ const firstOf = (v: unknown): string | undefined => (Array.isArray(v) ? str(v[0]) : str(v));
73
+
74
+ const allOf = (v: unknown): string[] | undefined => {
75
+ if (!Array.isArray(v)) return str(v) === undefined ? undefined : [str(v)!];
76
+ const items = v.map(str).filter((x): x is string => x !== undefined);
77
+ return items.length > 0 ? items : undefined;
78
+ };
79
+
80
+ /**
81
+ * Turns Nuclei's JSONL output into {@link Finding}s.
82
+ *
83
+ * Nuclei writes **one JSON object per line** (`-jsonl`), so the file is not
84
+ * itself valid JSON. Blank lines are skipped, and a line that will not parse is
85
+ * skipped rather than thrown on: a single malformed record should not cost a
86
+ * user every other result in the file. Records with no `template-id` or no
87
+ * location are skipped for the same reason — there is nothing to fingerprint.
88
+ *
89
+ * Severity comes from the CVSS score where Nuclei supplies one, and from its own
90
+ * rating otherwise; `severitySource` records which, because a severity with no
91
+ * provenance is not evidence.
92
+ *
93
+ * @param jsonl - The contents of a Nuclei JSONL file.
94
+ * @param options - Ownership, and the injected clock and id source.
95
+ * @returns One finding per usable record, in file order.
96
+ */
97
+ export function importNuclei(jsonl: string, options: ImportOptions): Finding[] {
98
+ const findings: Finding[] = [];
99
+
100
+ for (const line of jsonl.split('\n')) {
101
+ const trimmed = line.trim();
102
+ if (trimmed === '') continue;
103
+
104
+ let record: NucleiRecord;
105
+ try {
106
+ record = JSON.parse(trimmed) as NucleiRecord;
107
+ } catch {
108
+ // One unparseable line must not cost the user the rest of the file.
109
+ continue;
110
+ }
111
+
112
+ const templateId = str(record['template-id']);
113
+ const location = str(record['matched-at']) ?? str(record.host);
114
+ if (templateId === undefined || location === undefined) continue;
115
+
116
+ const info = record.info ?? {};
117
+ const classification = info.classification ?? {};
118
+
119
+ const cvss =
120
+ typeof classification['cvss-score'] === 'number' ? classification['cvss-score'] : undefined;
121
+ const rated = NUCLEI_SEVERITY[str(info.severity)?.toLowerCase() ?? ''];
122
+
123
+ let detectedSeverity: Severity;
124
+ let severitySource: SeveritySource;
125
+ if (cvss !== undefined && cvss >= 0 && cvss <= 10) {
126
+ detectedSeverity = severityFromCvss(cvss);
127
+ severitySource = 'cvss';
128
+ } else {
129
+ detectedSeverity = rated ?? 'advisory';
130
+ severitySource = 'engine_default';
131
+ }
132
+
133
+ // Nuclei writes CWE as `cwe-502`; the rest of the model uses `CWE-502`.
134
+ const cwe = firstOf(classification['cwe-id'])?.toUpperCase();
135
+ const category = firstOf(info.tags);
136
+ const key = vulnKey({
137
+ sourceEngine: 'nuclei',
138
+ sourceRuleId: templateId,
139
+ ...(cwe === undefined ? {} : { cwe }),
140
+ ...(category === undefined ? {} : { category }),
141
+ });
142
+
143
+ findings.push({
144
+ id: options.newId(),
145
+ orgId: options.orgId,
146
+ runId: options.runId,
147
+ fingerprint: fingerprint({ targetId: options.targetId, vulnKey: key, location }),
148
+ fingerprintVersion: FINGERPRINT_VERSION,
149
+ title: str(info.name) ?? templateId,
150
+ ...(str(info.description) === undefined ? {} : { description: str(info.description)! }),
151
+ detectedSeverity,
152
+ severitySource,
153
+ ...(cvss === undefined ? {} : { cvssScore: cvss }),
154
+ ...(str(classification['cvss-metrics']) === undefined
155
+ ? {}
156
+ : { cvssVector: str(classification['cvss-metrics'])! }),
157
+ ...(cwe === undefined ? {} : { cwe }),
158
+ ...(firstOf(classification['cve-id']) === undefined
159
+ ? {}
160
+ : { cve: firstOf(classification['cve-id'])!.toUpperCase() }),
161
+ vulnKey: key,
162
+ ...(category === undefined ? {} : { category }),
163
+ location,
164
+ ...(str(info.remediation) === undefined ? {} : { recommendation: str(info.remediation)! }),
165
+ ...(allOf(info.reference) === undefined ? {} : { references: allOf(info.reference)! }),
166
+ sourceEngine: 'nuclei',
167
+ sourceRuleId: templateId,
168
+ createdAt: options.now,
169
+ });
170
+ }
171
+
172
+ return findings;
173
+ }