@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.
- package/README.md +67 -31
- package/dist/coverage.d.ts +21 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +65 -0
- package/dist/coverage.js.map +1 -0
- package/dist/finding.d.ts +89 -0
- package/dist/finding.d.ts.map +1 -0
- package/dist/finding.js +2 -0
- package/dist/finding.js.map +1 -0
- package/dist/fingerprint.d.ts +185 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +247 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/import/nuclei.d.ts +39 -0
- package/dist/import/nuclei.d.ts.map +1 -0
- package/dist/import/nuclei.js +115 -0
- package/dist/import/nuclei.js.map +1 -0
- package/dist/import/zap.d.ts +26 -0
- package/dist/import/zap.d.ts.map +1 -0
- package/dist/import/zap.js +119 -0
- package/dist/import/zap.js.map +1 -0
- package/dist/index.d.ts +31 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/dist/issue.d.ts +190 -11
- package/dist/issue.d.ts.map +1 -1
- package/dist/reconcile.d.ts +115 -10
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +306 -12
- package/dist/reconcile.js.map +1 -1
- package/dist/run.d.ts +134 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +2 -0
- package/dist/run.js.map +1 -0
- package/dist/severity.d.ts +191 -0
- package/dist/severity.d.ts.map +1 -0
- package/dist/severity.js +171 -0
- package/dist/severity.js.map +1 -0
- package/dist/snapshot-builder.d.ts +70 -0
- package/dist/snapshot-builder.d.ts.map +1 -0
- package/dist/snapshot-builder.js +148 -0
- package/dist/snapshot-builder.js.map +1 -0
- package/dist/snapshot.d.ts +125 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +2 -0
- package/dist/snapshot.js.map +1 -0
- package/package.json +23 -2
- package/src/coverage.ts +65 -0
- package/src/finding.ts +112 -0
- package/src/fingerprint.ts +315 -0
- package/src/import/nuclei.ts +173 -0
- package/src/import/zap.ts +161 -0
- package/src/index.ts +56 -2
- package/src/issue.ts +244 -11
- package/src/reconcile.ts +421 -17
- package/src/run.ts +163 -0
- package/src/severity.ts +250 -0
- package/src/snapshot-builder.ts +199 -0
- package/src/snapshot.ts +146 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Finding } from './finding.js';
|
|
2
|
+
import type { IgnoreReason, Issue } from './issue.js';
|
|
3
|
+
import type { RunSummary } from './run.js';
|
|
4
|
+
import type { Severity, SlaStatus } from './severity.js';
|
|
5
|
+
/**
|
|
6
|
+
* A system under test.
|
|
7
|
+
*
|
|
8
|
+
* Targets are the unit of authorisation as well as of organisation: staging,
|
|
9
|
+
* production and dev are separate targets with separate tokens and separate
|
|
10
|
+
* verification states.
|
|
11
|
+
*/
|
|
12
|
+
export interface Target {
|
|
13
|
+
/** Unique id. */
|
|
14
|
+
readonly id: string;
|
|
15
|
+
/** Organisation it belongs to. */
|
|
16
|
+
readonly orgId: string;
|
|
17
|
+
/** Human-readable name, as it appears on a report cover. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** What is being tested — a URL, host or repository. */
|
|
20
|
+
readonly url: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What happened to an issue between the baseline and this run.
|
|
24
|
+
*
|
|
25
|
+
* This is the axis every report opens on, because it is the question a reader
|
|
26
|
+
* actually has: not "what is wrong" but "what changed since last time".
|
|
27
|
+
*/
|
|
28
|
+
export type IssueChange = 'new' | 'still_open' | 'regressed' | 'resolved' | 'ignored';
|
|
29
|
+
/**
|
|
30
|
+
* An issue as a report sees it: its own state, plus everything a template
|
|
31
|
+
* would otherwise have to compute.
|
|
32
|
+
*
|
|
33
|
+
* The derived fields are computed once, when the snapshot is built, so that
|
|
34
|
+
* five report templates in three formats cannot each work out `daysOpen`
|
|
35
|
+
* slightly differently.
|
|
36
|
+
*/
|
|
37
|
+
export interface SnapshotIssue {
|
|
38
|
+
/** The issue itself. */
|
|
39
|
+
readonly issue: Issue;
|
|
40
|
+
/** What happened to it relative to the baseline. */
|
|
41
|
+
readonly change: IssueChange;
|
|
42
|
+
/**
|
|
43
|
+
* Whole days between `firstSeen` and the run this snapshot is for.
|
|
44
|
+
*
|
|
45
|
+
* Measured from `firstSeen`, which is never reset, so a regression does not
|
|
46
|
+
* make a year-old problem look new.
|
|
47
|
+
*/
|
|
48
|
+
readonly daysOpen: number;
|
|
49
|
+
/** Where it stands against its remediation deadline, as of this run. */
|
|
50
|
+
readonly slaStatus: SlaStatus;
|
|
51
|
+
/**
|
|
52
|
+
* The evidence behind it.
|
|
53
|
+
*
|
|
54
|
+
* Findings from this run, and from earlier ones where the report shows
|
|
55
|
+
* history. This is what makes a report checkable rather than merely
|
|
56
|
+
* assertive.
|
|
57
|
+
*/
|
|
58
|
+
readonly findings: readonly Finding[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A suppressed issue, as the appendix shows it.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately its own shape rather than a flag on {@link SnapshotIssue}: the
|
|
64
|
+
* suppressed appendix answers a different question — not "what is wrong" but
|
|
65
|
+
* "what did you decide not to fix, and who decided it".
|
|
66
|
+
*
|
|
67
|
+
* **Every report carries this appendix, on by default.** It is an
|
|
68
|
+
* accepted-risk register, which is exactly what an auditor asks for, and
|
|
69
|
+
* hiding it would be both less useful and less honest.
|
|
70
|
+
*/
|
|
71
|
+
export interface SuppressedIssue {
|
|
72
|
+
/** The issue itself. */
|
|
73
|
+
readonly issue: Issue;
|
|
74
|
+
/** Why it was suppressed. */
|
|
75
|
+
readonly reason: IgnoreReason;
|
|
76
|
+
/** The justification given at the time. */
|
|
77
|
+
readonly comment: string;
|
|
78
|
+
/** Who suppressed it. */
|
|
79
|
+
readonly ignoredBy: string;
|
|
80
|
+
/** When. */
|
|
81
|
+
readonly ignoredAt: Date;
|
|
82
|
+
/** When the suppression lapses, if it does. */
|
|
83
|
+
readonly expiresAt?: Date;
|
|
84
|
+
/** Detected severity at the moment it was suppressed. */
|
|
85
|
+
readonly severityAtIgnore: Severity;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Issue state as of a run, plus a baseline to compare against.
|
|
89
|
+
*
|
|
90
|
+
* **The report engine's only input.** Every template consumes a `Snapshot` and
|
|
91
|
+
* nothing else — no database handle, no network, no clock. That is what makes
|
|
92
|
+
* a report reproducible, testable against golden files, and renderable by a
|
|
93
|
+
* third party who has findings on disk and no account.
|
|
94
|
+
*
|
|
95
|
+
* **The snapshot is the artefact; a PDF is a view of it.** Reports are rendered
|
|
96
|
+
* on demand and never stored (invariant 12). Snapshots are small, deterministic
|
|
97
|
+
* JSON, so caching one is cheap where storing a rendered document would buy
|
|
98
|
+
* retention policy, signed-URL expiry, encryption review and a deletion
|
|
99
|
+
* obligation in exchange for saving a render.
|
|
100
|
+
*/
|
|
101
|
+
export interface Snapshot {
|
|
102
|
+
/** The run this snapshot describes. */
|
|
103
|
+
readonly run: RunSummary;
|
|
104
|
+
/**
|
|
105
|
+
* What to compare against.
|
|
106
|
+
*
|
|
107
|
+
* The previous run for an ordinary report, or a specific named run for a
|
|
108
|
+
* retest. Absent for a first run, where there is nothing to compare to and
|
|
109
|
+
* every issue is `new`.
|
|
110
|
+
*/
|
|
111
|
+
readonly baseline?: RunSummary;
|
|
112
|
+
/** The system under test. */
|
|
113
|
+
readonly target: Target;
|
|
114
|
+
/**
|
|
115
|
+
* Every issue in scope, with what changed and the evidence for it.
|
|
116
|
+
*
|
|
117
|
+
* Excludes suppressed issues, which appear in {@link Snapshot.suppressed}
|
|
118
|
+
* instead — the two lists are disjoint by construction so a template cannot
|
|
119
|
+
* accidentally count an accepted risk as an open one.
|
|
120
|
+
*/
|
|
121
|
+
readonly issues: readonly SnapshotIssue[];
|
|
122
|
+
/** The accepted-risk register. */
|
|
123
|
+
readonly suppressed: readonly SuppressedIssue[];
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,WAAW,MAAM;IACrB,iBAAiB;IACjB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,kCAAkC;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB,wDAAwD;IACxD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,YAAY,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAEtF;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,wBAAwB;IACxB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IAEtB,oDAAoD;IACpD,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAE7B;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAE9B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CACvC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,eAAe;IAC9B,wBAAwB;IACxB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IAEtB,6BAA6B;IAC7B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAE9B,2CAA2C;IAC3C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB,yBAAyB;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAE3B,YAAY;IACZ,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IAEzB,+CAA+C;IAC/C,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAC;IAE1B,yDAAyD;IACzD,QAAQ,CAAC,gBAAgB,EAAE,QAAQ,CAAC;CACrC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,QAAQ;IACvB,uCAAuC;IACvC,QAAQ,CAAC,GAAG,EAAE,UAAU,CAAC;IAEzB;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC;IAE/B,6BAA6B;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAExB;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;IAE1C,kCAAkC;IAClC,QAAQ,CAAC,UAAU,EAAE,SAAS,eAAe,EAAE,CAAC;CACjD"}
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secureport/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared domain model for Secureport. Imported by the hosted API the same way a third party would.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"security",
|
|
7
|
+
"pentest",
|
|
8
|
+
"penetration-testing",
|
|
9
|
+
"vulnerability",
|
|
10
|
+
"vulnerability-management",
|
|
11
|
+
"appsec",
|
|
12
|
+
"devsecops",
|
|
13
|
+
"sast",
|
|
14
|
+
"dast",
|
|
15
|
+
"nuclei",
|
|
16
|
+
"zap",
|
|
17
|
+
"burp",
|
|
18
|
+
"nessus",
|
|
19
|
+
"security-reporting"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/agbjordan/secureport/tree/main/packages/core#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/agbjordan/secureport/issues"
|
|
24
|
+
},
|
|
5
25
|
"type": "module",
|
|
6
26
|
"main": "./dist/index.js",
|
|
7
27
|
"types": "./dist/index.d.ts",
|
|
@@ -17,6 +37,7 @@
|
|
|
17
37
|
"LICENSE"
|
|
18
38
|
],
|
|
19
39
|
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.20.1",
|
|
20
41
|
"typescript": "^5.7.2"
|
|
21
42
|
},
|
|
22
43
|
"license": "MIT",
|
|
@@ -29,7 +50,7 @@
|
|
|
29
50
|
"access": "public"
|
|
30
51
|
},
|
|
31
52
|
"scripts": {
|
|
32
|
-
"build": "tsc -p tsconfig.json",
|
|
53
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
33
54
|
"typecheck": "tsc --noEmit"
|
|
34
55
|
}
|
|
35
56
|
}
|
package/src/coverage.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Coverage } from './run.js';
|
|
2
|
+
|
|
3
|
+
/** Escapes a literal so it can sit inside a regular expression. */
|
|
4
|
+
function escapeLiteral(text: string): string {
|
|
5
|
+
return text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiles a coverage glob into a regular expression.
|
|
10
|
+
*
|
|
11
|
+
* `**` matches anything including `/`; `*` matches anything except `/`, so
|
|
12
|
+
* `https://a.example/*` covers `/orders` but not `/orders/123`; `?` matches one
|
|
13
|
+
* character. Everything else is literal.
|
|
14
|
+
*/
|
|
15
|
+
function globToRegExp(glob: string): RegExp {
|
|
16
|
+
let out = '';
|
|
17
|
+
for (let i = 0; i < glob.length; i++) {
|
|
18
|
+
const char = glob[i];
|
|
19
|
+
if (char === '*') {
|
|
20
|
+
if (glob[i + 1] === '*') {
|
|
21
|
+
out += '.*';
|
|
22
|
+
i++;
|
|
23
|
+
} else {
|
|
24
|
+
out += '[^/]*';
|
|
25
|
+
}
|
|
26
|
+
} else if (char === '?') {
|
|
27
|
+
out += '[^/]';
|
|
28
|
+
} else {
|
|
29
|
+
out += escapeLiteral(char);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return new RegExp(`^${out}$`, 'u');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The port in a location, where it states one. */
|
|
36
|
+
function portOf(location: string): number | undefined {
|
|
37
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\/[^/]*?:(\d+)(?:[/?#]|$)/iu.exec(location);
|
|
38
|
+
return match ? Number(match[1]) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether a run could have found something at this location.
|
|
43
|
+
*
|
|
44
|
+
* **This is what makes auto-resolution safe.** A run only resolves what it
|
|
45
|
+
* could have found (invariant 5): a quick profile that skipped `/admin` must
|
|
46
|
+
* not close an `/admin` issue, because not looking is not the same as not
|
|
47
|
+
* finding. Anything outside coverage is left entirely alone — not resolved, and
|
|
48
|
+
* not counted as a miss either.
|
|
49
|
+
*
|
|
50
|
+
* A location matches when it matches at least one covered path glob, **and**,
|
|
51
|
+
* where both the location and the coverage state a port, that port was
|
|
52
|
+
* exercised. The port rule errs towards leaving issues open: a scan of `:443`
|
|
53
|
+
* says nothing about `:8443`.
|
|
54
|
+
*
|
|
55
|
+
* @param location - The issue's location.
|
|
56
|
+
* @param coverage - What the run exercised.
|
|
57
|
+
* @returns `true` if the run could have found it.
|
|
58
|
+
*/
|
|
59
|
+
export function coversLocation(location: string, coverage: Coverage): boolean {
|
|
60
|
+
const port = portOf(location);
|
|
61
|
+
if (port !== undefined && coverage.ports !== undefined && coverage.ports.length > 0) {
|
|
62
|
+
if (!coverage.ports.includes(port)) return false;
|
|
63
|
+
}
|
|
64
|
+
return coverage.paths.some((glob) => globToRegExp(glob).test(location));
|
|
65
|
+
}
|
package/src/finding.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Severity, SeveritySource } from './severity.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One detection, in one run.
|
|
5
|
+
*
|
|
6
|
+
* **Findings are immutable evidence.** A finding is never updated after it is
|
|
7
|
+
* recorded (invariant 1) and it carries no status: whether something is open,
|
|
8
|
+
* fixed or accepted is a property of the {@link Issue} it reconciles into, not
|
|
9
|
+
* of the evidence for it. If a field here would need to change as a human works
|
|
10
|
+
* on the problem, it belongs on the issue instead.
|
|
11
|
+
*
|
|
12
|
+
* Many findings, from many runs and many engines, fold into one issue by
|
|
13
|
+
* {@link Finding.fingerprint}.
|
|
14
|
+
*/
|
|
15
|
+
export interface Finding {
|
|
16
|
+
/** Unique id for this detection. */
|
|
17
|
+
readonly id: string;
|
|
18
|
+
|
|
19
|
+
/** Organisation this finding belongs to. Every query carries it. */
|
|
20
|
+
readonly orgId: string;
|
|
21
|
+
|
|
22
|
+
/** The run that produced it. */
|
|
23
|
+
readonly runId: string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Content address of the underlying weakness, used to reconcile findings
|
|
27
|
+
* into a single issue across runs and engines.
|
|
28
|
+
*
|
|
29
|
+
* Derived from the target, the {@link Finding.vulnKey}, the normalised
|
|
30
|
+
* location and the parameter — never from volatile evidence such as tokens,
|
|
31
|
+
* timestamps or response bodies, which would make every run look new.
|
|
32
|
+
*/
|
|
33
|
+
readonly fingerprint: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which fingerprint algorithm produced {@link Finding.fingerprint}.
|
|
37
|
+
*
|
|
38
|
+
* **A public contract.** It travels with every fingerprint everywhere
|
|
39
|
+
* (invariant 8) so a stored fingerprint can always be interpreted, and
|
|
40
|
+
* changing the algorithm means a re-fingerprint migration that preserves
|
|
41
|
+
* history rather than a silent recomputation.
|
|
42
|
+
*/
|
|
43
|
+
readonly fingerprintVersion: string;
|
|
44
|
+
|
|
45
|
+
/** Short human-readable name for the weakness. */
|
|
46
|
+
readonly title: string;
|
|
47
|
+
|
|
48
|
+
/** Fuller explanation of what was detected. */
|
|
49
|
+
readonly description?: string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Severity as detected for this finding.
|
|
53
|
+
*
|
|
54
|
+
* What the evidence says. The issue's `effectiveSeverity` may differ, because
|
|
55
|
+
* a human may have overridden it.
|
|
56
|
+
*/
|
|
57
|
+
readonly detectedSeverity: Severity;
|
|
58
|
+
|
|
59
|
+
/** Why {@link Finding.detectedSeverity} is what it is. */
|
|
60
|
+
readonly severitySource: SeveritySource;
|
|
61
|
+
|
|
62
|
+
/** CVSS base score, where the engine or advisory supplied one. */
|
|
63
|
+
readonly cvssScore?: number;
|
|
64
|
+
|
|
65
|
+
/** CVSS vector string, where one was supplied. */
|
|
66
|
+
readonly cvssVector?: string;
|
|
67
|
+
|
|
68
|
+
/** CWE identifier, e.g. `CWE-79`. */
|
|
69
|
+
readonly cwe?: string;
|
|
70
|
+
|
|
71
|
+
/** CVE identifier, e.g. `CVE-2026-1234`. */
|
|
72
|
+
readonly cve?: string;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Engine-independent key for the weakness class.
|
|
76
|
+
*
|
|
77
|
+
* This is what makes a Nuclei detection and a ZAP detection of the same
|
|
78
|
+
* weakness collide into one issue. Without it there is no cross-engine
|
|
79
|
+
* deduplication, only per-engine.
|
|
80
|
+
*/
|
|
81
|
+
readonly vulnKey: string;
|
|
82
|
+
|
|
83
|
+
/** Broad grouping for reports, e.g. `injection`, `tls`, `access-control`. */
|
|
84
|
+
readonly category?: string;
|
|
85
|
+
|
|
86
|
+
/** Where it was found — a URL, host, port or file path. */
|
|
87
|
+
readonly location: string;
|
|
88
|
+
|
|
89
|
+
/** The specific parameter implicated, where the weakness has one. */
|
|
90
|
+
readonly parameter?: string;
|
|
91
|
+
|
|
92
|
+
/** Pointers to stored evidence: request/response captures, screenshots. */
|
|
93
|
+
readonly evidenceUri?: readonly string[];
|
|
94
|
+
|
|
95
|
+
/** What to do about it. */
|
|
96
|
+
readonly recommendation?: string;
|
|
97
|
+
|
|
98
|
+
/** External reading: advisories, vendor bulletins, standards. */
|
|
99
|
+
readonly references?: readonly string[];
|
|
100
|
+
|
|
101
|
+
/** Which engine detected it, e.g. `nuclei`, `zap`, `burp`, `nessus`. */
|
|
102
|
+
readonly sourceEngine: string;
|
|
103
|
+
|
|
104
|
+
/** The engine's own identifier for the rule that fired. */
|
|
105
|
+
readonly sourceRuleId?: string;
|
|
106
|
+
|
|
107
|
+
/** The engine's confidence, where it reports one, from 0 to 1. */
|
|
108
|
+
readonly confidence?: number;
|
|
109
|
+
|
|
110
|
+
/** When the finding was recorded. */
|
|
111
|
+
readonly createdAt: Date;
|
|
112
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which fingerprint algorithm this build of the package implements.
|
|
5
|
+
*
|
|
6
|
+
* **A public contract, and the most consequential string in the package.** It
|
|
7
|
+
* is stored on every finding and every issue, everywhere, so that a fingerprint
|
|
8
|
+
* recorded a year ago can still be interpreted. Changing the algorithm means
|
|
9
|
+
* bumping this *and* running a per-organisation re-fingerprint migration that
|
|
10
|
+
* preserves history — never a silent recomputation, which would orphan every
|
|
11
|
+
* issue whose evidence no longer hashes to the same value.
|
|
12
|
+
*
|
|
13
|
+
* If you are tempted to "just tweak" the normaliser, that is this constant's
|
|
14
|
+
* job to prevent.
|
|
15
|
+
*/
|
|
16
|
+
export const FINGERPRINT_VERSION = 'fp_v1';
|
|
17
|
+
|
|
18
|
+
/** A path segment that is entirely digits, e.g. the `123` in `/orders/123`. */
|
|
19
|
+
const NUMERIC_SEGMENT = /^\d+$/u;
|
|
20
|
+
|
|
21
|
+
/** A path segment that is a UUID in the canonical 8-4-4-4-12 form. */
|
|
22
|
+
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The placeholder that a variable path segment collapses to.
|
|
26
|
+
*
|
|
27
|
+
* Exported because it appears in normalised locations, which appear in reports
|
|
28
|
+
* and in support conversations: someone reading `/orders/{id}` should be able
|
|
29
|
+
* to find out what produced it.
|
|
30
|
+
*/
|
|
31
|
+
export const PATH_PLACEHOLDER = '{id}';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decodes percent-encoding exactly once, and never throws.
|
|
35
|
+
*
|
|
36
|
+
* Once, not repeatedly: decoding until stable would make `%2541` and `%41`
|
|
37
|
+
* collapse to the same thing, so a target that double-encodes could be made to
|
|
38
|
+
* collide with one that does not.
|
|
39
|
+
*
|
|
40
|
+
* Malformed encoding is left alone rather than rejected. A fingerprint that
|
|
41
|
+
* throws on strange input is a fingerprint that loses a finding.
|
|
42
|
+
*/
|
|
43
|
+
function decodeOnce(value: string): string {
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Collapses one path segment to {@link PATH_PLACEHOLDER} if it identifies a
|
|
53
|
+
* record rather than a route.
|
|
54
|
+
*/
|
|
55
|
+
function collapseSegment(segment: string): string {
|
|
56
|
+
if (NUMERIC_SEGMENT.test(segment) || UUID_SEGMENT.test(segment)) return PATH_PLACEHOLDER;
|
|
57
|
+
return segment;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalises a location so that the same weakness in the same place produces
|
|
62
|
+
* the same string, however the engine happened to write it down.
|
|
63
|
+
*
|
|
64
|
+
* This is the part of the fingerprint that decides whether `/orders/1` and
|
|
65
|
+
* `/orders/2` are one issue or two thousand. Applies, in order:
|
|
66
|
+
*
|
|
67
|
+
* - **Lowercases the host**, and converts an internationalised domain to
|
|
68
|
+
* punycode, so `HTTPS://例え.テスト/x` and `https://xn--r8jz45g.xn--zckzah/x`
|
|
69
|
+
* are one location.
|
|
70
|
+
* - **Drops a default port** (`:443` on https, `:80` on http) and keeps any
|
|
71
|
+
* other, because `:8443` is a different service and `:443` is not.
|
|
72
|
+
* - **Decodes percent-encoding once.**
|
|
73
|
+
* - **Collapses numeric and UUID path segments** to `{id}`, so a per-record URL
|
|
74
|
+
* does not open a per-record issue.
|
|
75
|
+
* - **Drops a trailing slash**, except on the root path where it is the path.
|
|
76
|
+
* - **Strips query values but keeps parameter names, sorted.** `?b=2&a=secret`
|
|
77
|
+
* becomes `?a&b`. The names are structure and belong in identity; the values
|
|
78
|
+
* are usually the payload that proved the weakness, which is evidence and
|
|
79
|
+
* must never reach a fingerprint. Sorting means parameter order cannot split
|
|
80
|
+
* one issue into two.
|
|
81
|
+
* - **Drops the fragment**, which the server never sees.
|
|
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.
|
|
87
|
+
*
|
|
88
|
+
* @param location - Where the weakness was found.
|
|
89
|
+
* @returns The normalised location.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* normaliseLocation('HTTPS://API.Example.com:443/Orders/123/items/?b=2&a=secret#f');
|
|
94
|
+
* // 'https://api.example.com/Orders/{id}/items?a&b'
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function normaliseLocation(location: string): string {
|
|
98
|
+
const trimmed = location.trim();
|
|
99
|
+
|
|
100
|
+
let url: URL | undefined;
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(trimmed);
|
|
103
|
+
} catch {
|
|
104
|
+
url = undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Not an absolute URL: normalise it as a bare path and stop. `new URL` would
|
|
108
|
+
// otherwise turn `example.com/x` into the `example.com:` protocol.
|
|
109
|
+
if (!url || url.protocol === '' || !url.host) {
|
|
110
|
+
return normalisePathOnly(trimmed);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Not `decodeOnce` here: normalisePathOnly decodes, and decoding on the way
|
|
114
|
+
// in as well would decode twice — which would fold `%252F` into `%2F` into
|
|
115
|
+
// `/` and let a double-encoding target collide with a single-encoding one.
|
|
116
|
+
const path = normalisePathOnly(url.pathname);
|
|
117
|
+
|
|
118
|
+
// `url.host` already carries punycode and lower case, and omits a default
|
|
119
|
+
// port for the scheme.
|
|
120
|
+
const names = [...new Set([...url.searchParams.keys()].map(decodeOnce))].sort();
|
|
121
|
+
const query = names.length > 0 ? `?${names.join('&')}` : '';
|
|
122
|
+
|
|
123
|
+
return `${url.protocol}//${url.host}${path}${query}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalises a path with no scheme or host.
|
|
128
|
+
*/
|
|
129
|
+
function normalisePathOnly(path: string): string {
|
|
130
|
+
const decoded = decodeOnce(path.trim());
|
|
131
|
+
if (decoded === '' || decoded === '/') return decoded === '' ? '' : '/';
|
|
132
|
+
|
|
133
|
+
const collapsed = decoded
|
|
134
|
+
.split('/')
|
|
135
|
+
.map((segment) => collapseSegment(segment))
|
|
136
|
+
.join('/');
|
|
137
|
+
|
|
138
|
+
return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What a scanner said it found, in the terms needed to name the weakness.
|
|
143
|
+
*/
|
|
144
|
+
export interface VulnKeyInput {
|
|
145
|
+
/** Which engine detected it, e.g. `nuclei`, `zap`. */
|
|
146
|
+
readonly sourceEngine: string;
|
|
147
|
+
|
|
148
|
+
/** The engine's own identifier for the rule that fired. */
|
|
149
|
+
readonly sourceRuleId?: string;
|
|
150
|
+
|
|
151
|
+
/** CWE identifier, e.g. `CWE-79`. */
|
|
152
|
+
readonly cwe?: string;
|
|
153
|
+
|
|
154
|
+
/** Broad grouping, e.g. `injection`. */
|
|
155
|
+
readonly category?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Maps `engine:ruleId` to a shared, engine-independent weakness key.
|
|
160
|
+
*
|
|
161
|
+
* **This table is the entire mechanism of cross-engine deduplication.** Without
|
|
162
|
+
* it a ZAP detection and a Nuclei detection of the same weakness fall back to
|
|
163
|
+
* CWE, and where either engine omits the CWE they never collide at all — you
|
|
164
|
+
* get per-engine tracking wearing the clothes of an issue tracker.
|
|
165
|
+
*
|
|
166
|
+
* **It is deliberately small, and that is not laziness.** A mapping asserts
|
|
167
|
+
* that a specific rule id means a specific weakness, and a wrong assertion
|
|
168
|
+
* silently merges two unrelated issues — worse than not mapping at all, because
|
|
169
|
+
* the merge is invisible. Rule ids cannot be known honestly until real scanner
|
|
170
|
+
* output has been parsed, which is what 4.4a and 4.4b do with committed
|
|
171
|
+
* fixtures. The table grows there, from evidence.
|
|
172
|
+
*
|
|
173
|
+
* **Entries must be added in pairs, per weakness, across engines — a half-filled
|
|
174
|
+
* table is worse than an empty one.** The mapping beats the CWE fallback, so
|
|
175
|
+
* mapping ZAP's HSTS rule while leaving Nuclei's unmapped gives them *different*
|
|
176
|
+
* keys, when falling back to `CWE-319` on both sides would have collided them
|
|
177
|
+
* correctly. Adding one engine's rule silently un-deduplicates the weakness.
|
|
178
|
+
* Found by importing a fixture from each engine and watching them stop
|
|
179
|
+
* agreeing.
|
|
180
|
+
*
|
|
181
|
+
* `00-DOMAIN.md` §10 leaves the eventual size open, leaning towards the top
|
|
182
|
+
* ~200 Nuclei templates plus ZAP's plugin list, with CWE fallback beyond.
|
|
183
|
+
*
|
|
184
|
+
* Keys are `${sourceEngine}:${sourceRuleId}`, both lowercased.
|
|
185
|
+
*/
|
|
186
|
+
export const VULN_KEY_MAP: Readonly<Record<string, string>> = Object.freeze({
|
|
187
|
+
// ZAP plugin ids are stable and documented, which is why the seed is ZAP's.
|
|
188
|
+
'zap:40012': 'xss-reflected',
|
|
189
|
+
'zap:40014': 'xss-persistent',
|
|
190
|
+
'zap:40018': 'sql-injection',
|
|
191
|
+
'zap:10038': 'csp-missing',
|
|
192
|
+
'zap:10035': 'hsts-missing',
|
|
193
|
+
'zap:10021': 'x-content-type-options-missing',
|
|
194
|
+
'zap:10020': 'x-frame-options-missing',
|
|
195
|
+
|
|
196
|
+
// Nuclei's side of the pairs above. Anything mapped for one engine and not
|
|
197
|
+
// the other stops the two agreeing, so these travel together.
|
|
198
|
+
'nuclei:xss-reflected': 'xss-reflected',
|
|
199
|
+
'nuclei:missing-hsts': 'hsts-missing',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The engine-independent key for a weakness class.
|
|
204
|
+
*
|
|
205
|
+
* Resolution order, per `00-DOMAIN.md` §4:
|
|
206
|
+
*
|
|
207
|
+
* 1. {@link VULN_KEY_MAP}, looked up by `engine:ruleId`.
|
|
208
|
+
* 2. `cwe` alone.
|
|
209
|
+
* 3. `category` alone.
|
|
210
|
+
* 4. `engine:ruleId` itself.
|
|
211
|
+
*
|
|
212
|
+
* **The spec says `cwe + '/' + category`, and that was tried and abandoned.**
|
|
213
|
+
* Engines do not share a category vocabulary: ZAP supplies no category at all,
|
|
214
|
+
* so reflected XSS there keys as `CWE-79`, while Nuclei tags the same finding
|
|
215
|
+
* `xss` and keys as `CWE-79/xss`. The two never collide — so the fallback
|
|
216
|
+
* actively prevented the cross-engine deduplication it exists to provide, which
|
|
217
|
+
* a fixture from each engine demonstrated immediately.
|
|
218
|
+
*
|
|
219
|
+
* CWE alone is coarser, and the coarseness is bounded: the fingerprint also
|
|
220
|
+
* carries the normalised location and the parameter, so two findings only merge
|
|
221
|
+
* when they share a weakness class *and* a place. Two genuinely different
|
|
222
|
+
* weaknesses under one CWE, at the same URL and parameter, is the case this
|
|
223
|
+
* gets wrong — and `POST /issues/{a}/merge/{b}` exists because something will.
|
|
224
|
+
*
|
|
225
|
+
* Steps 3 and 4 keep a finding trackable when there is no CWE at all. **Step 4
|
|
226
|
+
* never deduplicates across engines**, which is the honest outcome for a rule
|
|
227
|
+
* nobody has mapped: it tracks correctly and merges nothing it should not.
|
|
228
|
+
*
|
|
229
|
+
* @param input - What the engine reported.
|
|
230
|
+
* @returns The weakness key.
|
|
231
|
+
* @throws TypeError If nothing identifying is present at all.
|
|
232
|
+
*/
|
|
233
|
+
export function vulnKey(input: VulnKeyInput): string {
|
|
234
|
+
const engine = input.sourceEngine.trim().toLowerCase();
|
|
235
|
+
const ruleId = input.sourceRuleId?.trim().toLowerCase();
|
|
236
|
+
|
|
237
|
+
if (engine !== '' && ruleId !== undefined && ruleId !== '') {
|
|
238
|
+
const mapped = VULN_KEY_MAP[`${engine}:${ruleId}`];
|
|
239
|
+
if (mapped !== undefined) return mapped;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cwe = input.cwe?.trim().toUpperCase();
|
|
243
|
+
const category = input.category?.trim().toLowerCase();
|
|
244
|
+
|
|
245
|
+
if (cwe !== undefined && cwe !== '') return cwe;
|
|
246
|
+
if (category !== undefined && category !== '') return category;
|
|
247
|
+
if (engine !== '' && ruleId !== undefined && ruleId !== '') return `${engine}:${ruleId}`;
|
|
248
|
+
|
|
249
|
+
throw new TypeError(
|
|
250
|
+
'cannot derive a vuln_key: a finding needs a mapped rule, a CWE, a category, or an engine rule id',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Everything the fingerprint is computed from.
|
|
256
|
+
*
|
|
257
|
+
* Note what is absent: no title, no description, no evidence, no timestamp, no
|
|
258
|
+
* severity. **Volatile evidence never enters a fingerprint** — a nonce or a
|
|
259
|
+
* response body in here would make every run look like a fresh discovery, and
|
|
260
|
+
* the product's whole claim is that it can tell you what changed.
|
|
261
|
+
*/
|
|
262
|
+
export interface FingerprintInput {
|
|
263
|
+
/**
|
|
264
|
+
* The target the finding is on.
|
|
265
|
+
*
|
|
266
|
+
* Part of identity, which has a surprising consequence worth stating: the
|
|
267
|
+
* same weakness in staging and in production is **two issues**. They are two
|
|
268
|
+
* systems, fixed separately, and a verification of one must never authorise
|
|
269
|
+
* the other.
|
|
270
|
+
*/
|
|
271
|
+
readonly targetId: string;
|
|
272
|
+
|
|
273
|
+
/** The engine-independent weakness key, from {@link vulnKey}. */
|
|
274
|
+
readonly vulnKey: string;
|
|
275
|
+
|
|
276
|
+
/** Where it was found. Normalised by {@link normaliseLocation}. */
|
|
277
|
+
readonly location: string;
|
|
278
|
+
|
|
279
|
+
/** The parameter implicated, where the weakness has one. */
|
|
280
|
+
readonly parameter?: string;
|
|
281
|
+
|
|
282
|
+
/** The port, for findings that are about a service rather than a path. */
|
|
283
|
+
readonly port?: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The content address of a weakness: `sha256(targetId | vulnKey | normalised
|
|
288
|
+
* location | parameter ?? port ?? '')`.
|
|
289
|
+
*
|
|
290
|
+
* Deterministic and pure — the same input always gives the same digest, on any
|
|
291
|
+
* machine, in any order, at any time. That is what lets findings from different
|
|
292
|
+
* runs and different engines reconcile into one issue.
|
|
293
|
+
*
|
|
294
|
+
* Uses Node's `node:crypto`, which is a builtin rather than a dependency, so
|
|
295
|
+
* the package still installs nothing. It does mean fingerprinting requires
|
|
296
|
+
* Node; report rendering does not, and rendering is the only part of this
|
|
297
|
+
* package a browser was ever going to run.
|
|
298
|
+
*
|
|
299
|
+
* @param input - The identifying facts.
|
|
300
|
+
* @returns A lowercase hex SHA-256 digest.
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```ts
|
|
304
|
+
* const key = vulnKey({ sourceEngine: 'zap', sourceRuleId: '40012' });
|
|
305
|
+
* fingerprint({ targetId: 'tgt_1', vulnKey: key, location: '/search', parameter: 'q' });
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
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
|
+
);
|
|
313
|
+
|
|
314
|
+
return createHash('sha256').update(material, 'utf8').digest('hex');
|
|
315
|
+
}
|