@secureport/core 0.2.0 → 0.3.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 (60) hide show
  1. package/README.md +67 -31
  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/nuclei.d.ts +39 -0
  15. package/dist/import/nuclei.d.ts.map +1 -0
  16. package/dist/import/nuclei.js +115 -0
  17. package/dist/import/nuclei.js.map +1 -0
  18. package/dist/import/zap.d.ts +26 -0
  19. package/dist/import/zap.d.ts.map +1 -0
  20. package/dist/import/zap.js +119 -0
  21. package/dist/import/zap.js.map +1 -0
  22. package/dist/index.d.ts +31 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +22 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/issue.d.ts +190 -11
  27. package/dist/issue.d.ts.map +1 -1
  28. package/dist/reconcile.d.ts +115 -10
  29. package/dist/reconcile.d.ts.map +1 -1
  30. package/dist/reconcile.js +306 -12
  31. package/dist/reconcile.js.map +1 -1
  32. package/dist/run.d.ts +134 -0
  33. package/dist/run.d.ts.map +1 -0
  34. package/dist/run.js +2 -0
  35. package/dist/run.js.map +1 -0
  36. package/dist/severity.d.ts +191 -0
  37. package/dist/severity.d.ts.map +1 -0
  38. package/dist/severity.js +171 -0
  39. package/dist/severity.js.map +1 -0
  40. package/dist/snapshot-builder.d.ts +70 -0
  41. package/dist/snapshot-builder.d.ts.map +1 -0
  42. package/dist/snapshot-builder.js +148 -0
  43. package/dist/snapshot-builder.js.map +1 -0
  44. package/dist/snapshot.d.ts +125 -0
  45. package/dist/snapshot.d.ts.map +1 -0
  46. package/dist/snapshot.js +2 -0
  47. package/dist/snapshot.js.map +1 -0
  48. package/package.json +23 -2
  49. package/src/coverage.ts +65 -0
  50. package/src/finding.ts +112 -0
  51. package/src/fingerprint.ts +315 -0
  52. package/src/import/nuclei.ts +173 -0
  53. package/src/import/zap.ts +161 -0
  54. package/src/index.ts +56 -2
  55. package/src/issue.ts +244 -11
  56. package/src/reconcile.ts +421 -17
  57. package/src/run.ts +163 -0
  58. package/src/severity.ts +250 -0
  59. package/src/snapshot-builder.ts +199 -0
  60. package/src/snapshot.ts +146 -0
package/README.md CHANGED
@@ -6,17 +6,17 @@ model, one implementation.
6
6
 
7
7
  **MIT licensed. Zero runtime dependencies.**
8
8
 
9
- ## Status: early scaffold
9
+ ## The model in one paragraph
10
10
 
11
- `0.2.x` is a deliberately small first release, published to prove the release
12
- path rather than to be built against. **The domain shapes below are being
13
- replaced wholesale in `0.3.0`** with the full model — `Finding`, `Issue`,
14
- `Run`, `RunSummary`, `Snapshot` and `Coverage`, content-addressed fingerprints,
15
- a CVSS-derived severity model, and a `reconcile()` that matches findings to
16
- issues by fingerprint rather than by title.
11
+ A **run** produces **findings**. Findings are immutable evidence: one detection,
12
+ in one run, with no status of its own. Each finding is fingerprinted and
13
+ reconciled into an **issue** — the tracked record that persists across runs and
14
+ carries status, severity, age and history. A **snapshot** is issue state as of a
15
+ run, and is the only thing a report ever reads.
17
16
 
18
- If you are evaluating Secureport, wait for `0.3.0`. If you depend on `0.2.x`
19
- anyway, pin it exactly.
17
+ The tracked entity is the **issue**, not the finding. Two scanners reporting the
18
+ same weakness produce one issue with two sources, not two rows; an issue that
19
+ comes back after being fixed is a regression, not a discovery.
20
20
 
21
21
  ## Install
22
22
 
@@ -24,36 +24,72 @@ anyway, pin it exactly.
24
24
  npm install @secureport/core
