@secureport/core 1.0.0-rc.1 → 2.1.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 +2 -1
- package/dist/finding.d.ts +17 -0
- package/dist/finding.d.ts.map +1 -1
- package/dist/fingerprint.d.ts +12 -5
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +74 -8
- package/dist/fingerprint.js.map +1 -1
- package/dist/import/nessus.d.ts +12 -9
- package/dist/import/nessus.d.ts.map +1 -1
- package/dist/import/nessus.js +13 -9
- package/dist/import/nessus.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/issue.d.ts +8 -2
- package/dist/issue.d.ts.map +1 -1
- package/dist/reconcile.d.ts +25 -0
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +30 -3
- package/dist/reconcile.js.map +1 -1
- package/dist/refingerprint.d.ts +116 -0
- package/dist/refingerprint.d.ts.map +1 -0
- package/dist/refingerprint.js +190 -0
- package/dist/refingerprint.js.map +1 -0
- package/dist/report/html.js +1 -1
- package/dist/report/html.js.map +1 -1
- package/dist/report/markdown.js +1 -1
- package/dist/report/markdown.js.map +1 -1
- package/dist/run.d.ts +16 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/snapshot-builder.js +1 -1
- package/dist/snapshot-builder.js.map +1 -1
- package/package.json +1 -1
- package/src/finding.ts +18 -0
- package/src/fingerprint.ts +76 -10
- package/src/import/nessus.ts +13 -9
- package/src/index.ts +18 -1
- package/src/issue.ts +8 -2
- package/src/reconcile.ts +35 -3
- package/src/refingerprint.ts +282 -0
- package/src/report/html.ts +1 -1
- package/src/report/markdown.ts +1 -1
- package/src/run.ts +18 -0
- package/src/snapshot-builder.ts +1 -1
package/src/fingerprint.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { createHash } from 'node:crypto';
|
|
|
13
13
|
* If you are tempted to "just tweak" the normaliser, that is this constant's
|
|
14
14
|
* job to prevent.
|
|
15
15
|
*/
|
|
16
|
-
export const FINGERPRINT_VERSION = '
|
|
16
|
+
export const FINGERPRINT_VERSION = 'fp_v2';
|
|
17
17
|
|
|
18
18
|
/** A path segment that is entirely digits, e.g. the `123` in `/orders/123`. */
|
|
19
19
|
const NUMERIC_SEGMENT = /^\d+$/u;
|
|
@@ -80,10 +80,17 @@ function collapseSegment(segment: string): string {
|
|
|
80
80
|
* one issue into two.
|
|
81
81
|
* - **Drops the fragment**, which the server never sees.
|
|
82
82
|
*
|
|
83
|
-
*
|
|
84
|
-
* `
|
|
85
|
-
*
|
|
86
|
-
*
|
|
83
|
+
* - **Reads `host:443` and `host:80` as the URL the port implies**, so a
|
|
84
|
+
* network scanner's `app.example.com:443` and a web scanner's
|
|
85
|
+
* `https://app.example.com/` are one endpoint (B102, `fp_v2`). Only those two
|
|
86
|
+
* ports: any other is left alone rather than guessed at, because
|
|
87
|
+
* `host:8443` could be TLS or plaintext and inventing a scheme to force a
|
|
88
|
+
* match would assert something nobody measured.
|
|
89
|
+
*
|
|
90
|
+
* Anything else that is not a parseable absolute URL — a bare host, a file
|
|
91
|
+
* path — is normalised as a path alone. That is deliberate: refusing to
|
|
92
|
+
* fingerprint a non-HTTP finding would exclude whole classes of scanner from
|
|
93
|
+
* the model.
|
|
87
94
|
*
|
|
88
95
|
* @param location - Where the weakness was found.
|
|
89
96
|
* @returns The normalised location.
|
|
@@ -107,7 +114,9 @@ export function normaliseLocation(location: string): string {
|
|
|
107
114
|
// Not an absolute URL: normalise it as a bare path and stop. `new URL` would
|
|
108
115
|
// otherwise turn `example.com/x` into the `example.com:` protocol.
|
|
109
116
|
if (!url || url.protocol === '' || !url.host) {
|
|
110
|
-
|
|
117
|
+
const implied = impliedSchemeUrl(trimmed);
|
|
118
|
+
if (implied === undefined) return normalisePathOnly(trimmed);
|
|
119
|
+
url = implied;
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
// Not `decodeOnce` here: normalisePathOnly decodes, and decoding on the way
|
|
@@ -123,6 +132,32 @@ export function normaliseLocation(location: string): string {
|
|
|
123
132
|
return `${url.protocol}//${url.host}${path}${query}`;
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
/**
|
|
136
|
+
* A bare `host:port` read as the URL its port implies, or `undefined`.
|
|
137
|
+
*
|
|
138
|
+
* **Only `:443` and `:80`, and that restraint is the point.** A network scanner
|
|
139
|
+
* reports `app.example.com:443` for the weakness a web scanner reports at
|
|
140
|
+
* `https://app.example.com/`, and those are one endpoint: 443 means TLS and an
|
|
141
|
+
* empty path means the root. Both are convention rather than guarantee, but
|
|
142
|
+
* they are conventions every scanner in this model already relies on, and
|
|
143
|
+
* leaving the two unmatched means a network scan and a web scan of one host
|
|
144
|
+
* can never agree about anything — which is the duplication P6 exists to
|
|
145
|
+
* remove (B102).
|
|
146
|
+
*
|
|
147
|
+
* Any other port is left alone rather than guessed at. `app.example.com:8443`
|
|
148
|
+
* could be TLS or plaintext, and inventing a scheme to make a match happen
|
|
149
|
+
* would be asserting something nobody measured.
|
|
150
|
+
*/
|
|
151
|
+
function impliedSchemeUrl(location: string): URL | undefined {
|
|
152
|
+
const match = /^([a-z0-9.\-_]+|\[[0-9a-f:]+\]):(443|80)$/iu.exec(location);
|
|
153
|
+
if (!match) return undefined;
|
|
154
|
+
try {
|
|
155
|
+
return new URL(`${match[2] === '443' ? 'https' : 'http'}://${location}`);
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
126
161
|
/**
|
|
127
162
|
* Normalises a path with no scheme or host.
|
|
128
163
|
*/
|
|
@@ -197,6 +232,15 @@ export const VULN_KEY_MAP: Readonly<Record<string, string>> = Object.freeze({
|
|
|
197
232
|
// the other stops the two agreeing, so these travel together.
|
|
198
233
|
'nuclei:xss-reflected': 'xss-reflected',
|
|
199
234
|
'nuclei:missing-hsts': 'hsts-missing',
|
|
235
|
+
|
|
236
|
+
// Burp and Nessus, added in `fp_v2`. Neither had a single entry, so both fell
|
|
237
|
+
// through to the CWE for every finding — which is worse than no table at all:
|
|
238
|
+
// ZAP and Nuclei keyed `hsts-missing` while Burp and Nessus keyed `CWE-319`
|
|
239
|
+
// for the same weakness, so half a table was actively splitting issues that a
|
|
240
|
+
// plain CWE fallback would have kept together.
|
|
241
|
+
'burp:5244160': 'xss-reflected',
|
|
242
|
+
'burp:6234880': 'hsts-missing',
|
|
243
|
+
'nessus:42822': 'hsts-missing',
|
|
200
244
|
});
|
|
201
245
|
|
|
202
246
|
/**
|
|
@@ -306,10 +350,32 @@ export interface FingerprintInput {
|
|
|
306
350
|
* ```
|
|
307
351
|
*/
|
|
308
352
|
export function fingerprint(input: FingerprintInput): string {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
353
|
+
// The port is no longer part of the tail. `normaliseLocation` now canonicalises
|
|
354
|
+
// a `host:port` location, so a non-default port is already in the location and
|
|
355
|
+
// a default one is deliberately absent from it — counting the port again here
|
|
356
|
+
// made a Nessus finding on 443 unable to match a web scanner's finding on the
|
|
357
|
+
// same endpoint. `import/nessus.ts` recorded this double-count as something
|
|
358
|
+
// that had to stay because removing it would force a re-fingerprint migration;
|
|
359
|
+
// `fp_v2` is that migration.
|
|
360
|
+
const tail = input.parameter ?? '';
|
|
361
|
+
|
|
362
|
+
// A named parameter makes the query string redundant: both describe the same
|
|
363
|
+
// input, and keeping the query too means a scanner that reports the parameter
|
|
364
|
+
// separately (Burp: `/search`, parameter `q`) can never match one that leaves
|
|
365
|
+
// it in the URL (ZAP: `/search?q=test`, parameter `q`). When no parameter is
|
|
366
|
+
// named the query names stay, because then they are the only thing
|
|
367
|
+
// distinguishing one page from another — and Nuclei never names a parameter,
|
|
368
|
+
// so dropping them unconditionally would merge `/view?id` with `/view?page`.
|
|
369
|
+
const location = normaliseLocation(input.location);
|
|
370
|
+
const addressed = input.parameter === undefined ? location : stripQuery(location);
|
|
371
|
+
|
|
372
|
+
const material = [input.targetId, input.vulnKey, addressed, tail].join('|');
|
|
313
373
|
|
|
314
374
|
return createHash('sha256').update(material, 'utf8').digest('hex');
|
|
315
375
|
}
|
|
376
|
+
|
|
377
|
+
/** Everything before the `?` of an already-normalised location. */
|
|
378
|
+
function stripQuery(location: string): string {
|
|
379
|
+
const at = location.indexOf('?');
|
|
380
|
+
return at === -1 ? location : location.slice(0, at);
|
|
381
|
+
}
|
package/src/import/nessus.ts
CHANGED
|
@@ -24,16 +24,19 @@ const NESSUS_SEVERITY: Readonly<Record<string, Severity>> = Object.freeze({
|
|
|
24
24
|
*
|
|
25
25
|
* Nessus is host-oriented: every `ReportItem` hangs off a `ReportHost`, and the
|
|
26
26
|
* port and protocol are attributes rather than part of a URL. The location is
|
|
27
|
-
* assembled as `host:port`,
|
|
28
|
-
*
|
|
29
|
-
* location is lost. A network finding with no path still fingerprints
|
|
30
|
-
* distinctly per service because the port is inside its location.
|
|
27
|
+
* assembled as `host:port`, so a network finding with no path still
|
|
28
|
+
* fingerprints distinctly per service.
|
|
31
29
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
30
|
+
* The port is also recorded on {@link Finding.port} (**B42**), which is a
|
|
31
|
+
* record and nothing more. **The double-count is gone in `fp_v2`** (6.9c): the
|
|
32
|
+
* fingerprint's last component was `parameter ?? port ?? ''`, so a Nessus port
|
|
33
|
+
* was counted twice — once inside the location and once as that component —
|
|
34
|
+
* and this comment said it had to stay because removing it would force a
|
|
35
|
+
* re-fingerprint migration. 6.9b built that migration, so it went. The tail is
|
|
36
|
+
* now `parameter ?? ''`, and the port is carried by the location alone, which
|
|
37
|
+
* `normaliseLocation` canonicalises: `app.example.com:443` and
|
|
38
|
+
* `https://app.example.com/` are one endpoint (B102), and a non-default port
|
|
39
|
+
* stays in the location where it belongs.
|
|
37
40
|
*
|
|
38
41
|
* Severity comes from the CVSS v3 base score where Nessus supplies one, and from
|
|
39
42
|
* its own numeric rating otherwise.
|
|
@@ -121,6 +124,7 @@ export function importNessus(xml: string, options: ImportOptions): Finding[] {
|
|
|
121
124
|
vulnKey: key,
|
|
122
125
|
...(family === undefined ? {} : { category: family.toLowerCase() }),
|
|
123
126
|
location,
|
|
127
|
+
...(port === undefined ? {} : { port }),
|
|
124
128
|
...(childText(item, 'solution') === undefined
|
|
125
129
|
? {}
|
|
126
130
|
: { recommendation: childText(item, 'solution')! }),
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
|
|
17
17
|
export type { Finding } from './finding.js';
|
|
18
18
|
export type { FingerprintInput, VulnKeyInput } from './fingerprint.js';
|
|
19
|
+
export type {
|
|
20
|
+
FingerprintAlgorithm,
|
|
21
|
+
IssuePlan,
|
|
22
|
+
IssueWithEvidence,
|
|
23
|
+
RefingerprintOutcome,
|
|
24
|
+
RefingerprintPlan,
|
|
25
|
+
} from './refingerprint.js';
|
|
26
|
+
export { currentAlgorithm, planRefingerprint } from './refingerprint.js';
|
|
27
|
+
export { exposureOf } from './reconcile.js';
|
|
19
28
|
export {
|
|
20
29
|
FINGERPRINT_VERSION,
|
|
21
30
|
PATH_PLACEHOLDER,
|
|
@@ -60,7 +69,15 @@ export type { JsonReport } from './report/json.js';
|
|
|
60
69
|
export { renderJson } from './report/json.js';
|
|
61
70
|
export type { ReconcileInput, ReconcileResult } from './reconcile.js';
|
|
62
71
|
export { reconcile } from './reconcile.js';
|
|
63
|
-
export type {
|
|
72
|
+
export type {
|
|
73
|
+
Coverage,
|
|
74
|
+
Run,
|
|
75
|
+
RunKind,
|
|
76
|
+
RunStatus,
|
|
77
|
+
RunSummary,
|
|
78
|
+
RunTrigger,
|
|
79
|
+
SeverityCounts,
|
|
80
|
+
} from './run.js';
|
|
64
81
|
export type { IssueChange, Snapshot, SnapshotIssue, SuppressedIssue, Target } from './snapshot.js';
|
|
65
82
|
export type { Severity, SeveritySource, SlaPolicy, SlaStatus } from './severity.js';
|
|
66
83
|
export {
|
package/src/issue.ts
CHANGED
|
@@ -186,9 +186,15 @@ export interface Issue {
|
|
|
186
186
|
* When remediation is due, derived from {@link Issue.effectiveSeverity}.
|
|
187
187
|
*
|
|
188
188
|
* Recomputed whenever the effective severity changes. `null` where the
|
|
189
|
-
* severity carries no deadline
|
|
189
|
+
* severity carries no deadline — `advisory` under
|
|
190
|
+
* {@link DEFAULT_SLA_POLICY}.
|
|
191
|
+
*
|
|
192
|
+
* **Required, and nullable.** Two states, not three: reconciliation always
|
|
193
|
+
* computes this when it creates an issue, so "never computed" does not occur
|
|
194
|
+
* downstream and an optional field would describe a state nothing produces.
|
|
195
|
+
* One nullable column stores it exactly.
|
|
190
196
|
*/
|
|
191
|
-
readonly slaDueAt
|
|
197
|
+
readonly slaDueAt: Date | null;
|
|
192
198
|
}
|
|
193
199
|
|
|
194
200
|
/**
|
package/src/reconcile.ts
CHANGED
|
@@ -146,6 +146,40 @@ function defaultThreshold(kind: RunKind): number {
|
|
|
146
146
|
return kind === 'upload' ? 1 : 2;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* How much a single issue contributes to an exposure score.
|
|
151
|
+
*
|
|
152
|
+
* **Severity times age in days, capped at ninety.** The cap is the load-bearing
|
|
153
|
+
* part: without it a two-year-old advisory eventually outweighs a critical
|
|
154
|
+
* found this morning, and the number stops being about risk and starts being
|
|
155
|
+
* about how long you have had the account. Ninety days is where an unfixed
|
|
156
|
+
* issue stops getting worse and simply *is* bad.
|
|
157
|
+
*
|
|
158
|
+
* **Only `open` and `regressed` count.** A resolved issue is not exposure, and
|
|
159
|
+
* an ignored one is excluded from exposure exactly as it is excluded from
|
|
160
|
+
* every count but its own (invariant 7) — suppressing an issue is a statement
|
|
161
|
+
* that it is not currently risk, and a score that disagreed with that would
|
|
162
|
+
* make the suppression pointless.
|
|
163
|
+
*
|
|
164
|
+
* Exported because `reconcile()` is not the only thing that needs it: the
|
|
165
|
+
* nightly rollup scores a whole organisation across every target, and two
|
|
166
|
+
* implementations of this would be two definitions of exposure.
|
|
167
|
+
*
|
|
168
|
+
* @param issue - The issue to score.
|
|
169
|
+
* @param now - When the score is being taken. An argument, so the same issue
|
|
170
|
+
* and the same clock always give the same number.
|
|
171
|
+
* @returns The contribution, which is zero for anything not currently open.
|
|
172
|
+
*/
|
|
173
|
+
export function exposureOf(
|
|
174
|
+
issue: Pick<Issue, 'status' | 'effectiveSeverity' | 'firstSeen'>,
|
|
175
|
+
now: Date,
|
|
176
|
+
): number {
|
|
177
|
+
if (issue.status !== 'open' && issue.status !== 'regressed') return 0;
|
|
178
|
+
return (
|
|
179
|
+
SEVERITY_WEIGHTS[issue.effectiveSeverity] * Math.min(daysBetween(issue.firstSeen, now), 90)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
149
183
|
/** An all-zero count, so a report never has to decide what a missing key means. */
|
|
150
184
|
function emptyCounts(): Record<string, number> {
|
|
151
185
|
return Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
@@ -446,9 +480,7 @@ export function reconcile(input: ReconcileInput): ReconcileResult {
|
|
|
446
480
|
else if (regressedNow.has(key)) counts.regressed[severity]++;
|
|
447
481
|
else if (updated.has(key)) counts.stillOpen[severity]++;
|
|
448
482
|
|
|
449
|
-
|
|
450
|
-
exposureScore += SEVERITY_WEIGHTS[severity] * Math.min(daysBetween(issue.firstSeen, now), 90);
|
|
451
|
-
}
|
|
483
|
+
exposureScore += exposureOf(issue, now);
|
|
452
484
|
}
|
|
453
485
|
|
|
454
486
|
const summary: RunSummary = {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { Finding } from './finding.js';
|
|
2
|
+
import type { FingerprintInput, VulnKeyInput } from './fingerprint.js';
|
|
3
|
+
import { FINGERPRINT_VERSION, fingerprint, vulnKey } from './fingerprint.js';
|
|
4
|
+
import type { Issue } from './issue.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How to compute a fingerprint. Injected so the dry run can be pointed at a
|
|
8
|
+
* version that does not exist yet.
|
|
9
|
+
*
|
|
10
|
+
* **`@secureport/core` only ever contains one algorithm** — the current one,
|
|
11
|
+
* named by `FINGERPRINT_VERSION`. So on the day a version bump is being
|
|
12
|
+
* considered, the way to find out what it would do is to hand this planner
|
|
13
|
+
* the candidate implementation. Against the shipped algorithm the plan is a
|
|
14
|
+
* no-op by construction, which is exactly what makes it safe to run at any
|
|
15
|
+
* time and what lets the tests drive real merges and splits without a
|
|
16
|
+
* released `fp_v2`.
|
|
17
|
+
*/
|
|
18
|
+
export interface FingerprintAlgorithm {
|
|
19
|
+
readonly version: string;
|
|
20
|
+
fingerprintOf(input: FingerprintInput): string;
|
|
21
|
+
vulnKeyOf(input: VulnKeyInput): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The algorithm this build ships. */
|
|
25
|
+
export const currentAlgorithm: FingerprintAlgorithm = {
|
|
26
|
+
version: FINGERPRINT_VERSION,
|
|
27
|
+
fingerprintOf: fingerprint,
|
|
28
|
+
vulnKeyOf: vulnKey,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** An issue and the evidence a re-fingerprint would recompute it from. */
|
|
32
|
+
export interface IssueWithEvidence {
|
|
33
|
+
readonly issue: Issue;
|
|
34
|
+
/**
|
|
35
|
+
* The evidence that **defines** this issue, which is not always every
|
|
36
|
+
* finding linked to it: `gatherForOrg` excludes what a merge attached from
|
|
37
|
+
* another issue, because that evidence describes a different weakness and
|
|
38
|
+
* re-deriving a key from it would split the survivor apart.
|
|
39
|
+
*/
|
|
40
|
+
readonly findings: readonly Finding[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What would happen to one issue.
|
|
45
|
+
*
|
|
46
|
+
* - `unchanged` — recomputes to the fingerprint it already has.
|
|
47
|
+
* - `moved` — one new fingerprint, different, and nothing else lands on it.
|
|
48
|
+
* The issue keeps its identity and its history; only the key changes.
|
|
49
|
+
* - `merged` — its new fingerprint is shared with at least one other issue.
|
|
50
|
+
* They become one, which is the outcome B39 and B41 are chasing.
|
|
51
|
+
* - `split` — its findings no longer agree on a fingerprint, so the issue
|
|
52
|
+
* would become several. The most disruptive outcome and the one that most
|
|
53
|
+
* needs reading before an execution.
|
|
54
|
+
* - `undecidable` — at least one of its findings cannot be re-keyed at all,
|
|
55
|
+
* so no honest answer exists for it. See {@link planRefingerprint}.
|
|
56
|
+
*/
|
|
57
|
+
export type RefingerprintOutcome = 'unchanged' | 'moved' | 'merged' | 'split' | 'undecidable';
|
|
58
|
+
|
|
59
|
+
export interface IssuePlan {
|
|
60
|
+
readonly issueId: string;
|
|
61
|
+
readonly targetId: string;
|
|
62
|
+
readonly title: string;
|
|
63
|
+
readonly from: string;
|
|
64
|
+
readonly fromVersion: string;
|
|
65
|
+
/** Distinct fingerprints this issue's evidence produces at the new version. */
|
|
66
|
+
readonly to: readonly string[];
|
|
67
|
+
readonly outcome: RefingerprintOutcome;
|
|
68
|
+
/** Other issues that land on one of `to`. Empty unless `outcome` involves a merge. */
|
|
69
|
+
readonly mergesWith: readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* Findings linked to the issue — **not necessarily the number the recompute
|
|
72
|
+
* read**. A manual issue is keyed from its own columns however much evidence
|
|
73
|
+
* is attached to it, and a scanner issue with zero findings falls back to
|
|
74
|
+
* the same path. Both are reported here as they stand so the count stays a
|
|
75
|
+
* fact about the data rather than about this function.
|
|
76
|
+
*/
|
|
77
|
+
readonly evidence: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RefingerprintPlan {
|
|
81
|
+
readonly fromVersion: string;
|
|
82
|
+
readonly toVersion: string;
|
|
83
|
+
readonly issues: readonly IssuePlan[];
|
|
84
|
+
readonly counts: Readonly<Record<RefingerprintOutcome, number>>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Works out what re-fingerprinting every issue would do, and writes nothing.
|
|
89
|
+
*
|
|
90
|
+
* **Recomputed from inputs, never by comparing stored strings.** A stored
|
|
91
|
+
* `fp_v1` fingerprint and a stored `fp_v2` one are not comparable — that is
|
|
92
|
+
* the whole reason `reconcile()` refuses to run across versions (B45) — so
|
|
93
|
+
* the only honest question is "what does the new algorithm say about the same
|
|
94
|
+
* evidence?". That means re-deriving `vulnKey` too: B39's change was to the
|
|
95
|
+
* `vulnKey` fallback, and reading the stored value would miss precisely the
|
|
96
|
+
* class of change this migration exists for.
|
|
97
|
+
*
|
|
98
|
+
* **A manual issue is keyed from its own columns, evidence or not.** Findings
|
|
99
|
+
* can be attached to a human-raised issue after the fact; 24 of the 404 manual
|
|
100
|
+
* issues in the development database are. Re-deriving those from the evidence
|
|
101
|
+
* would replace the human's `vulnKey` with the scanner's and merge the issue
|
|
102
|
+
* into whichever upload-origin issue already holds that key — losing the
|
|
103
|
+
* issue a person raised, in a migration nobody expected to lose anything.
|
|
104
|
+
*
|
|
105
|
+
* **An issue an earlier merge retired keeps the key it has**, and this is what
|
|
106
|
+
* makes the plan reach a fixed point. A merge copies the duplicate's evidence
|
|
107
|
+
* to the survivor without removing it from the duplicate (B98), so re-deriving
|
|
108
|
+
* a retired issue from its evidence produces the survivor's key and proposes
|
|
109
|
+
* the merge that already happened — every run, forever. Checked before the
|
|
110
|
+
* manual rule, because an issue can be both and retirement is the later
|
|
111
|
+
* statement about its identity.
|
|
112
|
+
*
|
|
113
|
+
* **Findings are never touched, and this never needs them to be.** A finding
|
|
114
|
+
* is evidence (invariant 1) and `findings` rejects `UPDATE` at the database
|
|
115
|
+
* (B66); its `fingerprint_version` records which algorithm produced it and
|
|
116
|
+
* that stays true forever. Only `issues` are re-keyed. B69, 8 September.
|
|
117
|
+
*
|
|
118
|
+
* **A finding whose key cannot be re-derived makes its issue `undecidable`,
|
|
119
|
+
* and does not stop the report.** `vulnKey()` throws when a finding carries
|
|
120
|
+
* no mapped rule, CWE, category or engine rule id — so a row that was never
|
|
121
|
+
* produced by an importer at this version (or was produced by a different
|
|
122
|
+
* one) has no answer. Reporting that is the useful behaviour: a migration
|
|
123
|
+
* plan that dies on one row tells an operator nothing about the other
|
|
124
|
+
* thousand, and guessing a key from the stored value would defeat the whole
|
|
125
|
+
* purpose, since re-deriving `vulnKey` is the point.
|
|
126
|
+
*/
|
|
127
|
+
export function planRefingerprint(
|
|
128
|
+
input: readonly IssueWithEvidence[],
|
|
129
|
+
algorithm: FingerprintAlgorithm = currentAlgorithm,
|
|
130
|
+
): RefingerprintPlan {
|
|
131
|
+
const recomputed = input.map(({ issue, findings }) => {
|
|
132
|
+
// Retirement is checked first, and the order is load-bearing. An issue an
|
|
133
|
+
// earlier merge retired is not re-keyed at all: its identity moved to the
|
|
134
|
+
// survivor, and its evidence — which the merge copied to that survivor —
|
|
135
|
+
// would re-derive the survivor's key and propose merging the two all over
|
|
136
|
+
// again, so the plan would never reach a fixed point. A *manual* issue
|
|
137
|
+
// that was later merged away is both things at once, and retirement is
|
|
138
|
+
// the later statement about its identity, so it wins. Measured: with the
|
|
139
|
+
// branches the other way round, 95 issues reported `moved` on every run
|
|
140
|
+
// while the executor left them alone on every run, forever.
|
|
141
|
+
if (issue.status === 'ignored' && issue.ignoreReason === 'duplicate') {
|
|
142
|
+
return { issue, evidence: findings.length, undecidable: false, to: [issue.fingerprint] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A manual issue is keyed from its own columns: its `vulnKey` and location
|
|
146
|
+
// were declared by a person, and evidence linked afterwards corroborates
|
|
147
|
+
// that issue rather than redefining it. Re-deriving would replace the
|
|
148
|
+
// human's key with the scanner's and dissolve the issue into whichever
|
|
149
|
+
// upload-origin issue already holds it.
|
|
150
|
+
//
|
|
151
|
+
// A *merge survivor* is deliberately not handled here, though it is also
|
|
152
|
+
// an identity a person asserted. Keying one from its own columns carries a
|
|
153
|
+
// stale `vulnKey` — written before the mapping table knew the engine's
|
|
154
|
+
// rule — so the migrated issue stops matching what the importer now
|
|
155
|
+
// produces, auto-resolves and comes back as new. `gatherForOrg` excludes
|
|
156
|
+
// the absorbed evidence instead, so a survivor re-derives correctly from
|
|
157
|
+
// what was always its own.
|
|
158
|
+
if (issue.origin === 'manual') {
|
|
159
|
+
return {
|
|
160
|
+
issue,
|
|
161
|
+
evidence: findings.length,
|
|
162
|
+
undecidable: false,
|
|
163
|
+
to: [fromOwnColumns(issue, algorithm)],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const keys = findings.map((f) => fingerprintOfFinding(issue, f, algorithm));
|
|
168
|
+
return {
|
|
169
|
+
issue,
|
|
170
|
+
evidence: findings.length,
|
|
171
|
+
undecidable: keys.some((k) => k === undefined),
|
|
172
|
+
to: [...new Set(keys.filter((k): k is string => k !== undefined))].sort(),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// A scanner-origin issue whose evidence has since been detached has nothing
|
|
177
|
+
// left to re-derive from, so it falls back to its own columns rather than
|
|
178
|
+
// vanishing from the report.
|
|
179
|
+
for (const row of recomputed) {
|
|
180
|
+
if (row.to.length === 0 && !row.undecidable) {
|
|
181
|
+
row.to = [fromOwnColumns(row.issue, algorithm)];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const owners = new Map<string, string[]>();
|
|
186
|
+
for (const row of recomputed) {
|
|
187
|
+
if (row.undecidable) continue;
|
|
188
|
+
for (const fp of row.to) {
|
|
189
|
+
const list = owners.get(fp);
|
|
190
|
+
if (list) list.push(row.issue.id);
|
|
191
|
+
else owners.set(fp, [row.issue.id]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const counts: Record<RefingerprintOutcome, number> = {
|
|
196
|
+
unchanged: 0,
|
|
197
|
+
moved: 0,
|
|
198
|
+
merged: 0,
|
|
199
|
+
split: 0,
|
|
200
|
+
undecidable: 0,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const issues = recomputed.map(({ issue, evidence, to, undecidable }): IssuePlan => {
|
|
204
|
+
const mergesWith = [
|
|
205
|
+
...new Set(to.flatMap((fp) => owners.get(fp) ?? []).filter((id) => id !== issue.id)),
|
|
206
|
+
].sort();
|
|
207
|
+
|
|
208
|
+
// Split first: an issue that fragments is the most disruptive outcome
|
|
209
|
+
// and stays visible even when one of its fragments also collides.
|
|
210
|
+
const outcome: RefingerprintOutcome = undecidable
|
|
211
|
+
? 'undecidable'
|
|
212
|
+
: to.length > 1
|
|
213
|
+
? 'split'
|
|
214
|
+
: mergesWith.length > 0
|
|
215
|
+
? 'merged'
|
|
216
|
+
: to[0] === issue.fingerprint
|
|
217
|
+
? 'unchanged'
|
|
218
|
+
: 'moved';
|
|
219
|
+
|
|
220
|
+
counts[outcome] += 1;
|
|
221
|
+
return {
|
|
222
|
+
issueId: issue.id,
|
|
223
|
+
targetId: issue.targetId,
|
|
224
|
+
title: issue.title,
|
|
225
|
+
from: issue.fingerprint,
|
|
226
|
+
fromVersion: issue.fingerprintVersion,
|
|
227
|
+
to,
|
|
228
|
+
outcome,
|
|
229
|
+
mergesWith,
|
|
230
|
+
evidence,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const fromVersions = [...new Set(input.map(({ issue }) => issue.fingerprintVersion))].sort();
|
|
235
|
+
return {
|
|
236
|
+
fromVersion: fromVersions.join(', ') || algorithm.version,
|
|
237
|
+
toVersion: algorithm.version,
|
|
238
|
+
issues,
|
|
239
|
+
counts,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** An issue's fingerprint at the new version, from the issue's own columns. */
|
|
244
|
+
function fromOwnColumns(issue: Issue, algorithm: FingerprintAlgorithm): string {
|
|
245
|
+
return algorithm.fingerprintOf({
|
|
246
|
+
targetId: issue.targetId,
|
|
247
|
+
vulnKey: issue.vulnKey,
|
|
248
|
+
location: issue.location,
|
|
249
|
+
...(issue.parameter === undefined ? {} : { parameter: issue.parameter }),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One finding's fingerprint at the new version, `vulnKey` re-derived and all,
|
|
255
|
+
* or `undefined` when the key cannot be derived from what the finding holds.
|
|
256
|
+
*/
|
|
257
|
+
function fingerprintOfFinding(
|
|
258
|
+
issue: Issue,
|
|
259
|
+
finding: Finding,
|
|
260
|
+
algorithm: FingerprintAlgorithm,
|
|
261
|
+
): string | undefined {
|
|
262
|
+
let key;
|
|
263
|
+
try {
|
|
264
|
+
key = algorithm.vulnKeyOf({
|
|
265
|
+
sourceEngine: finding.sourceEngine,
|
|
266
|
+
...(finding.sourceRuleId === undefined ? {} : { sourceRuleId: finding.sourceRuleId }),
|
|
267
|
+
...(finding.cwe === undefined ? {} : { cwe: finding.cwe }),
|
|
268
|
+
...(finding.category === undefined ? {} : { category: finding.category }),
|
|
269
|
+
});
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
return algorithm.fingerprintOf({
|
|
274
|
+
// The target is the issue's: a finding records the run it came from, and
|
|
275
|
+
// every run of an issue's evidence is against the same target anyway.
|
|
276
|
+
targetId: issue.targetId,
|
|
277
|
+
vulnKey: key,
|
|
278
|
+
location: finding.location,
|
|
279
|
+
...(finding.parameter === undefined ? {} : { parameter: finding.parameter }),
|
|
280
|
+
...(finding.port === undefined ? {} : { port: finding.port }),
|
|
281
|
+
});
|
|
282
|
+
}
|
package/src/report/html.ts
CHANGED
|
@@ -121,7 +121,7 @@ function issueDetail(entry: SnapshotIssue): string {
|
|
|
121
121
|
}</td></tr>
|
|
122
122
|
<tr><td>First seen</td><td>${formatDate(issue.firstSeen)} (${entry.daysOpen} days)</td></tr>
|
|
123
123
|
<tr><td>Remediation</td><td${entry.slaStatus === 'breached' ? ' class="breached"' : ''}>${
|
|
124
|
-
issue.slaDueAt ===
|
|
124
|
+
issue.slaDueAt === null
|
|
125
125
|
? 'no deadline'
|
|
126
126
|
: `${formatDate(issue.slaDueAt)} — ${entry.slaStatus.replace('_', ' ')}`
|
|
127
127
|
}</td></tr>
|
package/src/report/markdown.ts
CHANGED
|
@@ -67,7 +67,7 @@ function issueDetail(entry: SnapshotIssue): string[] {
|
|
|
67
67
|
`| Location | \`${cell(issue.location)}\`${issue.parameter === undefined ? '' : ` (\`${cell(issue.parameter)}\`)`} |`,
|
|
68
68
|
`| First seen | ${formatDate(issue.firstSeen)} (${entry.daysOpen} days) |`,
|
|
69
69
|
`| Remediation | ${
|
|
70
|
-
issue.slaDueAt ===
|
|
70
|
+
issue.slaDueAt === null
|
|
71
71
|
? 'no deadline'
|
|
72
72
|
: `${formatDate(issue.slaDueAt)} — ${entry.slaStatus.replace('_', ' ')}`
|
|
73
73
|
} |`,
|
package/src/run.ts
CHANGED
|
@@ -50,6 +50,21 @@ export interface Coverage {
|
|
|
50
50
|
readonly engines: readonly string[];
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Where a run is in its lifecycle.
|
|
55
|
+
*
|
|
56
|
+
* - `running` — started and not yet ended.
|
|
57
|
+
* - `finished` — ended, and its findings are what it found.
|
|
58
|
+
* - `failed` — ended without a usable result; any findings it recorded are
|
|
59
|
+
* partial.
|
|
60
|
+
*
|
|
61
|
+
* `finishedAt` alone cannot carry this: it says a run ended, not whether it
|
|
62
|
+
* succeeded, and reconciling a failed run's partial findings as if they were
|
|
63
|
+
* complete would resolve everything the run never reached. A running run has
|
|
64
|
+
* no `finishedAt`; an ended one always does.
|
|
65
|
+
*/
|
|
66
|
+
export type RunStatus = 'running' | 'finished' | 'failed';
|
|
67
|
+
|
|
53
68
|
/**
|
|
54
69
|
* One execution against a target.
|
|
55
70
|
*/
|
|
@@ -69,6 +84,9 @@ export interface Run {
|
|
|
69
84
|
/** What set it going. */
|
|
70
85
|
readonly trigger: RunTrigger;
|
|
71
86
|
|
|
87
|
+
/** Where it is in its lifecycle. */
|
|
88
|
+
readonly status: RunStatus;
|
|
89
|
+
|
|
72
90
|
/** What it exercised. */
|
|
73
91
|
readonly coverage: Coverage;
|
|
74
92
|
|
package/src/snapshot-builder.ts
CHANGED
|
@@ -145,7 +145,7 @@ export function buildSnapshot(input: BuildSnapshotInput): Snapshot {
|
|
|
145
145
|
// From firstSeen, which is never reset, so a regression does not make a
|
|
146
146
|
// year-old problem look new.
|
|
147
147
|
daysOpen: daysBetween(issue.firstSeen, input.now),
|
|
148
|
-
slaStatus: slaStatus(issue.slaDueAt
|
|
148
|
+
slaStatus: slaStatus(issue.slaDueAt, input.now, slaPolicy),
|
|
149
149
|
findings: byFingerprint.get(issueKey(issue)) ?? [],
|
|
150
150
|
});
|
|
151
151
|
}
|