@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,247 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Which fingerprint algorithm this build of the package implements.
4
+ *
5
+ * **A public contract, and the most consequential string in the package.** It
6
+ * is stored on every finding and every issue, everywhere, so that a fingerprint
7
+ * recorded a year ago can still be interpreted. Changing the algorithm means
8
+ * bumping this *and* running a per-organisation re-fingerprint migration that
9
+ * preserves history — never a silent recomputation, which would orphan every
10
+ * issue whose evidence no longer hashes to the same value.
11
+ *
12
+ * If you are tempted to "just tweak" the normaliser, that is this constant's
13
+ * job to prevent.
14
+ */
15
+ export const FINGERPRINT_VERSION = 'fp_v1';
16
+ /** A path segment that is entirely digits, e.g. the `123` in `/orders/123`. */
17
+ const NUMERIC_SEGMENT = /^\d+$/u;
18
+ /** A path segment that is a UUID in the canonical 8-4-4-4-12 form. */
19
+ const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
20
+ /**
21
+ * The placeholder that a variable path segment collapses to.
22
+ *
23
+ * Exported because it appears in normalised locations, which appear in reports
24
+ * and in support conversations: someone reading `/orders/{id}` should be able
25
+ * to find out what produced it.
26
+ */
27
+ export const PATH_PLACEHOLDER = '{id}';
28
+ /**
29
+ * Decodes percent-encoding exactly once, and never throws.
30
+ *
31
+ * Once, not repeatedly: decoding until stable would make `%2541` and `%41`
32
+ * collapse to the same thing, so a target that double-encodes could be made to
33
+ * collide with one that does not.
34
+ *
35
+ * Malformed encoding is left alone rather than rejected. A fingerprint that
36
+ * throws on strange input is a fingerprint that loses a finding.
37
+ */
38
+ function decodeOnce(value) {
39
+ try {
40
+ return decodeURIComponent(value);
41
+ }
42
+ catch {
43
+ return value;
44
+ }
45
+ }
46
+ /**
47
+ * Collapses one path segment to {@link PATH_PLACEHOLDER} if it identifies a
48
+ * record rather than a route.
49
+ */
50
+ function collapseSegment(segment) {
51
+ if (NUMERIC_SEGMENT.test(segment) || UUID_SEGMENT.test(segment))
52
+ return PATH_PLACEHOLDER;
53
+ return segment;
54
+ }
55
+ /**
56
+ * Normalises a location so that the same weakness in the same place produces
57
+ * the same string, however the engine happened to write it down.
58
+ *
59
+ * This is the part of the fingerprint that decides whether `/orders/1` and
60
+ * `/orders/2` are one issue or two thousand. Applies, in order:
61
+ *
62
+ * - **Lowercases the host**, and converts an internationalised domain to
63
+ * punycode, so `HTTPS://例え.テスト/x` and `https://xn--r8jz45g.xn--zckzah/x`
64
+ * are one location.
65
+ * - **Drops a default port** (`:443` on https, `:80` on http) and keeps any
66
+ * other, because `:8443` is a different service and `:443` is not.
67
+ * - **Decodes percent-encoding once.**
68
+ * - **Collapses numeric and UUID path segments** to `{id}`, so a per-record URL
69
+ * does not open a per-record issue.
70
+ * - **Drops a trailing slash**, except on the root path where it is the path.
71
+ * - **Strips query values but keeps parameter names, sorted.** `?b=2&a=secret`
72
+ * becomes `?a&b`. The names are structure and belong in identity; the values
73
+ * are usually the payload that proved the weakness, which is evidence and
74
+ * must never reach a fingerprint. Sorting means parameter order cannot split
75
+ * one issue into two.
76
+ * - **Drops the fragment**, which the server never sees.
77
+ *
78
+ * Anything that is not a parseable absolute URL — a bare host, a file path, a
79
+ * `host:port` pair from a network scan — is normalised as a path alone. That is
80
+ * deliberate: refusing to fingerprint a non-HTTP finding would exclude whole
81
+ * classes of scanner from the model.
82
+ *
83
+ * @param location - Where the weakness was found.
84
+ * @returns The normalised location.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * normaliseLocation('HTTPS://API.Example.com:443/Orders/123/items/?b=2&a=secret#f');
89
+ * // 'https://api.example.com/Orders/{id}/items?a&b'
90
+ * ```
91
+ */
92
+ export function normaliseLocation(location) {
93
+ const trimmed = location.trim();
94
+ let url;
95
+ try {
96
+ url = new URL(trimmed);
97
+ }
98
+ catch {
99
+ url = undefined;
100
+ }
101
+ // Not an absolute URL: normalise it as a bare path and stop. `new URL` would
102
+ // otherwise turn `example.com/x` into the `example.com:` protocol.
103
+ if (!url || url.protocol === '' || !url.host) {
104
+ return normalisePathOnly(trimmed);
105
+ }
106
+ // Not `decodeOnce` here: normalisePathOnly decodes, and decoding on the way
107
+ // in as well would decode twice — which would fold `%252F` into `%2F` into
108
+ // `/` and let a double-encoding target collide with a single-encoding one.
109
+ const path = normalisePathOnly(url.pathname);
110
+ // `url.host` already carries punycode and lower case, and omits a default
111
+ // port for the scheme.
112
+ const names = [...new Set([...url.searchParams.keys()].map(decodeOnce))].sort();
113
+ const query = names.length > 0 ? `?${names.join('&')}` : '';
114
+ return `${url.protocol}//${url.host}${path}${query}`;
115
+ }
116
+ /**
117
+ * Normalises a path with no scheme or host.
118
+ */
119
+ function normalisePathOnly(path) {
120
+ const decoded = decodeOnce(path.trim());
121
+ if (decoded === '' || decoded === '/')
122
+ return decoded === '' ? '' : '/';
123
+ const collapsed = decoded
124
+ .split('/')
125
+ .map((segment) => collapseSegment(segment))
126
+ .join('/');
127
+ return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed;
128
+ }
129
+ /**
130
+ * Maps `engine:ruleId` to a shared, engine-independent weakness key.
131
+ *
132
+ * **This table is the entire mechanism of cross-engine deduplication.** Without
133
+ * it a ZAP detection and a Nuclei detection of the same weakness fall back to
134
+ * CWE, and where either engine omits the CWE they never collide at all — you
135
+ * get per-engine tracking wearing the clothes of an issue tracker.
136
+ *
137
+ * **It is deliberately small, and that is not laziness.** A mapping asserts
138
+ * that a specific rule id means a specific weakness, and a wrong assertion
139
+ * silently merges two unrelated issues — worse than not mapping at all, because
140
+ * the merge is invisible. Rule ids cannot be known honestly until real scanner
141
+ * output has been parsed, which is what 4.4a and 4.4b do with committed
142
+ * fixtures. The table grows there, from evidence.
143
+ *
144
+ * **Entries must be added in pairs, per weakness, across engines — a half-filled
145
+ * table is worse than an empty one.** The mapping beats the CWE fallback, so
146
+ * mapping ZAP's HSTS rule while leaving Nuclei's unmapped gives them *different*
147
+ * keys, when falling back to `CWE-319` on both sides would have collided them
148
+ * correctly. Adding one engine's rule silently un-deduplicates the weakness.
149
+ * Found by importing a fixture from each engine and watching them stop
150
+ * agreeing.
151
+ *
152
+ * `00-DOMAIN.md` §10 leaves the eventual size open, leaning towards the top
153
+ * ~200 Nuclei templates plus ZAP's plugin list, with CWE fallback beyond.
154
+ *
155
+ * Keys are `${sourceEngine}:${sourceRuleId}`, both lowercased.
156
+ */
157
+ export const VULN_KEY_MAP = Object.freeze({
158
+ // ZAP plugin ids are stable and documented, which is why the seed is ZAP's.
159
+ 'zap:40012': 'xss-reflected',
160
+ 'zap:40014': 'xss-persistent',
161
+ 'zap:40018': 'sql-injection',
162
+ 'zap:10038': 'csp-missing',
163
+ 'zap:10035': 'hsts-missing',
164
+ 'zap:10021': 'x-content-type-options-missing',
165
+ 'zap:10020': 'x-frame-options-missing',
166
+ // Nuclei's side of the pairs above. Anything mapped for one engine and not
167
+ // the other stops the two agreeing, so these travel together.
168
+ 'nuclei:xss-reflected': 'xss-reflected',
169
+ 'nuclei:missing-hsts': 'hsts-missing',
170
+ });
171
+ /**
172
+ * The engine-independent key for a weakness class.
173
+ *
174
+ * Resolution order, per `00-DOMAIN.md` §4:
175
+ *
176
+ * 1. {@link VULN_KEY_MAP}, looked up by `engine:ruleId`.
177
+ * 2. `cwe` alone.
178
+ * 3. `category` alone.
179
+ * 4. `engine:ruleId` itself.
180
+ *
181
+ * **The spec says `cwe + '/' + category`, and that was tried and abandoned.**
182
+ * Engines do not share a category vocabulary: ZAP supplies no category at all,
183
+ * so reflected XSS there keys as `CWE-79`, while Nuclei tags the same finding
184
+ * `xss` and keys as `CWE-79/xss`. The two never collide — so the fallback
185
+ * actively prevented the cross-engine deduplication it exists to provide, which
186
+ * a fixture from each engine demonstrated immediately.
187
+ *
188
+ * CWE alone is coarser, and the coarseness is bounded: the fingerprint also
189
+ * carries the normalised location and the parameter, so two findings only merge
190
+ * when they share a weakness class *and* a place. Two genuinely different
191
+ * weaknesses under one CWE, at the same URL and parameter, is the case this
192
+ * gets wrong — and `POST /issues/{a}/merge/{b}` exists because something will.
193
+ *
194
+ * Steps 3 and 4 keep a finding trackable when there is no CWE at all. **Step 4
195
+ * never deduplicates across engines**, which is the honest outcome for a rule
196
+ * nobody has mapped: it tracks correctly and merges nothing it should not.
197
+ *
198
+ * @param input - What the engine reported.
199
+ * @returns The weakness key.
200
+ * @throws TypeError If nothing identifying is present at all.
201
+ */
202
+ export function vulnKey(input) {
203
+ const engine = input.sourceEngine.trim().toLowerCase();
204
+ const ruleId = input.sourceRuleId?.trim().toLowerCase();
205
+ if (engine !== '' && ruleId !== undefined && ruleId !== '') {
206
+ const mapped = VULN_KEY_MAP[`${engine}:${ruleId}`];
207
+ if (mapped !== undefined)
208
+ return mapped;
209
+ }
210
+ const cwe = input.cwe?.trim().toUpperCase();
211
+ const category = input.category?.trim().toLowerCase();
212
+ if (cwe !== undefined && cwe !== '')
213
+ return cwe;
214
+ if (category !== undefined && category !== '')
215
+ return category;
216
+ if (engine !== '' && ruleId !== undefined && ruleId !== '')
217
+ return `${engine}:${ruleId}`;
218
+ throw new TypeError('cannot derive a vuln_key: a finding needs a mapped rule, a CWE, a category, or an engine rule id');
219
+ }
220
+ /**
221
+ * The content address of a weakness: `sha256(targetId | vulnKey | normalised
222
+ * location | parameter ?? port ?? '')`.
223
+ *
224
+ * Deterministic and pure — the same input always gives the same digest, on any
225
+ * machine, in any order, at any time. That is what lets findings from different
226
+ * runs and different engines reconcile into one issue.
227
+ *
228
+ * Uses Node's `node:crypto`, which is a builtin rather than a dependency, so
229
+ * the package still installs nothing. It does mean fingerprinting requires
230
+ * Node; report rendering does not, and rendering is the only part of this
231
+ * package a browser was ever going to run.
232
+ *
233
+ * @param input - The identifying facts.
234
+ * @returns A lowercase hex SHA-256 digest.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * const key = vulnKey({ sourceEngine: 'zap', sourceRuleId: '40012' });
239
+ * fingerprint({ targetId: 'tgt_1', vulnKey: key, location: '/search', parameter: 'q' });
240
+ * ```
241
+ */
242
+ export function fingerprint(input) {
243
+ const tail = input.parameter ?? (input.port !== undefined ? String(input.port) : '');
244
+ const material = [input.targetId, input.vulnKey, normaliseLocation(input.location), tail].join('|');
245
+ return createHash('sha256').update(material, 'utf8').digest('hex');
246
+ }
247
+ //# sourceMappingURL=fingerprint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.js","sourceRoot":"","sources":["../src/fingerprint.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import type { Finding } from '../finding.js';
2
+ import type { ImportOptions } from './nuclei.js';
3
+ /**
4
+ * Turns a Burp Suite XML export into {@link Finding}s.
5
+ *
6
+ * Burp exports a flat `<issues>` document, one `<issue>` per detection, with the
7
+ * host and path in separate elements — so the location is assembled rather than
8
+ * read. Prose fields arrive as CDATA containing HTML, which is stripped.
9
+ *
10
+ * `<type>` is Burp's numeric issue type and is used as the rule id, because it
11
+ * is stable across versions where the human-readable `<name>` is not.
12
+ *
13
+ * @param xml - The contents of a Burp XML export.
14
+ * @param options - Ownership, and the injected clock and id source.
15
+ * @returns One finding per issue, in document order.
16
+ * @throws SyntaxError If the document is not well-formed XML.
17
+ */
18
+ export declare function importBurp(xml: string, options: ImportOptions): Finding[];
19
+ //# sourceMappingURL=burp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"burp.d.ts","sourceRoot":"","sources":["../../src/import/burp.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAI7C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAwCjD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,EAAE,CA0DzE"}
@@ -0,0 +1,114 @@
1
+ import { fingerprint, vulnKey, FINGERPRINT_VERSION } from '../fingerprint.js';
2
+ import { childText, findAll, parseXml } from './xml.js';
3
+ /**
4
+ * Burp's severity vocabulary, mapped onto {@link Severity}.
5
+ *
6
+ * **Burp has no "critical" either.** Like ZAP it tops out at High, and
7
+ * `Information` is the bottom of the scale rather than a separate category.
8
+ */
9
+ const BURP_SEVERITY = Object.freeze({
10
+ high: 'high',
11
+ medium: 'medium',
12
+ low: 'low',
13
+ information: 'advisory',
14
+ info: 'advisory',
15
+ });
16
+ /**
17
+ * Burp reports confidence in words. Mapped to a number so the model does not
18
+ * have to carry a second vocabulary for it.
19
+ */
20
+ const BURP_CONFIDENCE = Object.freeze({
21
+ certain: 1,
22
+ firm: 0.7,
23
+ tentative: 0.4,
24
+ });
25
+ /**
26
+ * Burp writes the affected parameter into the location, not a field of its own.
27
+ *
28
+ * `/search [q parameter]` means the `q` parameter of `/search`. Pulling it out
29
+ * matters: the parameter is part of the fingerprint, so leaving it embedded in
30
+ * the path would make Burp's finding a different issue from ZAP's finding of the
31
+ * same weakness in the same place.
32
+ */
33
+ function splitLocation(location) {
34
+ const match = /^(.*?)\s*\[\s*(.+?)\s+parameter\s*\]\s*$/iu.exec(location);
35
+ if (match)
36
+ return { path: match[1].trim(), parameter: match[2].trim() };
37
+ return { path: location.trim() };
38
+ }
39
+ /**
40
+ * Turns a Burp Suite XML export into {@link Finding}s.
41
+ *
42
+ * Burp exports a flat `<issues>` document, one `<issue>` per detection, with the
43
+ * host and path in separate elements — so the location is assembled rather than
44
+ * read. Prose fields arrive as CDATA containing HTML, which is stripped.
45
+ *
46
+ * `<type>` is Burp's numeric issue type and is used as the rule id, because it
47
+ * is stable across versions where the human-readable `<name>` is not.
48
+ *
49
+ * @param xml - The contents of a Burp XML export.
50
+ * @param options - Ownership, and the injected clock and id source.
51
+ * @returns One finding per issue, in document order.
52
+ * @throws SyntaxError If the document is not well-formed XML.
53
+ */
54
+ export function importBurp(xml, options) {
55
+ const root = parseXml(xml);
56
+ const findings = [];
57
+ for (const issue of findAll(root, 'issue')) {
58
+ const name = childText(issue, 'name');
59
+ const type = childText(issue, 'type');
60
+ if (name === undefined || type === undefined)
61
+ continue;
62
+ const host = childText(issue, 'host') ?? '';
63
+ const raw = childText(issue, 'location') ?? childText(issue, 'path') ?? '';
64
+ const { path, parameter } = splitLocation(raw);
65
+ const location = path.startsWith('http') ? path : `${host}${path}`;
66
+ if (location === '')
67
+ continue;
68
+ const cwe = childText(issue, 'vulnerabilityClassifications')
69
+ ?.match(/CWE-\d+/iu)?.[0]
70
+ ?.toUpperCase();
71
+ const key = vulnKey({
72
+ sourceEngine: 'burp',
73
+ sourceRuleId: type,
74
+ ...(cwe === undefined ? {} : { cwe }),
75
+ });
76
+ const confidence = BURP_CONFIDENCE[childText(issue, 'confidence')?.toLowerCase() ?? ''];
77
+ const background = childText(issue, 'issueBackground');
78
+ const remediation = childText(issue, 'remediationBackground');
79
+ findings.push({
80
+ id: options.newId(),
81
+ orgId: options.orgId,
82
+ runId: options.runId,
83
+ fingerprint: fingerprint({
84
+ targetId: options.targetId,
85
+ vulnKey: key,
86
+ location,
87
+ ...(parameter === undefined ? {} : { parameter }),
88
+ }),
89
+ fingerprintVersion: FINGERPRINT_VERSION,
90
+ title: name,
91
+ ...(background === undefined ? {} : { description: stripHtml(background) }),
92
+ detectedSeverity: BURP_SEVERITY[childText(issue, 'severity')?.toLowerCase() ?? ''] ?? 'advisory',
93
+ severitySource: 'engine_default',
94
+ ...(cwe === undefined ? {} : { cwe }),
95
+ vulnKey: key,
96
+ location,
97
+ ...(parameter === undefined ? {} : { parameter }),
98
+ ...(remediation === undefined ? {} : { recommendation: stripHtml(remediation) }),
99
+ sourceEngine: 'burp',
100
+ sourceRuleId: type,
101
+ ...(confidence === undefined ? {} : { confidence }),
102
+ createdAt: options.now,
103
+ });
104
+ }
105
+ return findings;
106
+ }
107
+ /** Burp's prose fields are HTML inside CDATA. */
108
+ function stripHtml(value) {
109
+ return value
110
+ .replace(/<[^>]*>/gu, ' ')
111
+ .replace(/\s+/gu, ' ')
112
+ .trim();
113
+ }
114
+ //# sourceMappingURL=burp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"burp.js","sourceRoot":"","sources":["../../src/import/burp.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,90 @@
1
+ import type { Finding } from '../finding.js';
2
+ import type { Severity } from '../severity.js';
3
+ import type { ImportOptions } from './nuclei.js';
4
+ /**
5
+ * One finding in the generic Secureport format.
6
+ *
7
+ * **Written last, on purpose.** This is the shape the four scanner importers
8
+ * turned out to have in common, rather than a format designed in advance and
9
+ * then argued with. Everything an engine reliably supplies is here; everything
10
+ * only one of them had is not.
11
+ *
12
+ * Only `title` and `location` are required. A tool that knows nothing but "this
13
+ * is wrong, and it is here" can still produce a trackable finding — which is the
14
+ * point of having a generic format at all.
15
+ */
16
+ export interface GenericFinding {
17
+ /** What is wrong. */
18
+ readonly title: string;
19
+ /** Where it is — a URL, a host, a `host:port`, or a file path. */
20
+ readonly location: string;
21
+ /** Fuller explanation. */
22
+ readonly description?: string;
23
+ /**
24
+ * Severity, if the tool rates it.
25
+ *
26
+ * Ignored when `cvssScore` is present: a score is more precise than a word,
27
+ * and `severitySource` records which was used.
28
+ */
29
+ readonly severity?: Severity;
30
+ /** CVSS base score, 0–10. Preferred over `severity` when both are given. */
31
+ readonly cvssScore?: number;
32
+ /** CVSS vector string. */
33
+ readonly cvssVector?: string;
34
+ /** CWE identifier, with or without the `CWE-` prefix. */
35
+ readonly cwe?: string;
36
+ /** CVE identifier. */
37
+ readonly cve?: string;
38
+ /** Broad grouping, e.g. `injection`. */
39
+ readonly category?: string;
40
+ /** The parameter implicated, where the weakness has one. */
41
+ readonly parameter?: string;
42
+ /** The port, for findings about a service rather than a path. */
43
+ readonly port?: number;
44
+ /** What produced it. Defaults to `generic`. */
45
+ readonly engine?: string;
46
+ /** The producing tool's own identifier for the rule. */
47
+ readonly ruleId?: string;
48
+ /** What to do about it. */
49
+ readonly recommendation?: string;
50
+ /** Further reading. */
51
+ readonly references?: readonly string[];
52
+ /** Pointers to stored evidence. */
53
+ readonly evidenceUri?: readonly string[];
54
+ /** The tool's confidence, 0–1. */
55
+ readonly confidence?: number;
56
+ }
57
+ /** A generic findings document. */
58
+ export interface GenericDocument {
59
+ /**
60
+ * Format version.
61
+ *
62
+ * Present so this file can change shape later without guessing. An unknown
63
+ * version is refused rather than parsed optimistically.
64
+ */
65
+ readonly version: 1;
66
+ /** The findings. */
67
+ readonly findings: readonly GenericFinding[];
68
+ }
69
+ /**
70
+ * Turns the generic Secureport format into {@link Finding}s.
71
+ *
72
+ * The escape hatch for everything with no importer of its own: a manual test, an
73
+ * internal tool, a scanner nobody has written a parser for. Fingerprinting,
74
+ * reconciliation and reports then work identically — a finding that arrives this
75
+ * way is not a second-class finding.
76
+ *
77
+ * Refuses the document rather than salvaging part of it. Unlike Nuclei's JSONL,
78
+ * where one bad line costs one record, this is a single document a human or a
79
+ * script wrote deliberately: a field in the wrong shape is a mistake worth
80
+ * hearing about, not one to route around silently.
81
+ *
82
+ * @param json - The contents of a generic findings document.
83
+ * @param options - Ownership, and the injected clock and id source.
84
+ * @returns One finding per entry, in document order.
85
+ * @throws SyntaxError If the text is not valid JSON.
86
+ * @throws TypeError If the document is not the expected shape, naming the entry
87
+ * and field at fault.
88
+ */
89
+ export declare function importGeneric(json: string, options: ImportOptions): Finding[];
90
+ //# sourceMappingURL=generic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generic.d.ts","sourceRoot":"","sources":["../../src/import/generic.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,QAAQ,EAAkB,MAAM,gBAAgB,CAAC;AAG/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,cAAc;IAC7B,qBAAqB;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,0BAA0B;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAE7B,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAE5B,0BAA0B;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAE7B,yDAAyD;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAEtB,sBAAsB;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAEtB,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAE3B,4DAA4D;IAC5D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAE5B,iEAAiE;IACjE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAEvB,+CAA+C;IAC/C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,wDAAwD;IACxD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,2BAA2B;IAC3B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAEjC,uBAAuB;IACvB,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAExC,mCAAmC;IACnC,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAEzC,kCAAkC;IAClC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,mCAAmC;AACnC,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IAEpB,oBAAoB;IACpB,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAC;CAC9C;AAuCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,EAAE,CA8G7E"}
@@ -0,0 +1,159 @@
1
+ import { SEVERITY_ORDER, severityFromCvss } from '../severity.js';
2
+ import { fingerprint, vulnKey, FINGERPRINT_VERSION } from '../fingerprint.js';
3
+ const SEVERITIES = new Set(SEVERITY_ORDER);
4
+ /** A non-empty trimmed string, or nothing. */
5
+ const str = (v) => typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
6
+ /** A finite number, or nothing. */
7
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
8
+ /**
9
+ * A title reduced to something usable as a weakness key.
10
+ *
11
+ * The last resort, and only for the generic format. A tool that reports nothing
12
+ * but "this is wrong, and it is here" has no CWE, no category and no rule id —
13
+ * and `vulnKey` would rightly refuse, because there is nothing to key on.
14
+ * Refusing would mean the minimal case cannot be imported at all, which defeats
15
+ * the point of having a generic format.
16
+ *
17
+ * **Titles are a poor key and this is not pretending otherwise:** reword the
18
+ * title and the issue becomes a different issue, and two tools describing the
19
+ * same weakness differently never collide. Supplying a `ruleId`, a `cwe` or a
20
+ * `category` is strictly better and all three are preferred over this.
21
+ */
22
+ const titleKey = (title) => title
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/gu, '-')
25
+ .replace(/^-|-$/gu, '');
26
+ /** An array of non-empty strings, or nothing. */
27
+ const strArray = (v) => {
28
+ if (!Array.isArray(v))
29
+ return undefined;
30
+ const items = v.map(str).filter((x) => x !== undefined);
31
+ return items.length > 0 ? items : undefined;
32
+ };
33
+ /**
34
+ * Turns the generic Secureport format into {@link Finding}s.
35
+ *
36
+ * The escape hatch for everything with no importer of its own: a manual test, an
37
+ * internal tool, a scanner nobody has written a parser for. Fingerprinting,
38
+ * reconciliation and reports then work identically — a finding that arrives this
39
+ * way is not a second-class finding.
40
+ *
41
+ * Refuses the document rather than salvaging part of it. Unlike Nuclei's JSONL,
42
+ * where one bad line costs one record, this is a single document a human or a
43
+ * script wrote deliberately: a field in the wrong shape is a mistake worth
44
+ * hearing about, not one to route around silently.
45
+ *
46
+ * @param json - The contents of a generic findings document.
47
+ * @param options - Ownership, and the injected clock and id source.
48
+ * @returns One finding per entry, in document order.
49
+ * @throws SyntaxError If the text is not valid JSON.
50
+ * @throws TypeError If the document is not the expected shape, naming the entry
51
+ * and field at fault.
52
+ */
53
+ export function importGeneric(json, options) {
54
+ const doc = JSON.parse(json);
55
+ if (typeof doc !== 'object' || doc === null) {
56
+ throw new TypeError('generic findings document must be an object');
57
+ }
58
+ const record = doc;
59
+ if (record['version'] !== 1) {
60
+ throw new TypeError(`unsupported generic findings version: ${String(record['version'])}`);
61
+ }
62
+ const raw = record['findings'];
63
+ if (!Array.isArray(raw)) {
64
+ throw new TypeError('generic findings document has no `findings` array');
65
+ }
66
+ return raw.map((item, index) => {
67
+ const where = `findings[${String(index)}]`;
68
+ if (typeof item !== 'object' || item === null) {
69
+ throw new TypeError(`${where} is not an object`);
70
+ }
71
+ const entry = item;
72
+ const title = str(entry['title']);
73
+ const location = str(entry['location']);
74
+ if (title === undefined)
75
+ throw new TypeError(`${where}.title is required`);
76
+ if (location === undefined)
77
+ throw new TypeError(`${where}.location is required`);
78
+ const stated = str(entry['severity']);
79
+ if (stated !== undefined && !SEVERITIES.has(stated)) {
80
+ throw new TypeError(`${where}.severity is not a severity: ${stated}`);
81
+ }
82
+ const score = num(entry['cvssScore']);
83
+ let detectedSeverity;
84
+ let severitySource;
85
+ if (score !== undefined && score >= 0 && score <= 10) {
86
+ detectedSeverity = severityFromCvss(score);
87
+ severitySource = 'cvss';
88
+ }
89
+ else if (stated !== undefined) {
90
+ detectedSeverity = stated;
91
+ // Somebody stated it for this finding specifically, which is the
92
+ // strongest provenance there is.
93
+ severitySource = 'explicit';
94
+ }
95
+ else {
96
+ detectedSeverity = 'advisory';
97
+ severitySource = 'engine_default';
98
+ }
99
+ const engine = str(entry['engine']) ?? 'generic';
100
+ const ruleId = str(entry['ruleId']);
101
+ const rawCwe = str(entry['cwe']);
102
+ const cwe = rawCwe === undefined ? undefined : `CWE-${rawCwe.replace(/^CWE-/iu, '')}`;
103
+ const category = str(entry['category']);
104
+ const parameter = str(entry['parameter']);
105
+ const port = num(entry['port']);
106
+ // Nothing classifies this finding, so key on the title rather than refuse
107
+ // it. `sourceRuleId` stays absent: the tool supplied none, and recording a
108
+ // slug as though it had would be a lie about provenance.
109
+ const key = cwe === undefined && category === undefined && ruleId === undefined
110
+ ? `${engine}:${titleKey(title)}`
111
+ : vulnKey({
112
+ sourceEngine: engine,
113
+ ...(ruleId === undefined ? {} : { sourceRuleId: ruleId }),
114
+ ...(cwe === undefined ? {} : { cwe }),
115
+ ...(category === undefined ? {} : { category }),
116
+ });
117
+ return {
118
+ id: options.newId(),
119
+ orgId: options.orgId,
120
+ runId: options.runId,
121
+ fingerprint: fingerprint({
122
+ targetId: options.targetId,
123
+ vulnKey: key,
124
+ location,
125
+ ...(parameter === undefined ? {} : { parameter }),
126
+ ...(port === undefined ? {} : { port }),
127
+ }),
128
+ fingerprintVersion: FINGERPRINT_VERSION,
129
+ title,
130
+ ...(str(entry['description']) === undefined
131
+ ? {}
132
+ : { description: str(entry['description']) }),
133
+ detectedSeverity,
134
+ severitySource,
135
+ ...(score === undefined ? {} : { cvssScore: score }),
136
+ ...(str(entry['cvssVector']) === undefined ? {} : { cvssVector: str(entry['cvssVector']) }),
137
+ ...(cwe === undefined ? {} : { cwe }),
138
+ ...(str(entry['cve']) === undefined ? {} : { cve: str(entry['cve']).toUpperCase() }),
139
+ vulnKey: key,
140
+ ...(category === undefined ? {} : { category: category.toLowerCase() }),
141
+ location,
142
+ ...(parameter === undefined ? {} : { parameter }),
143
+ ...(strArray(entry['evidenceUri']) === undefined
144
+ ? {}
145
+ : { evidenceUri: strArray(entry['evidenceUri']) }),
146
+ ...(str(entry['recommendation']) === undefined
147
+ ? {}
148
+ : { recommendation: str(entry['recommendation']) }),
149
+ ...(strArray(entry['references']) === undefined
150
+ ? {}
151
+ : { references: strArray(entry['references']) }),
152
+ sourceEngine: engine,
153
+ ...(ruleId === undefined ? {} : { sourceRuleId: ruleId }),
154
+ ...(num(entry['confidence']) === undefined ? {} : { confidence: num(entry['confidence']) }),
155
+ createdAt: options.now,
156
+ };
157
+ });
158
+ }
159
+ //# sourceMappingURL=generic.js.map