25
25
  ```
26
26
 
27
- ## Usage
27
+ ## Status
28
+
29
+ `0.3.x` ships the domain types, the severity model and the SLA policy.
30
+ Still to come, in this order: the fingerprint, `reconcile()`, `SnapshotBuilder`
31
+ and the scanner importers. Until `reconcile()` lands, this package describes the
32
+ model rather than computing it.
33
+
34
+ `0.2.x` had a placeholder `reconcile()` that matched issues by title. It is gone
35
+ rather than deprecated — matching by title is not a simplified version of
36
+ matching by fingerprint, it is a different and wrong answer.
37
+
38
+ ## Severity
39
+
40
+ Five levels, ordered: `critical` · `high` · `medium` · `low` · `advisory`.
41
+
42
+ Compare them with `severityRank`, never as strings — sorting the strings puts
43
+ `advisory` first.
28
44
 
29
45
  ```ts
30
- import { reconcile, type Finding, type Issue } from '@secureport/core';
46
+ import { severityFromCvss, severityRank, SEVERITY_WEIGHTS } from '@secureport/core';
47
+
48
+ severityFromCvss(9.8); // 'critical' — CVSS v3.1 rating scale, unchanged
49
+ severityFromCvss(0); // 'advisory' — CVSS calls this "None"
50
+
51
+ severityRank('critical') > severityRank('high'); // true
52
+ SEVERITY_WEIGHTS.advisory; // 0 — advisories never inflate the exposure score
53
+ ```
31
54
 
32
- const open: Issue[] = [
33
- { id: 'ISS-1', title: 'Missing HSTS header', severity: 'medium', status: 'open' },
34
- ];
55
+ `severitySource` records _why_ a severity was assigned — `explicit`, `cvss`,
56
+ `engine_default` or `advisory`, in that precedence order. A severity with no
57
+ provenance is not evidence.
35
58
 
36
- const findings: Finding[] = [
37
- { title: 'Missing HSTS header', severity: 'medium' },
38
- { title: 'Directory listing enabled', severity: 'low' },
39
- ];
59
+ ## Remediation deadlines
40
60
 
41
- let n = 2;
42
- const issues = reconcile(findings, open, () => `ISS-${n++}`);
43
- // ISS-1 is reused for the HSTS finding; ISS-2 opens for the directory listing.
61
+ `sla_due_at` derives from an issue's **effective** severity, and both helpers
62
+ are pure — neither reads the clock.
63
+
64
+ ```ts
65
+ import { slaDueAt, slaStatus, DEFAULT_SLA_POLICY } from '@secureport/core';
66
+
67
+ const due = slaDueAt('high', issue.firstSeen); // 30 days after firstSeen
68
+ slaStatus(due, asOf); // 'within' | 'due_soon' | 'breached'
44
69
  ```
45
70
 
46
- ## The model in one paragraph
71
+ Windows default to 7 / 30 / 90 / 180 days for critical through low, and
72
+ **advisory has no deadline at all** — a zero-weight severity should not
73
+ manufacture breaches. Pass your own `SlaPolicy` to override; the defaults are
74
+ this package's opinion, not the domain's.
47
75
 
48
- The tracked entity is the **issue**, not the finding. A **run** is one
49
- execution against a target. A **finding** is one detection within one run and
50
- is immutable — it never carries a status. Findings reconcile into **issues**,
51
- which persist across runs and carry status, severity and history. That
52
- distinction is the whole point of the package: two scanners reporting the same
53
- weakness produce one issue with two sources, not two rows.
76
+ Deadlines run from `firstSeen`, which is never reset — so a regression does not
77
+ give a year-old problem a fresh clock.
78
+
79
+ ## Reports
80
+
81
+ Every report template consumes a `Snapshot` and nothing else: no database
82
+ handle, no network, no clock. That is what makes a report reproducible, testable
83
+ against golden files, and renderable by someone with findings on disk and no
84
+ account.
85
+
86
+ The snapshot is the artefact; a PDF is a view of it.
54
87
 
55
88
  ## Contracts
56
89
 
57
- Every export is a semver contract. From `0.3.0`, `fingerprint_version` and the
58
- report JSON schema are versioned public contracts in their own right, and a
59
- deprecation policy lands before `1.0.0`.
90
+ Every export is a semver contract. `fingerprintVersion` travels with every
91
+ fingerprint everywhere, so a stored fingerprint can always be interpreted, and
92
+ changing the algorithm means a migration that preserves history rather than a
93
+ silent recomputation.
94
+
95
+ A deprecation policy lands before `1.0.0`.
@@ -0,0 +1,21 @@
1
+ import type { Coverage } from './run.js';
2
+ /**
3
+ * Whether a run could have found something at this location.
4
+ *
5
+ * **This is what makes auto-resolution safe.** A run only resolves what it
6
+ * could have found (invariant 5): a quick profile that skipped `/admin` must
7
+ * not close an `/admin` issue, because not looking is not the same as not
8
+ * finding. Anything outside coverage is left entirely alone — not resolved, and
9
+ * not counted as a miss either.
10
+ *
11
+ * A location matches when it matches at least one covered path glob, **and**,
12
+ * where both the location and the coverage state a port, that port was
13
+ * exercised. The port rule errs towards leaving issues open: a scan of `:443`
14
+ * says nothing about `:8443`.
15
+ *
16
+ * @param location - The issue's location.
17
+ * @param coverage - What the run exercised.
18
+ * @returns `true` if the run could have found it.
19
+ */
20
+ export declare function coversLocation(location: string, coverage: Coverage): boolean;
21
+ //# sourceMappingURL=coverage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../src/coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAwCzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAM5E"}
@@ -0,0 +1,65 @@
1
+ /** Escapes a literal so it can sit inside a regular expression. */
2
+ function escapeLiteral(text) {
3
+ return text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
4
+ }
5
+ /**
6
+ * Compiles a coverage glob into a regular expression.
7
+ *
8
+ * `**` matches anything including `/`; `*` matches anything except `/`, so
9
+ * `https://a.example/*` covers `/orders` but not `/orders/123`; `?` matches one
10
+ * character. Everything else is literal.
11
+ */
12
+ function globToRegExp(glob) {
13
+ let out = '';
14
+ for (let i = 0; i < glob.length; i++) {
15
+ const char = glob[i];
16
+ if (char === '*') {
17
+ if (glob[i + 1] === '*') {
18
+ out += '.*';
19
+ i++;
20
+ }
21
+ else {
22
+ out += '[^/]*';
23
+ }
24
+ }
25
+ else if (char === '?') {
26
+ out += '[^/]';
27
+ }
28
+ else {
29
+ out += escapeLiteral(char);
30
+ }
31
+ }
32
+ return new RegExp(`^${out}$`, 'u');
33
+ }
34
+ /** The port in a location, where it states one. */
35
+ function portOf(location) {
36
+ const match = /^[a-z][a-z0-9+.-]*:\/\/[^/]*?:(\d+)(?:[/?#]|$)/iu.exec(location);
37
+ return match ? Number(match[1]) : undefined;
38
+ }
39
+ /**
40
+ * Whether a run could have found something at this location.
41
+ *
42
+ * **This is what makes auto-resolution safe.** A run only resolves what it
43
+ * could have found (invariant 5): a quick profile that skipped `/admin` must
44
+ * not close an `/admin` issue, because not looking is not the same as not
45
+ * finding. Anything outside coverage is left entirely alone — not resolved, and
46
+ * not counted as a miss either.
47
+ *
48
+ * A location matches when it matches at least one covered path glob, **and**,
49
+ * where both the location and the coverage state a port, that port was
50
+ * exercised. The port rule errs towards leaving issues open: a scan of `:443`
51
+ * says nothing about `:8443`.
52
+ *
53
+ * @param location - The issue's location.
54
+ * @param coverage - What the run exercised.
55
+ * @returns `true` if the run could have found it.
56
+ */
57
+ export function coversLocation(location, coverage) {
58
+ const port = portOf(location);
59
+ if (port !== undefined && coverage.ports !== undefined && coverage.ports.length > 0) {
60
+ if (!coverage.ports.includes(port))
61
+ return false;
62
+ }
63
+ return coverage.paths.some((glob) => globToRegExp(glob).test(location));
64
+ }
65
+ //# sourceMappingURL=coverage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coverage.js","sourceRoot":"","sources":["../src/coverage.ts"],"names":[],"mappings":"AAEA,mEAAmE;AACnE,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,CAAC,OAAO,CAAC,sBAAsB,EAAE,MAAM,CAAC,CAAC;AACtD,CAAC;AAED;;;;;;GAMG;AACH,SAAS,YAAY,CAAC,IAAY;IAChC,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACrB,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;YACjB,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;gBACxB,GAAG,IAAI,IAAI,CAAC;gBACZ,CAAC,EAAE,CAAC;YACN,CAAC;iBAAM,CAAC;gBACN,GAAG,IAAI,OAAO,CAAC;YACjB,CAAC;QACH,CAAC;aAAM,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;YACxB,GAAG,IAAI,MAAM,CAAC;QAChB,CAAC;aAAM,CAAC;YACN,GAAG,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC;IACD,OAAO,IAAI,MAAM,CAAC,IAAI,GAAG,GAAG,EAAE,GAAG,CAAC,CAAC;AACrC,CAAC;AAED,mDAAmD;AACnD,SAAS,MAAM,CAAC,QAAgB;IAC9B,MAAM,KAAK,GAAG,kDAAkD,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChF,OAAO,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgB,EAAE,QAAkB;IACjE,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC9B,IAAI,IAAI,KAAK,SAAS,IAAI,QAAQ,CAAC,KAAK,KAAK,SAAS,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpF,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;IACnD,CAAC;IACD,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC1E,CAAC"}
@@ -0,0 +1,89 @@
1
+ import type { Severity, SeveritySource } from './severity.js';
2
+ /**
3
+ * One detection, in one run.
4
+ *
5
+ * **Findings are immutable evidence.** A finding is never updated after it is
6
+ * recorded (invariant 1) and it carries no status: whether something is open,
7
+ * fixed or accepted is a property of the {@link Issue} it reconciles into, not
8
+ * of the evidence for it. If a field here would need to change as a human works
9
+ * on the problem, it belongs on the issue instead.
10
+ *
11
+ * Many findings, from many runs and many engines, fold into one issue by
12
+ * {@link Finding.fingerprint}.
13
+ */
14
+ export interface Finding {
15
+ /** Unique id for this detection. */
16
+ readonly id: string;
17
+ /** Organisation this finding belongs to. Every query carries it. */
18
+ readonly orgId: string;
19
+ /** The run that produced it. */
20
+ readonly runId: string;
21
+ /**
22
+ * Content address of the underlying weakness, used to reconcile findings
23
+ * into a single issue across runs and engines.
24
+ *
25
+ * Derived from the target, the {@link Finding.vulnKey}, the normalised
26
+ * location and the parameter — never from volatile evidence such as tokens,
27
+ * timestamps or response bodies, which would make every run look new.
28
+ */
29
+ readonly fingerprint: string;
30
+ /**
31
+ * Which fingerprint algorithm produced {@link Finding.fingerprint}.
32
+ *
33
+ * **A public contract.** It travels with every fingerprint everywhere
34
+ * (invariant 8) so a stored fingerprint can always be interpreted, and
35
+ * changing the algorithm means a re-fingerprint migration that preserves
36
+ * history rather than a silent recomputation.
37
+ */
38
+ readonly fingerprintVersion: string;
39
+ /** Short human-readable name for the weakness. */
40
+ readonly title: string;
41
+ /** Fuller explanation of what was detected. */
42
+ readonly description?: string;
43
+ /**
44
+ * Severity as detected for this finding.
45
+ *
46
+ * What the evidence says. The issue's `effectiveSeverity` may differ, because
47
+ * a human may have overridden it.
48
+ */
49
+ readonly detectedSeverity: Severity;
50
+ /** Why {@link Finding.detectedSeverity} is what it is. */
51
+ readonly severitySource: SeveritySource;
52
+ /** CVSS base score, where the engine or advisory supplied one. */
53
+ readonly cvssScore?: number;
54
+ /** CVSS vector string, where one was supplied. */
55
+ readonly cvssVector?: string;
56
+ /** CWE identifier, e.g. `CWE-79`. */
57
+ readonly cwe?: string;
58
+ /** CVE identifier, e.g. `CVE-2026-1234`. */
59
+ readonly cve?: string;
60
+ /**
61
+ * Engine-independent key for the weakness class.
62
+ *
63
+ * This is what makes a Nuclei detection and a ZAP detection of the same
64
+ * weakness collide into one issue. Without it there is no cross-engine
65
+ * deduplication, only per-engine.
66
+ */
67
+ readonly vulnKey: string;
68
+ /** Broad grouping for reports, e.g. `injection`, `tls`, `access-control`. */
69
+ readonly category?: string;
70
+ /** Where it was found — a URL, host, port or file path. */
71
+ readonly location: string;
72
+ /** The specific parameter implicated, where the weakness has one. */
73
+ readonly parameter?: string;
74
+ /** Pointers to stored evidence: request/response captures, screenshots. */
75
+ readonly evidenceUri?: readonly string[];
76
+ /** What to do about it. */
77
+ readonly recommendation?: string;
78
+ /** External reading: advisories, vendor bulletins, standards. */
79
+ readonly references?: readonly string[];
80
+ /** Which engine detected it, e.g. `nuclei`, `zap`, `burp`, `nessus`. */
81
+ readonly sourceEngine: string;
82
+ /** The engine's own identifier for the rule that fired. */
83
+ readonly sourceRuleId?: string;
84
+ /** The engine's confidence, where it reports one, from 0 to 1. */
85
+ readonly confidence?: number;
86
+ /** When the finding was recorded. */
87
+ readonly createdAt: Date;
88
+ }
89
+ //# sourceMappingURL=finding.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finding.d.ts","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE9D;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,OAAO;IACtB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,gCAAgC;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB;;;;;;;OAOG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAE7B;;;;;;;OAOG;IACH,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IAEpC,kDAAkD;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,+CAA+C;IAC/C,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B;;;;;OAKG;IACH,QAAQ,CAAC,gBAAgB,EAAE,QAAQ,CAAC;IAEpC,0DAA0D;IAC1D,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IAExC,kEAAkE;IAClE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAE5B,kDAAkD;IAClD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAE7B,qCAAqC;IACrC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAEtB,4CAA4C;IAC5C,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAE3B,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,qEAAqE;IACrE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAE5B,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAEzC,2BAA2B;IAC3B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAEjC,iEAAiE;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAExC,wEAAwE;IACxE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAE9B,2DAA2D;IAC3D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAE/B,kEAAkE;IAClE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAE7B,qCAAqC;IACrC,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;CAC1B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=finding.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finding.js","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":""}
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Which fingerprint algorithm this build of the package implements.
3
+ *
4
+ * **A public contract, and the most consequential string in the package.** It
5
+ * is stored on every finding and every issue, everywhere, so that a fingerprint
6
+ * recorded a year ago can still be interpreted. Changing the algorithm means
7
+ * bumping this *and* running a per-organisation re-fingerprint migration that
8
+ * preserves history — never a silent recomputation, which would orphan every
9
+ * issue whose evidence no longer hashes to the same value.
10
+ *
11
+ * If you are tempted to "just tweak" the normaliser, that is this constant's
12
+ * job to prevent.
13
+ */
14
+ export declare const FINGERPRINT_VERSION = "fp_v1";
15
+ /**
16
+ * The placeholder that a variable path segment collapses to.
17
+ *
18
+ * Exported because it appears in normalised locations, which appear in reports
19
+ * and in support conversations: someone reading `/orders/{id}` should be able
20
+ * to find out what produced it.
21
+ */
22
+ export declare const PATH_PLACEHOLDER = "{id}";
23
+ /**
24
+ * Normalises a location so that the same weakness in the same place produces
25
+ * the same string, however the engine happened to write it down.
26
+ *
27
+ * This is the part of the fingerprint that decides whether `/orders/1` and
28
+ * `/orders/2` are one issue or two thousand. Applies, in order:
29
+ *
30
+ * - **Lowercases the host**, and converts an internationalised domain to
31
+ * punycode, so `HTTPS://例え.テスト/x` and `https://xn--r8jz45g.xn--zckzah/x`
32
+ * are one location.
33
+ * - **Drops a default port** (`:443` on https, `:80` on http) and keeps any
34
+ * other, because `:8443` is a different service and `:443` is not.
35
+ * - **Decodes percent-encoding once.**
36
+ * - **Collapses numeric and UUID path segments** to `{id}`, so a per-record URL
37
+ * does not open a per-record issue.
38
+ * - **Drops a trailing slash**, except on the root path where it is the path.
39
+ * - **Strips query values but keeps parameter names, sorted.** `?b=2&a=secret`
40
+ * becomes `?a&b`. The names are structure and belong in identity; the values
41
+ * are usually the payload that proved the weakness, which is evidence and
42
+ * must never reach a fingerprint. Sorting means parameter order cannot split
43
+ * one issue into two.
44
+ * - **Drops the fragment**, which the server never sees.
45
+ *
46
+ * Anything that is not a parseable absolute URL — a bare host, a file path, a
47
+ * `host:port` pair from a network scan — is normalised as a path alone. That is
48
+ * deliberate: refusing to fingerprint a non-HTTP finding would exclude whole
49
+ * classes of scanner from the model.
50
+ *
51
+ * @param location - Where the weakness was found.
52
+ * @returns The normalised location.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * normaliseLocation('HTTPS://API.Example.com:443/Orders/123/items/?b=2&a=secret#f');
57
+ * // 'https://api.example.com/Orders/{id}/items?a&b'
58
+ * ```
59
+ */
60
+ export declare function normaliseLocation(location: string): string;
61
+ /**
62
+ * What a scanner said it found, in the terms needed to name the weakness.
63
+ */
64
+ export interface VulnKeyInput {
65
+ /** Which engine detected it, e.g. `nuclei`, `zap`. */
66
+ readonly sourceEngine: string;
67
+ /** The engine's own identifier for the rule that fired. */
68
+ readonly sourceRuleId?: string;
69
+ /** CWE identifier, e.g. `CWE-79`. */
70
+ readonly cwe?: string;
71
+ /** Broad grouping, e.g. `injection`. */
72
+ readonly category?: string;
73
+ }
74
+ /**
75
+ * Maps `engine:ruleId` to a shared, engine-independent weakness key.
76
+ *
77
+ * **This table is the entire mechanism of cross-engine deduplication.** Without
78
+ * it a ZAP detection and a Nuclei detection of the same weakness fall back to
79
+ * CWE, and where either engine omits the CWE they never collide at all — you
80
+ * get per-engine tracking wearing the clothes of an issue tracker.
81
+ *
82
+ * **It is deliberately small, and that is not laziness.** A mapping asserts
83
+ * that a specific rule id means a specific weakness, and a wrong assertion
84
+ * silently merges two unrelated issues — worse than not mapping at all, because
85
+ * the merge is invisible. Rule ids cannot be known honestly until real scanner
86
+ * output has been parsed, which is what 4.4a and 4.4b do with committed
87
+ * fixtures. The table grows there, from evidence.
88
+ *
89
+ * **Entries must be added in pairs, per weakness, across engines — a half-filled
90
+ * table is worse than an empty one.** The mapping beats the CWE fallback, so
91
+ * mapping ZAP's HSTS rule while leaving Nuclei's unmapped gives them *different*
92
+ * keys, when falling back to `CWE-319` on both sides would have collided them
93
+ * correctly. Adding one engine's rule silently un-deduplicates the weakness.
94
+ * Found by importing a fixture from each engine and watching them stop
95
+ * agreeing.
96
+ *
97
+ * `00-DOMAIN.md` §10 leaves the eventual size open, leaning towards the top
98
+ * ~200 Nuclei templates plus ZAP's plugin list, with CWE fallback beyond.
99
+ *
100
+ * Keys are `${sourceEngine}:${sourceRuleId}`, both lowercased.
101
+ */
102
+ export declare const VULN_KEY_MAP: Readonly<Record<string, string>>;
103
+ /**
104
+ * The engine-independent key for a weakness class.
105
+ *
106
+ * Resolution order, per `00-DOMAIN.md` §4:
107
+ *
108
+ * 1. {@link VULN_KEY_MAP}, looked up by `engine:ruleId`.
109
+ * 2. `cwe` alone.
110
+ * 3. `category` alone.
111
+ * 4. `engine:ruleId` itself.
112
+ *
113
+ * **The spec says `cwe + '/' + category`, and that was tried and abandoned.**
114
+ * Engines do not share a category vocabulary: ZAP supplies no category at all,
115
+ * so reflected XSS there keys as `CWE-79`, while Nuclei tags the same finding
116
+ * `xss` and keys as `CWE-79/xss`. The two never collide — so the fallback
117
+ * actively prevented the cross-engine deduplication it exists to provide, which
118
+ * a fixture from each engine demonstrated immediately.
119
+ *
120
+ * CWE alone is coarser, and the coarseness is bounded: the fingerprint also
121
+ * carries the normalised location and the parameter, so two findings only merge
122
+ * when they share a weakness class *and* a place. Two genuinely different
123
+ * weaknesses under one CWE, at the same URL and parameter, is the case this
124
+ * gets wrong — and `POST /issues/{a}/merge/{b}` exists because something will.
125
+ *
126
+ * Steps 3 and 4 keep a finding trackable when there is no CWE at all. **Step 4
127
+ * never deduplicates across engines**, which is the honest outcome for a rule
128
+ * nobody has mapped: it tracks correctly and merges nothing it should not.
129
+ *
130
+ * @param input - What the engine reported.
131
+ * @returns The weakness key.
132
+ * @throws TypeError If nothing identifying is present at all.
133
+ */
134
+ export declare function vulnKey(input: VulnKeyInput): string;
135
+ /**
136
+ * Everything the fingerprint is computed from.
137
+ *
138
+ * Note what is absent: no title, no description, no evidence, no timestamp, no
139
+ * severity. **Volatile evidence never enters a fingerprint** — a nonce or a
140
+ * response body in here would make every run look like a fresh discovery, and
141
+ * the product's whole claim is that it can tell you what changed.
142
+ */
143
+ export interface FingerprintInput {
144
+ /**
145
+ * The target the finding is on.
146
+ *
147
+ * Part of identity, which has a surprising consequence worth stating: the
148
+ * same weakness in staging and in production is **two issues**. They are two
149
+ * systems, fixed separately, and a verification of one must never authorise
150
+ * the other.
151
+ */
152
+ readonly targetId: string;
153
+ /** The engine-independent weakness key, from {@link vulnKey}. */
154
+ readonly vulnKey: string;
155
+ /** Where it was found. Normalised by {@link normaliseLocation}. */
156
+ readonly location: string;
157
+ /** The parameter implicated, where the weakness has one. */
158
+ readonly parameter?: string;
159
+ /** The port, for findings that are about a service rather than a path. */
160
+ readonly port?: number;
161
+ }
162
+ /**
163
+ * The content address of a weakness: `sha256(targetId | vulnKey | normalised
164
+ * location | parameter ?? port ?? '')`.
165
+ *
166
+ * Deterministic and pure — the same input always gives the same digest, on any
167
+ * machine, in any order, at any time. That is what lets findings from different
168
+ * runs and different engines reconcile into one issue.
169
+ *
170
+ * Uses Node's `node:crypto`, which is a builtin rather than a dependency, so
171
+ * the package still installs nothing. It does mean fingerprinting requires
172
+ * Node; report rendering does not, and rendering is the only part of this
173
+ * package a browser was ever going to run.
174
+ *
175
+ * @param input - The identifying facts.
176
+ * @returns A lowercase hex SHA-256 digest.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * const key = vulnKey({ sourceEngine: 'zap', sourceRuleId: '40012' });
181
+ * fingerprint({ targetId: 'tgt_1', vulnKey: key, location: '/search', parameter: 'q' });
182
+ * ```
183
+ */
184
+ export declare function fingerprint(input: FingerprintInput): string;
185
+ //# sourceMappingURL=fingerprint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.d.ts","sourceRoot":"","sources":["../src/fingerprint.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,UAAU,CAAC;AAQ3C;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,SAAS,CAAC;AA6BvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA2B1D;AAiBD;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,sDAAsD;IACtD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAE9B,2DAA2D;IAC3D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAE/B,qCAAqC;IACrC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAEtB,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAcxD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAmBnD;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;OAOG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,4DAA4D;IAC5D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAE5B,0EAA0E;IAC1E,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAO3D"}