@secureport/core 1.0.0 → 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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refingerprint.d.ts","sourceRoot":"","sources":["../src/refingerprint.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEvE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAExC;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAAC;IAC/C,SAAS,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAAC;CACxC;AAED,sCAAsC;AACtC,eAAO,MAAM,gBAAgB,EAAE,oBAI9B,CAAC;AAEF,0EAA0E;AAC1E,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CACvC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,oBAAoB,GAAG,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,aAAa,CAAC;AAE9F,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,oBAAoB,CAAC;IACvC,sFAAsF;IACtF,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC,CAAC;CACjE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,SAAS,iBAAiB,EAAE,EACnC,SAAS,GAAE,oBAAuC,GACjD,iBAAiB,CA+GnB"}
@@ -0,0 +1,190 @@
1
+ import { FINGERPRINT_VERSION, fingerprint, vulnKey } from './fingerprint.js';
2
+ /** The algorithm this build ships. */
3
+ export const currentAlgorithm = {
4
+ version: FINGERPRINT_VERSION,
5
+ fingerprintOf: fingerprint,
6
+ vulnKeyOf: vulnKey,
7
+ };
8
+ /**
9
+ * Works out what re-fingerprinting every issue would do, and writes nothing.
10
+ *
11
+ * **Recomputed from inputs, never by comparing stored strings.** A stored
12
+ * `fp_v1` fingerprint and a stored `fp_v2` one are not comparable — that is
13
+ * the whole reason `reconcile()` refuses to run across versions (B45) — so
14
+ * the only honest question is "what does the new algorithm say about the same
15
+ * evidence?". That means re-deriving `vulnKey` too: B39's change was to the
16
+ * `vulnKey` fallback, and reading the stored value would miss precisely the
17
+ * class of change this migration exists for.
18
+ *
19
+ * **A manual issue is keyed from its own columns, evidence or not.** Findings
20
+ * can be attached to a human-raised issue after the fact; 24 of the 404 manual
21
+ * issues in the development database are. Re-deriving those from the evidence
22
+ * would replace the human's `vulnKey` with the scanner's and merge the issue
23
+ * into whichever upload-origin issue already holds that key — losing the
24
+ * issue a person raised, in a migration nobody expected to lose anything.
25
+ *
26
+ * **An issue an earlier merge retired keeps the key it has**, and this is what
27
+ * makes the plan reach a fixed point. A merge copies the duplicate's evidence
28
+ * to the survivor without removing it from the duplicate (B98), so re-deriving
29
+ * a retired issue from its evidence produces the survivor's key and proposes
30
+ * the merge that already happened — every run, forever. Checked before the
31
+ * manual rule, because an issue can be both and retirement is the later
32
+ * statement about its identity.
33
+ *
34
+ * **Findings are never touched, and this never needs them to be.** A finding
35
+ * is evidence (invariant 1) and `findings` rejects `UPDATE` at the database
36
+ * (B66); its `fingerprint_version` records which algorithm produced it and
37
+ * that stays true forever. Only `issues` are re-keyed. B69, 8 September.
38
+ *
39
+ * **A finding whose key cannot be re-derived makes its issue `undecidable`,
40
+ * and does not stop the report.** `vulnKey()` throws when a finding carries
41
+ * no mapped rule, CWE, category or engine rule id — so a row that was never
42
+ * produced by an importer at this version (or was produced by a different
43
+ * one) has no answer. Reporting that is the useful behaviour: a migration
44
+ * plan that dies on one row tells an operator nothing about the other
45
+ * thousand, and guessing a key from the stored value would defeat the whole
46
+ * purpose, since re-deriving `vulnKey` is the point.
47
+ */
48
+ export function planRefingerprint(input, algorithm = currentAlgorithm) {
49
+ const recomputed = input.map(({ issue, findings }) => {
50
+ // Retirement is checked first, and the order is load-bearing. An issue an
51
+ // earlier merge retired is not re-keyed at all: its identity moved to the
52
+ // survivor, and its evidence — which the merge copied to that survivor —
53
+ // would re-derive the survivor's key and propose merging the two all over
54
+ // again, so the plan would never reach a fixed point. A *manual* issue
55
+ // that was later merged away is both things at once, and retirement is
56
+ // the later statement about its identity, so it wins. Measured: with the
57
+ // branches the other way round, 95 issues reported `moved` on every run
58
+ // while the executor left them alone on every run, forever.
59
+ if (issue.status === 'ignored' && issue.ignoreReason === 'duplicate') {
60
+ return { issue, evidence: findings.length, undecidable: false, to: [issue.fingerprint] };
61
+ }
62
+ // A manual issue is keyed from its own columns: its `vulnKey` and location
63
+ // were declared by a person, and evidence linked afterwards corroborates
64
+ // that issue rather than redefining it. Re-deriving would replace the
65
+ // human's key with the scanner's and dissolve the issue into whichever
66
+ // upload-origin issue already holds it.
67
+ //
68
+ // A *merge survivor* is deliberately not handled here, though it is also
69
+ // an identity a person asserted. Keying one from its own columns carries a
70
+ // stale `vulnKey` — written before the mapping table knew the engine's
71
+ // rule — so the migrated issue stops matching what the importer now
72
+ // produces, auto-resolves and comes back as new. `gatherForOrg` excludes
73
+ // the absorbed evidence instead, so a survivor re-derives correctly from
74
+ // what was always its own.
75
+ if (issue.origin === 'manual') {
76
+ return {
77
+ issue,
78
+ evidence: findings.length,
79
+ undecidable: false,
80
+ to: [fromOwnColumns(issue, algorithm)],
81
+ };
82
+ }
83
+ const keys = findings.map((f) => fingerprintOfFinding(issue, f, algorithm));
84
+ return {
85
+ issue,
86
+ evidence: findings.length,
87
+ undecidable: keys.some((k) => k === undefined),
88
+ to: [...new Set(keys.filter((k) => k !== undefined))].sort(),
89
+ };
90
+ });
91
+ // A scanner-origin issue whose evidence has since been detached has nothing
92
+ // left to re-derive from, so it falls back to its own columns rather than
93
+ // vanishing from the report.
94
+ for (const row of recomputed) {
95
+ if (row.to.length === 0 && !row.undecidable) {
96
+ row.to = [fromOwnColumns(row.issue, algorithm)];
97
+ }
98
+ }
99
+ const owners = new Map();
100
+ for (const row of recomputed) {
101
+ if (row.undecidable)
102
+ continue;
103
+ for (const fp of row.to) {
104
+ const list = owners.get(fp);
105
+ if (list)
106
+ list.push(row.issue.id);
107
+ else
108
+ owners.set(fp, [row.issue.id]);
109
+ }
110
+ }
111
+ const counts = {
112
+ unchanged: 0,
113
+ moved: 0,
114
+ merged: 0,
115
+ split: 0,
116
+ undecidable: 0,
117
+ };
118
+ const issues = recomputed.map(({ issue, evidence, to, undecidable }) => {
119
+ const mergesWith = [
120
+ ...new Set(to.flatMap((fp) => owners.get(fp) ?? []).filter((id) => id !== issue.id)),
121
+ ].sort();
122
+ // Split first: an issue that fragments is the most disruptive outcome
123
+ // and stays visible even when one of its fragments also collides.
124
+ const outcome = undecidable
125
+ ? 'undecidable'
126
+ : to.length > 1
127
+ ? 'split'
128
+ : mergesWith.length > 0
129
+ ? 'merged'
130
+ : to[0] === issue.fingerprint
131
+ ? 'unchanged'
132
+ : 'moved';
133
+ counts[outcome] += 1;
134
+ return {
135
+ issueId: issue.id,
136
+ targetId: issue.targetId,
137
+ title: issue.title,
138
+ from: issue.fingerprint,
139
+ fromVersion: issue.fingerprintVersion,
140
+ to,
141
+ outcome,
142
+ mergesWith,
143
+ evidence,
144
+ };
145
+ });
146
+ const fromVersions = [...new Set(input.map(({ issue }) => issue.fingerprintVersion))].sort();
147
+ return {
148
+ fromVersion: fromVersions.join(', ') || algorithm.version,
149
+ toVersion: algorithm.version,
150
+ issues,
151
+ counts,
152
+ };
153
+ }
154
+ /** An issue's fingerprint at the new version, from the issue's own columns. */
155
+ function fromOwnColumns(issue, algorithm) {
156
+ return algorithm.fingerprintOf({
157
+ targetId: issue.targetId,
158
+ vulnKey: issue.vulnKey,
159
+ location: issue.location,
160
+ ...(issue.parameter === undefined ? {} : { parameter: issue.parameter }),
161
+ });
162
+ }
163
+ /**
164
+ * One finding's fingerprint at the new version, `vulnKey` re-derived and all,
165
+ * or `undefined` when the key cannot be derived from what the finding holds.
166
+ */
167
+ function fingerprintOfFinding(issue, finding, algorithm) {
168
+ let key;
169
+ try {
170
+ key = algorithm.vulnKeyOf({
171
+ sourceEngine: finding.sourceEngine,
172
+ ...(finding.sourceRuleId === undefined ? {} : { sourceRuleId: finding.sourceRuleId }),
173
+ ...(finding.cwe === undefined ? {} : { cwe: finding.cwe }),
174
+ ...(finding.category === undefined ? {} : { category: finding.category }),
175
+ });
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ return algorithm.fingerprintOf({
181
+ // The target is the issue's: a finding records the run it came from, and
182
+ // every run of an issue's evidence is against the same target anyway.
183
+ targetId: issue.targetId,
184
+ vulnKey: key,
185
+ location: finding.location,
186
+ ...(finding.parameter === undefined ? {} : { parameter: finding.parameter }),
187
+ ...(finding.port === undefined ? {} : { port: finding.port }),
188
+ });
189
+ }
190
+ //# sourceMappingURL=refingerprint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refingerprint.js","sourceRoot":"","sources":["../src/refingerprint.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAqB7E,sCAAsC;AACtC,MAAM,CAAC,MAAM,gBAAgB,GAAyB;IACpD,OAAO,EAAE,mBAAmB;IAC5B,aAAa,EAAE,WAAW;IAC1B,SAAS,EAAE,OAAO;CACnB,CAAC;AA0DF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,MAAM,UAAU,iBAAiB,CAC/B,KAAmC,EACnC,YAAkC,gBAAgB;IAElD,MAAM,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE;QACnD,0EAA0E;QAC1E,0EAA0E;QAC1E,yEAAyE;QACzE,0EAA0E;QAC1E,uEAAuE;QACvE,uEAAuE;QACvE,yEAAyE;QACzE,wEAAwE;QACxE,4DAA4D;QAC5D,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,IAAI,KAAK,CAAC,YAAY,KAAK,WAAW,EAAE,CAAC;YACrE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,EAAE,CAAC;QAC3F,CAAC;QAED,2EAA2E;QAC3E,yEAAyE;QACzE,sEAAsE;QACtE,uEAAuE;QACvE,wCAAwC;QACxC,EAAE;QACF,yEAAyE;QACzE,2EAA2E;QAC3E,uEAAuE;QACvE,oEAAoE;QACpE,yEAAyE;QACzE,yEAAyE;QACzE,2BAA2B;QAC3B,IAAI,KAAK,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC9B,OAAO;gBACL,KAAK;gBACL,QAAQ,EAAE,QAAQ,CAAC,MAAM;gBACzB,WAAW,EAAE,KAAK;gBAClB,EAAE,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;aACvC,CAAC;QACJ,CAAC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,oBAAoB,CAAC,KAAK,EAAE,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;QAC5E,OAAO;YACL,KAAK;YACL,QAAQ,EAAE,QAAQ,CAAC,MAAM;YACzB,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC;YAC9C,EAAE,EAAE,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE;SAC1E,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,4EAA4E;IAC5E,0EAA0E;IAC1E,6BAA6B;IAC7B,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;QAC7B,IAAI,GAAG,CAAC,EAAE,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;YAC5C,GAAG,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC;QAClD,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC3C,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;QAC7B,IAAI,GAAG,CAAC,WAAW;YAAE,SAAS;QAC9B,KAAK,MAAM,EAAE,IAAI,GAAG,CAAC,EAAE,EAAE,CAAC;YACxB,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAC5B,IAAI,IAAI;gBAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;;gBAC7B,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAyC;QACnD,SAAS,EAAE,CAAC;QACZ,KAAK,EAAE,CAAC;QACR,MAAM,EAAE,CAAC;QACT,KAAK,EAAE,CAAC;QACR,WAAW,EAAE,CAAC;KACf,CAAC;IAEF,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,WAAW,EAAE,EAAa,EAAE;QAChF,MAAM,UAAU,GAAG;YACjB,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,CAAC,CAAC;SACrF,CAAC,IAAI,EAAE,CAAC;QAET,sEAAsE;QACtE,kEAAkE;QAClE,MAAM,OAAO,GAAyB,WAAW;YAC/C,CAAC,CAAC,aAAa;YACf,CAAC,CAAC,EAAE,CAAC,MAAM,GAAG,CAAC;gBACb,CAAC,CAAC,OAAO;gBACT,CAAC,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC;oBACrB,CAAC,CAAC,QAAQ;oBACV,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,WAAW;wBAC3B,CAAC,CAAC,WAAW;wBACb,CAAC,CAAC,OAAO,CAAC;QAElB,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACrB,OAAO;YACL,OAAO,EAAE,KAAK,CAAC,EAAE;YACjB,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,IAAI,EAAE,KAAK,CAAC,WAAW;YACvB,WAAW,EAAE,KAAK,CAAC,kBAAkB;YACrC,EAAE;YACF,OAAO;YACP,UAAU;YACV,QAAQ;SACT,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,MAAM,YAAY,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC7F,OAAO;QACL,WAAW,EAAE,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,OAAO;QACzD,SAAS,EAAE,SAAS,CAAC,OAAO;QAC5B,MAAM;QACN,MAAM;KACP,CAAC;AACJ,CAAC;AAED,+EAA+E;AAC/E,SAAS,cAAc,CAAC,KAAY,EAAE,SAA+B;IACnE,OAAO,SAAS,CAAC,aAAa,CAAC;QAC7B,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,GAAG,CAAC,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC;KACzE,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,SAAS,oBAAoB,CAC3B,KAAY,EACZ,OAAgB,EAChB,SAA+B;IAE/B,IAAI,GAAG,CAAC;IACR,IAAI,CAAC;QACH,GAAG,GAAG,SAAS,CAAC,SAAS,CAAC;YACxB,YAAY,EAAE,OAAO,CAAC,YAAY;YAClC,GAAG,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC;YACrF,GAAG,CAAC,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC;YAC1D,GAAG,CAAC,OAAO,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC;SAC1E,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,SAAS,CAAC,aAAa,CAAC;QAC7B,yEAAyE;QACzE,sEAAsE;QACtE,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,OAAO,EAAE,GAAG;QACZ,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,GAAG,CAAC,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC;QAC5E,GAAG,CAAC,OAAO,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC;KAC9D,CAAC,CAAC;AACL,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureport/core",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Shared domain model for Secureport. Imported by the hosted API the same way a third party would.",
5
5
  "keywords": [
6
6
  "security",
package/src/finding.ts CHANGED
@@ -94,15 +94,16 @@ export interface Finding {
94
94
  *
95
95
  * **Recorded, not addressed.** The port is already inside
96
96
  * {@link Finding.location} for host-oriented engines (Nessus assembles
97
- * `host:port`) and is already a fingerprint component, so this field changes
98
- * neither. It exists because a port was otherwise unrecoverable from a
99
- * finding except by parsing its location, which is not something a consumer
100
- * should have to do.
97
+ * `host:port`), and that location is what the fingerprint reads. It exists
98
+ * because a port was otherwise unrecoverable from a finding except by
99
+ * parsing its location, which is not something a consumer should have to do.
101
100
  *
102
- * Deliberately **not** added to the fingerprint inputs: doing so would move
103
- * every existing Nessus fingerprint and force a re-fingerprint migration,
104
- * which is a far worse outcome than a field being merely inconvenient to
105
- * read.
101
+ * **No longer a fingerprint component at all, as of `fp_v2`.** It used to
102
+ * fill the last slot when no parameter was named, which counted a Nessus
103
+ * port twice and stopped a network scan ever matching a web scan of the same
104
+ * endpoint. The location carries the port now, canonicalised — so a default
105
+ * port drops and any other is kept, which is the distinction that actually
106
+ * matters.
106
107
  */
107
108
  readonly port?: number;
108
109
 
@@ -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 = 'fp_v1';
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
- * Anything that is not a parseable absolute URL — a bare host, a file path, a
84
- * `host:port` pair from a network scan — is normalised as a path alone. That is
85
- * deliberate: refusing to fingerprint a non-HTTP finding would exclude whole
86
- * classes of scanner from the model.
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
- return normalisePathOnly(trimmed);
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
- const tail = input.parameter ?? (input.port !== undefined ? String(input.port) : '');
310
- const material = [input.targetId, input.vulnKey, normaliseLocation(input.location), tail].join(
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
+ }
@@ -28,12 +28,15 @@ const NESSUS_SEVERITY: Readonly<Record<string, Severity>> = Object.freeze({
28
28
  * fingerprints distinctly per service.
29
29
  *
30
30
  * The port is also recorded on {@link Finding.port} (**B42**), which is a
31
- * record and nothing more. The location assembly and the fingerprint inputs are
32
- * deliberately unchanged: the fingerprint's last component is
33
- * `parameter ?? port ?? ''`, so a Nessus port is still counted twice — once
34
- * inside the location and once as that component. That double-count is
35
- * harmless and **must stay**, because removing it would move every existing
36
- * Nessus fingerprint and force a re-fingerprint migration.
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.
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,
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
- if (issue.status === 'open' || issue.status === 'regressed') {
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 = {