@secureport/core 0.3.0 → 1.0.0-rc.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.
- package/README.md +116 -11
- package/dist/import/burp.d.ts +19 -0
- package/dist/import/burp.d.ts.map +1 -0
- package/dist/import/burp.js +114 -0
- package/dist/import/burp.js.map +1 -0
- package/dist/import/generic.d.ts +90 -0
- package/dist/import/generic.d.ts.map +1 -0
- package/dist/import/generic.js +159 -0
- package/dist/import/generic.js.map +1 -0
- package/dist/import/nessus.d.ts +32 -0
- package/dist/import/nessus.d.ts.map +1 -0
- package/dist/import/nessus.js +125 -0
- package/dist/import/nessus.js.map +1 -0
- package/dist/import/xml.d.ts +47 -0
- package/dist/import/xml.d.ts.map +1 -0
- package/dist/import/xml.js +157 -0
- package/dist/import/xml.js.map +1 -0
- package/dist/import/zap.d.ts +1 -1
- package/dist/import/zap.js +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +35 -0
- package/dist/reconcile.js.map +1 -1
- package/dist/report/html.d.ts +21 -0
- package/dist/report/html.d.ts.map +1 -0
- package/dist/report/html.js +324 -0
- package/dist/report/html.js.map +1 -0
- package/dist/report/json.d.ts +81 -0
- package/dist/report/json.d.ts.map +1 -0
- package/dist/report/json.js +47 -0
- package/dist/report/json.js.map +1 -0
- package/dist/report/markdown.d.ts +24 -0
- package/dist/report/markdown.d.ts.map +1 -0
- package/dist/report/markdown.js +304 -0
- package/dist/report/markdown.js.map +1 -0
- package/dist/report/model.d.ts +215 -0
- package/dist/report/model.d.ts.map +1 -0
- package/dist/report/model.js +197 -0
- package/dist/report/model.js.map +1 -0
- package/dist/snapshot-builder.d.ts +8 -0
- package/dist/snapshot-builder.d.ts.map +1 -1
- package/dist/snapshot-builder.js +25 -1
- package/dist/snapshot-builder.js.map +1 -1
- package/package.json +3 -3
- package/src/import/burp.ts +126 -0
- package/src/import/generic.ts +258 -0
- package/src/import/nessus.ts +136 -0
- package/src/import/xml.ts +187 -0
- package/src/import/zap.ts +1 -1
- package/src/index.ts +19 -0
- package/src/reconcile.ts +37 -0
- package/src/report/html.ts +449 -0
- package/src/report/json.ts +134 -0
- package/src/report/markdown.ts +435 -0
- package/src/report/model.ts +462 -0
- package/src/snapshot-builder.ts +28 -2
|
@@ -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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A parsed XML element.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately minimal: a name, its attributes, its child elements and its
|
|
5
|
+
* text. No namespaces, no processing instructions, no mixed-content ordering —
|
|
6
|
+
* none of which appears in a Burp or Nessus export.
|
|
7
|
+
*/
|
|
8
|
+
export interface XmlNode {
|
|
9
|
+
/** The element name, as written. */
|
|
10
|
+
readonly name: string;
|
|
11
|
+
|
|
12
|
+
/** Attributes, with entities decoded. */
|
|
13
|
+
readonly attrs: Readonly<Record<string, string>>;
|
|
14
|
+
|
|
15
|
+
/** Child elements, in document order. */
|
|
16
|
+
readonly children: readonly XmlNode[];
|
|
17
|
+
|
|
18
|
+
/** All direct text and CDATA of this element, concatenated and trimmed. */
|
|
19
|
+
readonly text: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ENTITIES: Readonly<Record<string, string>> = Object.freeze({
|
|
23
|
+
amp: '&',
|
|
24
|
+
lt: '<',
|
|
25
|
+
gt: '>',
|
|
26
|
+
quot: '"',
|
|
27
|
+
apos: "'",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Decodes the five XML entities and numeric character references. */
|
|
31
|
+
function decodeEntities(text: string): string {
|
|
32
|
+
return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/giu, (whole, body: string) => {
|
|
33
|
+
if (body.startsWith('#')) {
|
|
34
|
+
const code =
|
|
35
|
+
body[1]?.toLowerCase() === 'x' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
36
|
+
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
|
|
37
|
+
? String.fromCodePoint(code)
|
|
38
|
+
: whole;
|
|
39
|
+
}
|
|
40
|
+
return ENTITIES[body.toLowerCase()] ?? whole;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MutableNode {
|
|
45
|
+
name: string;
|
|
46
|
+
attrs: Record<string, string>;
|
|
47
|
+
children: MutableNode[];
|
|
48
|
+
text: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads the attributes out of a start tag's body. */
|
|
52
|
+
function parseAttrs(body: string): Record<string, string> {
|
|
53
|
+
const attrs: Record<string, string> = {};
|
|
54
|
+
const pattern = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/gu;
|
|
55
|
+
let match: RegExpExecArray | null;
|
|
56
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
57
|
+
attrs[match[1]] = decodeEntities(match[3] ?? match[4] ?? '');
|
|
58
|
+
}
|
|
59
|
+
return attrs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parses the subset of XML that scanner exports actually use.
|
|
64
|
+
*
|
|
65
|
+
* **Deliberately narrow, and it throws rather than guesses.** Burp and Nessus
|
|
66
|
+
* both export machine-generated XML with a fixed shape: elements, attributes,
|
|
67
|
+
* text and CDATA. This handles exactly that, plus comments, the XML
|
|
68
|
+
* declaration, a DOCTYPE, self-closing tags and the five named entities with
|
|
69
|
+
* numeric character references.
|
|
70
|
+
*
|
|
71
|
+
* It does **not** handle namespaces, entity definitions or mixed-content
|
|
72
|
+
* ordering, and it has no opinion about schemas. Anything structurally
|
|
73
|
+
* unexpected — an unclosed element, a mismatched end tag, two root elements —
|
|
74
|
+
* is an error, because a parser that guesses at broken input produces findings
|
|
75
|
+
* that are quietly wrong, and a wrong finding is worse than a failed import.
|
|
76
|
+
*
|
|
77
|
+
* Hand-written rather than taken from a library because `@secureport/core` ships
|
|
78
|
+
* with zero runtime dependencies: it is imported by the hosted API and by third
|
|
79
|
+
* parties on equal terms, and a domain model that drags an XML parser in with it
|
|
80
|
+
* is a supply-chain decision made on everyone's behalf.
|
|
81
|
+
*
|
|
82
|
+
* @param source - The XML document.
|
|
83
|
+
* @returns The root element.
|
|
84
|
+
* @throws SyntaxError If the document is not well-formed, or has no root.
|
|
85
|
+
*/
|
|
86
|
+
export function parseXml(source: string): XmlNode {
|
|
87
|
+
const stack: MutableNode[] = [];
|
|
88
|
+
let root: MutableNode | undefined;
|
|
89
|
+
let i = 0;
|
|
90
|
+
|
|
91
|
+
while (i < source.length) {
|
|
92
|
+
const lt = source.indexOf('<', i);
|
|
93
|
+
if (lt === -1) break;
|
|
94
|
+
|
|
95
|
+
// Text before this tag belongs to the element currently open.
|
|
96
|
+
if (lt > i && stack.length > 0) {
|
|
97
|
+
stack[stack.length - 1].text += decodeEntities(source.slice(i, lt));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (source.startsWith('<!--', lt)) {
|
|
101
|
+
const end = source.indexOf('-->', lt);
|
|
102
|
+
if (end === -1) throw new SyntaxError('unterminated comment');
|
|
103
|
+
i = end + 3;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (source.startsWith('<![CDATA[', lt)) {
|
|
108
|
+
const end = source.indexOf(']]>', lt);
|
|
109
|
+
if (end === -1) throw new SyntaxError('unterminated CDATA section');
|
|
110
|
+
// CDATA is literal: no entity decoding, which is the whole point of it.
|
|
111
|
+
if (stack.length > 0) stack[stack.length - 1].text += source.slice(lt + 9, end);
|
|
112
|
+
i = end + 3;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (source.startsWith('<?', lt) || source.startsWith('<!', lt)) {
|
|
117
|
+
const end = source.indexOf('>', lt);
|
|
118
|
+
if (end === -1) throw new SyntaxError('unterminated declaration');
|
|
119
|
+
i = end + 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const gt = source.indexOf('>', lt);
|
|
124
|
+
if (gt === -1) throw new SyntaxError('unterminated tag');
|
|
125
|
+
const raw = source.slice(lt + 1, gt);
|
|
126
|
+
|
|
127
|
+
if (raw.startsWith('/')) {
|
|
128
|
+
const name = raw.slice(1).trim();
|
|
129
|
+
const open = stack.pop();
|
|
130
|
+
if (open === undefined) throw new SyntaxError(`unexpected closing tag </${name}>`);
|
|
131
|
+
if (open.name !== name) {
|
|
132
|
+
throw new SyntaxError(`closing tag </${name}> does not match <${open.name}>`);
|
|
133
|
+
}
|
|
134
|
+
i = gt + 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const selfClosing = raw.endsWith('/');
|
|
139
|
+
const body = selfClosing ? raw.slice(0, -1) : raw;
|
|
140
|
+
const nameEnd = body.search(/[\s/]/u);
|
|
141
|
+
const name = (nameEnd === -1 ? body : body.slice(0, nameEnd)).trim();
|
|
142
|
+
if (name === '') throw new SyntaxError('tag with no name');
|
|
143
|
+
|
|
144
|
+
const node: MutableNode = {
|
|
145
|
+
name,
|
|
146
|
+
attrs: parseAttrs(nameEnd === -1 ? '' : body.slice(nameEnd)),
|
|
147
|
+
children: [],
|
|
148
|
+
text: '',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (stack.length > 0) stack[stack.length - 1].children.push(node);
|
|
152
|
+
else if (root === undefined) root = node;
|
|
153
|
+
else throw new SyntaxError('more than one root element');
|
|
154
|
+
|
|
155
|
+
if (!selfClosing) stack.push(node);
|
|
156
|
+
i = gt + 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (stack.length > 0) throw new SyntaxError(`unclosed element <${stack[stack.length - 1].name}>`);
|
|
160
|
+
if (root === undefined) throw new SyntaxError('no root element');
|
|
161
|
+
|
|
162
|
+
const finish = (node: MutableNode): XmlNode => ({
|
|
163
|
+
name: node.name,
|
|
164
|
+
attrs: node.attrs,
|
|
165
|
+
children: node.children.map(finish),
|
|
166
|
+
text: node.text.trim(),
|
|
167
|
+
});
|
|
168
|
+
return finish(root);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every descendant with this name, at any depth. */
|
|
172
|
+
export function findAll(node: XmlNode, name: string): XmlNode[] {
|
|
173
|
+
const found: XmlNode[] = [];
|
|
174
|
+
const walk = (current: XmlNode) => {
|
|
175
|
+
if (current.name === name) found.push(current);
|
|
176
|
+
for (const child of current.children) walk(child);
|
|
177
|
+
};
|
|
178
|
+
walk(node);
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The text of the first direct child with this name, if it has any. */
|
|
183
|
+
export function childText(node: XmlNode, name: string): string | undefined {
|
|
184
|
+
const child = node.children.find((c) => c.name === name);
|
|
185
|
+
const text = child?.text.trim();
|
|
186
|
+
return text === undefined || text === '' ? undefined : text;
|
|
187
|
+
}
|
package/src/import/zap.ts
CHANGED
|
@@ -81,7 +81,7 @@ const splitReferences = (v: unknown): string[] | undefined => {
|
|
|
81
81
|
* An alert with no instances still produces one finding, against the site — a
|
|
82
82
|
* detection with no location is still a detection.
|
|
83
83
|
*
|
|
84
|
-
* Severity comes from ZAP's `riskcode`; see
|
|
84
|
+
* Severity comes from ZAP's `riskcode`; see `ZAP_RISK` below for why nothing
|
|
85
85
|
* here is ever `critical`.
|
|
86
86
|
*
|
|
87
87
|
* @param json - The contents of a ZAP JSON report.
|
package/src/index.ts
CHANGED
|
@@ -37,8 +37,27 @@ export { coversLocation } from './coverage.js';
|
|
|
37
37
|
export type { ImportOptions } from './import/nuclei.js';
|
|
38
38
|
export { importNuclei } from './import/nuclei.js';
|
|
39
39
|
export { importZap } from './import/zap.js';
|
|
40
|
+
export { importBurp } from './import/burp.js';
|
|
41
|
+
export { importNessus } from './import/nessus.js';
|
|
42
|
+
export type { GenericDocument, GenericFinding } from './import/generic.js';
|
|
43
|
+
export { importGeneric } from './import/generic.js';
|
|
40
44
|
export type { BuildSnapshotInput } from './snapshot-builder.js';
|
|
41
45
|
export { buildSnapshot, parseSnapshot } from './snapshot-builder.js';
|
|
46
|
+
export type {
|
|
47
|
+
ReportKind,
|
|
48
|
+
ReportModel,
|
|
49
|
+
ReportOptions,
|
|
50
|
+
ReportSection,
|
|
51
|
+
RetestEntry,
|
|
52
|
+
RetestOutcome,
|
|
53
|
+
RetestVerdict,
|
|
54
|
+
TestingBasis,
|
|
55
|
+
} from './report/model.js';
|
|
56
|
+
export { buildReportModel } from './report/model.js';
|
|
57
|
+
export { renderMarkdown } from './report/markdown.js';
|
|
58
|
+
export { renderHtml } from './report/html.js';
|
|
59
|
+
export type { JsonReport } from './report/json.js';
|
|
60
|
+
export { renderJson } from './report/json.js';
|
|
42
61
|
export type { ReconcileInput, ReconcileResult } from './reconcile.js';
|
|
43
62
|
export { reconcile } from './reconcile.js';
|
|
44
63
|
export type { Coverage, Run, RunKind, RunSummary, RunTrigger, SeverityCounts } from './run.js';
|
package/src/reconcile.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Finding } from './finding.js';
|
|
2
|
+
import { FINGERPRINT_VERSION } from './fingerprint.js';
|
|
2
3
|
import type { Issue, IssueEvent } from './issue.js';
|
|
3
4
|
import type { Run, RunKind, RunSummary, SeverityCounts } from './run.js';
|
|
4
5
|
import {
|
|
@@ -155,6 +156,40 @@ function daysBetween(from: Date, to: Date): number {
|
|
|
155
156
|
return Math.max(0, Math.floor((to.getTime() - from.getTime()) / 86_400_000));
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Refuses to reconcile issues or findings computed by a different algorithm.
|
|
161
|
+
*
|
|
162
|
+
* **This prevents the worst failure in the product, and nothing checked for it
|
|
163
|
+
* until backlog B45 was raised.** If this package ships `fp_v2` and runs
|
|
164
|
+
* against a store of `fp_v1` issues, every fingerprint mismatches: every
|
|
165
|
+
* existing issue is unmatched and auto-resolves, every finding creates a new
|
|
166
|
+
* issue, and the history the product exists to keep is destroyed — silently,
|
|
167
|
+
* during an ordinary ingest.
|
|
168
|
+
*
|
|
169
|
+
* `fingerprintVersion` being stored on every row makes that *detectable*
|
|
170
|
+
* (invariant 8). Checking it here makes it *loud*. A version change is
|
|
171
|
+
* survivable only through the re-fingerprint migration described in
|
|
172
|
+
* `00-DOMAIN.md` §4, which is the one thing permitted to hold both versions at
|
|
173
|
+
* once — and it does so in memory, without writing.
|
|
174
|
+
*
|
|
175
|
+
* Findings are checked too, and separately: a store at the current version
|
|
176
|
+
* being handed findings from an older importer is the same mismatch arriving
|
|
177
|
+
* from the other direction.
|
|
178
|
+
*/
|
|
179
|
+
function assertOneFingerprintVersion(issues: readonly Issue[], findings: readonly Finding[]): void {
|
|
180
|
+
const found = new Set<string>();
|
|
181
|
+
for (const issue of issues) found.add(issue.fingerprintVersion);
|
|
182
|
+
for (const finding of findings) found.add(finding.fingerprintVersion);
|
|
183
|
+
found.delete(FINGERPRINT_VERSION);
|
|
184
|
+
if (found.size === 0) return;
|
|
185
|
+
|
|
186
|
+
throw new Error(
|
|
187
|
+
`reconcile() was given fingerprints at ${[...found].sort().join(', ')} but this package ` +
|
|
188
|
+
`computes ${FINGERPRINT_VERSION}. Matching across versions would resolve every existing ` +
|
|
189
|
+
'issue and recreate it as new. Run the re-fingerprint migration first (00-DOMAIN.md §4).',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
158
193
|
/**
|
|
159
194
|
* Turns a run's findings into issue state.
|
|
160
195
|
*
|
|
@@ -204,6 +239,8 @@ export function reconcile(input: ReconcileInput): ReconcileResult {
|
|
|
204
239
|
const actor = input.actor ?? 'reconciler';
|
|
205
240
|
const slaPolicy = input.slaPolicy ?? DEFAULT_SLA_POLICY;
|
|
206
241
|
|
|
242
|
+
assertOneFingerprintVersion(issues, findings);
|
|
243
|
+
|
|
207
244
|
const events: IssueEvent[] = [];
|
|
208
245
|
const emit = (issueId: string, type: IssueEvent['type'], payload?: Record<string, unknown>) => {
|
|
209
246
|
events.push({
|