@secureport/core 0.2.1 → 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 +3 -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,173 @@
|
|
|
1
|
+
import type { Finding } from '../finding.js';
|
|
2
|
+
import type { Severity, SeveritySource } from '../severity.js';
|
|
3
|
+
import { severityFromCvss } from '../severity.js';
|
|
4
|
+
import { fingerprint, vulnKey, FINGERPRINT_VERSION } from '../fingerprint.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What an importer needs that a scanner's output cannot tell it.
|
|
8
|
+
*
|
|
9
|
+
* A scanner knows what it found; it does not know which organisation, run or
|
|
10
|
+
* target the result belongs to, and it must not invent ids. All of it is
|
|
11
|
+
* supplied, so importing stays pure and repeatable.
|
|
12
|
+
*/
|
|
13
|
+
export interface ImportOptions {
|
|
14
|
+
/** Organisation the findings belong to. */
|
|
15
|
+
readonly orgId: string;
|
|
16
|
+
|
|
17
|
+
/** The run that produced them. */
|
|
18
|
+
readonly runId: string;
|
|
19
|
+
|
|
20
|
+
/** The target they are against. Part of every fingerprint. */
|
|
21
|
+
readonly targetId: string;
|
|
22
|
+
|
|
23
|
+
/** When the import happened. No importer reads the clock. */
|
|
24
|
+
readonly now: Date;
|
|
25
|
+
|
|
26
|
+
/** Supplies finding ids. Injected so importing is deterministic. */
|
|
27
|
+
readonly newId: () => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Nuclei's severity vocabulary, mapped onto {@link Severity}.
|
|
32
|
+
*
|
|
33
|
+
* `info` and `unknown` both become `advisory`: this model has no
|
|
34
|
+
* "informational" category separate from the bottom of the scale, and an
|
|
35
|
+
* unrated finding is not evidence of low risk — it is evidence of nothing, which
|
|
36
|
+
* is what `advisory` means here.
|
|
37
|
+
*/
|
|
38
|
+
const NUCLEI_SEVERITY: Readonly<Record<string, Severity>> = Object.freeze({
|
|
39
|
+
critical: 'critical',
|
|
40
|
+
high: 'high',
|
|
41
|
+
medium: 'medium',
|
|
42
|
+
low: 'low',
|
|
43
|
+
info: 'advisory',
|
|
44
|
+
unknown: 'advisory',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** The subset of a Nuclei JSONL record this importer reads. */
|
|
48
|
+
interface NucleiRecord {
|
|
49
|
+
'template-id'?: unknown;
|
|
50
|
+
'matched-at'?: unknown;
|
|
51
|
+
host?: unknown;
|
|
52
|
+
type?: unknown;
|
|
53
|
+
info?: {
|
|
54
|
+
name?: unknown;
|
|
55
|
+
description?: unknown;
|
|
56
|
+
severity?: unknown;
|
|
57
|
+
tags?: unknown;
|
|
58
|
+
reference?: unknown;
|
|
59
|
+
remediation?: unknown;
|
|
60
|
+
classification?: {
|
|
61
|
+
'cve-id'?: unknown;
|
|
62
|
+
'cwe-id'?: unknown;
|
|
63
|
+
'cvss-score'?: unknown;
|
|
64
|
+
'cvss-metrics'?: unknown;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const str = (v: unknown): string | undefined =>
|
|
70
|
+
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
|
71
|
+
|
|
72
|
+
const firstOf = (v: unknown): string | undefined => (Array.isArray(v) ? str(v[0]) : str(v));
|
|
73
|
+
|
|
74
|
+
const allOf = (v: unknown): string[] | undefined => {
|
|
75
|
+
if (!Array.isArray(v)) return str(v) === undefined ? undefined : [str(v)!];
|
|
76
|
+
const items = v.map(str).filter((x): x is string => x !== undefined);
|
|
77
|
+
return items.length > 0 ? items : undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Turns Nuclei's JSONL output into {@link Finding}s.
|
|
82
|
+
*
|
|
83
|
+
* Nuclei writes **one JSON object per line** (`-jsonl`), so the file is not
|
|
84
|
+
* itself valid JSON. Blank lines are skipped, and a line that will not parse is
|
|
85
|
+
* skipped rather than thrown on: a single malformed record should not cost a
|
|
86
|
+
* user every other result in the file. Records with no `template-id` or no
|
|
87
|
+
* location are skipped for the same reason — there is nothing to fingerprint.
|
|
88
|
+
*
|
|
89
|
+
* Severity comes from the CVSS score where Nuclei supplies one, and from its own
|
|
90
|
+
* rating otherwise; `severitySource` records which, because a severity with no
|
|
91
|
+
* provenance is not evidence.
|
|
92
|
+
*
|
|
93
|
+
* @param jsonl - The contents of a Nuclei JSONL file.
|
|
94
|
+
* @param options - Ownership, and the injected clock and id source.
|
|
95
|
+
* @returns One finding per usable record, in file order.
|
|
96
|
+
*/
|
|
97
|
+
export function importNuclei(jsonl: string, options: ImportOptions): Finding[] {
|
|
98
|
+
const findings: Finding[] = [];
|
|
99
|
+
|
|
100
|
+
for (const line of jsonl.split('\n')) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (trimmed === '') continue;
|
|
103
|
+
|
|
104
|
+
let record: NucleiRecord;
|
|
105
|
+
try {
|
|
106
|
+
record = JSON.parse(trimmed) as NucleiRecord;
|
|
107
|
+
} catch {
|
|
108
|
+
// One unparseable line must not cost the user the rest of the file.
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const templateId = str(record['template-id']);
|
|
113
|
+
const location = str(record['matched-at']) ?? str(record.host);
|
|
114
|
+
if (templateId === undefined || location === undefined) continue;
|
|
115
|
+
|
|
116
|
+
const info = record.info ?? {};
|
|
117
|
+
const classification = info.classification ?? {};
|
|
118
|
+
|
|
119
|
+
const cvss =
|
|
120
|
+
typeof classification['cvss-score'] === 'number' ? classification['cvss-score'] : undefined;
|
|
121
|
+
const rated = NUCLEI_SEVERITY[str(info.severity)?.toLowerCase() ?? ''];
|
|
122
|
+
|
|
123
|
+
let detectedSeverity: Severity;
|
|
124
|
+
let severitySource: SeveritySource;
|
|
125
|
+
if (cvss !== undefined && cvss >= 0 && cvss <= 10) {
|
|
126
|
+
detectedSeverity = severityFromCvss(cvss);
|
|
127
|
+
severitySource = 'cvss';
|
|
128
|
+
} else {
|
|
129
|
+
detectedSeverity = rated ?? 'advisory';
|
|
130
|
+
severitySource = 'engine_default';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Nuclei writes CWE as `cwe-502`; the rest of the model uses `CWE-502`.
|
|
134
|
+
const cwe = firstOf(classification['cwe-id'])?.toUpperCase();
|
|
135
|
+
const category = firstOf(info.tags);
|
|
136
|
+
const key = vulnKey({
|
|
137
|
+
sourceEngine: 'nuclei',
|
|
138
|
+
sourceRuleId: templateId,
|
|
139
|
+
...(cwe === undefined ? {} : { cwe }),
|
|
140
|
+
...(category === undefined ? {} : { category }),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
findings.push({
|
|
144
|
+
id: options.newId(),
|
|
145
|
+
orgId: options.orgId,
|
|
146
|
+
runId: options.runId,
|
|
147
|
+
fingerprint: fingerprint({ targetId: options.targetId, vulnKey: key, location }),
|
|
148
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
149
|
+
title: str(info.name) ?? templateId,
|
|
150
|
+
...(str(info.description) === undefined ? {} : { description: str(info.description)! }),
|
|
151
|
+
detectedSeverity,
|
|
152
|
+
severitySource,
|
|
153
|
+
...(cvss === undefined ? {} : { cvssScore: cvss }),
|
|
154
|
+
...(str(classification['cvss-metrics']) === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: { cvssVector: str(classification['cvss-metrics'])! }),
|
|
157
|
+
...(cwe === undefined ? {} : { cwe }),
|
|
158
|
+
...(firstOf(classification['cve-id']) === undefined
|
|
159
|
+
? {}
|
|
160
|
+
: { cve: firstOf(classification['cve-id'])!.toUpperCase() }),
|
|
161
|
+
vulnKey: key,
|
|
162
|
+
...(category === undefined ? {} : { category }),
|
|
163
|
+
location,
|
|
164
|
+
...(str(info.remediation) === undefined ? {} : { recommendation: str(info.remediation)! }),
|
|
165
|
+
...(allOf(info.reference) === undefined ? {} : { references: allOf(info.reference)! }),
|
|
166
|
+
sourceEngine: 'nuclei',
|
|
167
|
+
sourceRuleId: templateId,
|
|
168
|
+
createdAt: options.now,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return findings;
|
|
173
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Finding } from '../finding.js';
|
|
2
|
+
import type { Severity } from '../severity.js';
|
|
3
|
+
import { fingerprint, vulnKey, FINGERPRINT_VERSION } from '../fingerprint.js';
|
|
4
|
+
import type { ImportOptions } from './nuclei.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ZAP's `riskcode` mapped onto {@link Severity}.
|
|
8
|
+
*
|
|
9
|
+
* **ZAP has no "critical".** Its scale tops out at High (`3`), so nothing this
|
|
10
|
+
* importer produces is ever `critical` — a `critical` issue in a report that
|
|
11
|
+
* ZAP also detected got there from another engine, a CVSS score or a human.
|
|
12
|
+
* Worth knowing before wondering why a ZAP-only target has none.
|
|
13
|
+
*/
|
|
14
|
+
const ZAP_RISK: Readonly<Record<string, Severity>> = Object.freeze({
|
|
15
|
+
'3': 'high',
|
|
16
|
+
'2': 'medium',
|
|
17
|
+
'1': 'low',
|
|
18
|
+
'0': 'advisory',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** The subset of a ZAP JSON report this importer reads. */
|
|
22
|
+
interface ZapReport {
|
|
23
|
+
site?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ZapSite {
|
|
27
|
+
'@name'?: unknown;
|
|
28
|
+
alerts?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ZapAlert {
|
|
32
|
+
pluginid?: unknown;
|
|
33
|
+
alertRef?: unknown;
|
|
34
|
+
alert?: unknown;
|
|
35
|
+
name?: unknown;
|
|
36
|
+
riskcode?: unknown;
|
|
37
|
+
desc?: unknown;
|
|
38
|
+
solution?: unknown;
|
|
39
|
+
reference?: unknown;
|
|
40
|
+
cweid?: unknown;
|
|
41
|
+
instances?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ZapInstance {
|
|
45
|
+
uri?: unknown;
|
|
46
|
+
param?: unknown;
|
|
47
|
+
evidence?: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const str = (v: unknown): string | undefined => {
|
|
51
|
+
if (typeof v === 'number') return String(v);
|
|
52
|
+
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** ZAP embeds HTML in its description and solution fields. */
|
|
56
|
+
const stripHtml = (v: string): string =>
|
|
57
|
+
v
|
|
58
|
+
.replace(/<[^>]*>/gu, ' ')
|
|
59
|
+
.replace(/\s+/gu, ' ')
|
|
60
|
+
.trim();
|
|
61
|
+
|
|
62
|
+
/** ZAP's reference field is one string of newline-separated URLs. */
|
|
63
|
+
const splitReferences = (v: unknown): string[] | undefined => {
|
|
64
|
+
const text = str(v);
|
|
65
|
+
if (text === undefined) return undefined;
|
|
66
|
+
const urls = stripHtml(text)
|
|
67
|
+
.split(/\s+/u)
|
|
68
|
+
.filter((token) => token.startsWith('http'));
|
|
69
|
+
return urls.length > 0 ? urls : undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Turns a ZAP JSON report into {@link Finding}s.
|
|
74
|
+
*
|
|
75
|
+
* **One finding per instance, not per alert.** ZAP groups every occurrence of a
|
|
76
|
+
* weakness under a single alert with an `instances` array, so an alert is a
|
|
77
|
+
* class and an instance is a detection. Flattening is what lets two URLs
|
|
78
|
+
* affected by the same rule become two issues, and lets one of them be fixed
|
|
79
|
+
* without closing the other.
|
|
80
|
+
*
|
|
81
|
+
* An alert with no instances still produces one finding, against the site — a
|
|
82
|
+
* detection with no location is still a detection.
|
|
83
|
+
*
|
|
84
|
+
* Severity comes from ZAP's `riskcode`; see {@link ZAP_RISK} for why nothing
|
|
85
|
+
* here is ever `critical`.
|
|
86
|
+
*
|
|
87
|
+
* @param json - The contents of a ZAP JSON report.
|
|
88
|
+
* @param options - Ownership, and the injected clock and id source.
|
|
89
|
+
* @returns One finding per instance, in report order.
|
|
90
|
+
* @throws SyntaxError If the report is not valid JSON. Unlike Nuclei's JSONL,
|
|
91
|
+
* where one bad line costs one record, a ZAP report is a single document: if it
|
|
92
|
+
* will not parse there is nothing to salvage and silence would be a lie.
|
|
93
|
+
*/
|
|
94
|
+
export function importZap(json: string, options: ImportOptions): Finding[] {
|
|
95
|
+
const report = JSON.parse(json) as ZapReport;
|
|
96
|
+
const sites = Array.isArray(report.site) ? (report.site as ZapSite[]) : [];
|
|
97
|
+
const findings: Finding[] = [];
|
|
98
|
+
|
|
99
|
+
for (const site of sites) {
|
|
100
|
+
const siteName = str(site['@name']);
|
|
101
|
+
const alerts = Array.isArray(site.alerts) ? (site.alerts as ZapAlert[]) : [];
|
|
102
|
+
|
|
103
|
+
for (const alert of alerts) {
|
|
104
|
+
const pluginId = str(alert.pluginid);
|
|
105
|
+
const title = str(alert.alert) ?? str(alert.name);
|
|
106
|
+
if (pluginId === undefined || title === undefined) continue;
|
|
107
|
+
|
|
108
|
+
// ZAP writes `0` for "no CWE", which is not a CWE.
|
|
109
|
+
const rawCwe = str(alert.cweid);
|
|
110
|
+
const cwe =
|
|
111
|
+
rawCwe !== undefined && rawCwe !== '0' && rawCwe !== '-1' ? `CWE-${rawCwe}` : undefined;
|
|
112
|
+
|
|
113
|
+
const key = vulnKey({
|
|
114
|
+
sourceEngine: 'zap',
|
|
115
|
+
sourceRuleId: pluginId,
|
|
116
|
+
...(cwe === undefined ? {} : { cwe }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const instances = Array.isArray(alert.instances) ? (alert.instances as ZapInstance[]) : [];
|
|
120
|
+
const targets: ZapInstance[] = instances.length > 0 ? instances : [{ uri: siteName }];
|
|
121
|
+
|
|
122
|
+
for (const instance of targets) {
|
|
123
|
+
const location = str(instance.uri) ?? siteName;
|
|
124
|
+
if (location === undefined) continue;
|
|
125
|
+
const parameter = str(instance.param);
|
|
126
|
+
|
|
127
|
+
findings.push({
|
|
128
|
+
id: options.newId(),
|
|
129
|
+
orgId: options.orgId,
|
|
130
|
+
runId: options.runId,
|
|
131
|
+
fingerprint: fingerprint({
|
|
132
|
+
targetId: options.targetId,
|
|
133
|
+
vulnKey: key,
|
|
134
|
+
location,
|
|
135
|
+
...(parameter === undefined ? {} : { parameter }),
|
|
136
|
+
}),
|
|
137
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
138
|
+
title,
|
|
139
|
+
...(str(alert.desc) === undefined ? {} : { description: stripHtml(str(alert.desc)!) }),
|
|
140
|
+
detectedSeverity: ZAP_RISK[str(alert.riskcode) ?? ''] ?? 'advisory',
|
|
141
|
+
severitySource: 'engine_default',
|
|
142
|
+
...(cwe === undefined ? {} : { cwe }),
|
|
143
|
+
vulnKey: key,
|
|
144
|
+
location,
|
|
145
|
+
...(parameter === undefined ? {} : { parameter }),
|
|
146
|
+
...(str(alert.solution) === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { recommendation: stripHtml(str(alert.solution)!) }),
|
|
149
|
+
...(splitReferences(alert.reference) === undefined
|
|
150
|
+
? {}
|
|
151
|
+
: { references: splitReferences(alert.reference)! }),
|
|
152
|
+
sourceEngine: 'zap',
|
|
153
|
+
sourceRuleId: pluginId,
|
|
154
|
+
createdAt: options.now,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return findings;
|
|
161
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,56 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The Secureport domain model.
|
|
3
|
+
*
|
|
4
|
+
* The tracked entity is the **issue**, not the finding. A {@link Run} produces
|
|
5
|
+
* {@link Finding}s, which are immutable evidence. Findings are fingerprinted
|
|
6
|
+
* and reconciled into {@link Issue}s, which persist across runs and carry
|
|
7
|
+
* status, severity, age and history. A {@link Snapshot} is issue state as of a
|
|
8
|
+
* run, and is the only thing a report ever reads.
|
|
9
|
+
*
|
|
10
|
+
* Zero runtime dependencies, by design: this package is imported by the hosted
|
|
11
|
+
* API and by third parties on equal terms, and a domain model should not drag
|
|
12
|
+
* anything in with it.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type { Finding } from './finding.js';
|
|
18
|
+
export type { FingerprintInput, VulnKeyInput } from './fingerprint.js';
|
|
19
|
+
export {
|
|
20
|
+
FINGERPRINT_VERSION,
|
|
21
|
+
PATH_PLACEHOLDER,
|
|
22
|
+
VULN_KEY_MAP,
|
|
23
|
+
fingerprint,
|
|
24
|
+
normaliseLocation,
|
|
25
|
+
vulnKey,
|
|
26
|
+
} from './fingerprint.js';
|
|
27
|
+
export type {
|
|
28
|
+
IgnoreReason,
|
|
29
|
+
IgnoreScope,
|
|
30
|
+
Issue,
|
|
31
|
+
IssueEvent,
|
|
32
|
+
IssueEventType,
|
|
33
|
+
IssueOrigin,
|
|
34
|
+
IssueStatus,
|
|
35
|
+
} from './issue.js';
|
|
36
|
+
export { coversLocation } from './coverage.js';
|
|
37
|
+
export type { ImportOptions } from './import/nuclei.js';
|
|
38
|
+
export { importNuclei } from './import/nuclei.js';
|
|
39
|
+
export { importZap } from './import/zap.js';
|
|
40
|
+
export type { BuildSnapshotInput } from './snapshot-builder.js';
|
|
41
|
+
export { buildSnapshot, parseSnapshot } from './snapshot-builder.js';
|
|
42
|
+
export type { ReconcileInput, ReconcileResult } from './reconcile.js';
|
|
43
|
+
export { reconcile } from './reconcile.js';
|
|
44
|
+
export type { Coverage, Run, RunKind, RunSummary, RunTrigger, SeverityCounts } from './run.js';
|
|
45
|
+
export type { IssueChange, Snapshot, SnapshotIssue, SuppressedIssue, Target } from './snapshot.js';
|
|
46
|
+
export type { Severity, SeveritySource, SlaPolicy, SlaStatus } from './severity.js';
|
|
47
|
+
export {
|
|
48
|
+
DEFAULT_SLA_POLICY,
|
|
49
|
+
SEVERITY_ORDER,
|
|
50
|
+
SEVERITY_SOURCE_PRECEDENCE,
|
|
51
|
+
SEVERITY_WEIGHTS,
|
|
52
|
+
severityFromCvss,
|
|
53
|
+
severityRank,
|
|
54
|
+
slaDueAt,
|
|
55
|
+
slaStatus,
|
|
56
|
+
} from './severity.js';
|
package/src/issue.ts
CHANGED
|
@@ -1,17 +1,250 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { Severity } from './severity.js';
|
|
2
|
+
import type { RunKind } from './run.js';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Where an issue is in its lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* - `open` — currently detected, or detected recently enough not to be resolved.
|
|
8
|
+
* - `resolved` — no longer detected by runs that covered it.
|
|
9
|
+
* - `regressed` — was resolved, and has come back. Distinct from `open` because
|
|
10
|
+
* a returning issue is a different and more interesting event than a new one.
|
|
11
|
+
* - `ignored` — deliberately suppressed, with a reason and an author.
|
|
12
|
+
*
|
|
13
|
+
* There is no `triaging` state. Triage is a person's activity, not an issue's
|
|
14
|
+
* condition, and a status nobody can define precisely is a status nobody
|
|
15
|
+
* filters on correctly.
|
|
16
|
+
*/
|
|
17
|
+
export type IssueStatus = 'open' | 'resolved' | 'regressed' | 'ignored';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How an issue came into existence.
|
|
21
|
+
*
|
|
22
|
+
* Matters because **manual-origin issues never auto-resolve** (invariant 6): a
|
|
23
|
+
* human closes what a human opened.
|
|
24
|
+
*/
|
|
25
|
+
export type IssueOrigin = RunKind;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Why an issue was suppressed.
|
|
29
|
+
*
|
|
30
|
+
* A fixed set rather than free text because this list is what a
|
|
31
|
+
* suppressed-findings appendix is grouped by, and an auditor reading it needs
|
|
32
|
+
* categories that mean the same thing every time.
|
|
33
|
+
*/
|
|
34
|
+
export type IgnoreReason = 'false_positive' | 'accepted_risk' | 'out_of_scope' | 'duplicate';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How widely an ignore applies.
|
|
38
|
+
*
|
|
39
|
+
* - `target` — this issue on this target only. The default.
|
|
40
|
+
* - `org` — the same weakness anywhere in the organisation, matched on
|
|
41
|
+
* `vulnKey` and normalised location, ignoring the parameter.
|
|
42
|
+
*/
|
|
43
|
+
export type IgnoreScope = 'target' | 'org';
|
|
6
44
|
|
|
7
45
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
46
|
+
* The tracked record that findings reconcile into, and the entity the product
|
|
47
|
+
* is actually about.
|
|
48
|
+
*
|
|
49
|
+
* An issue persists across runs. It carries status, severity, age, ownership
|
|
50
|
+
* and history; findings are the evidence beneath it. The distinction is the
|
|
51
|
+
* whole point of the model: two scanners reporting the same weakness produce
|
|
52
|
+
* one issue with two sources, not two rows, and an issue that comes back after
|
|
53
|
+
* being fixed is the same issue regressing rather than a new discovery.
|
|
54
|
+
*
|
|
55
|
+
* Unique on `(orgId, targetId, fingerprint)`.
|
|
56
|
+
*
|
|
57
|
+
* **Issue state changes only through reconciliation or an issue-service
|
|
58
|
+
* method, and every change emits an {@link IssueEvent}** (invariant 2).
|
|
11
59
|
*/
|
|
12
60
|
export interface Issue {
|
|
13
|
-
id
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
61
|
+
/** Unique id. */
|
|
62
|
+
readonly id: string;
|
|
63
|
+
|
|
64
|
+
/** Organisation this issue belongs to. */
|
|
65
|
+
readonly orgId: string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Target this issue is on.
|
|
69
|
+
*
|
|
70
|
+
* Part of the identity, and deliberately so: because the fingerprint includes
|
|
71
|
+
* the target, **the same weakness in staging and in production is two
|
|
72
|
+
* issues**. They are two systems, fixed separately, and verifying one must
|
|
73
|
+
* never authorise the other.
|
|
74
|
+
*/
|
|
75
|
+
readonly targetId: string;
|
|
76
|
+
|
|
77
|
+
/** Content address of the weakness. Stable across runs and engines. */
|
|
78
|
+
readonly fingerprint: string;
|
|
79
|
+
|
|
80
|
+
/** Which algorithm produced {@link Issue.fingerprint}. */
|
|
81
|
+
readonly fingerprintVersion: string;
|
|
82
|
+
|
|
83
|
+
/** Human-readable name. Seeded from the first finding; editable afterwards. */
|
|
84
|
+
readonly title: string;
|
|
85
|
+
|
|
86
|
+
/** Engine-independent key for the weakness class. */
|
|
87
|
+
readonly vulnKey: string;
|
|
88
|
+
|
|
89
|
+
/** CWE identifier, where one applies. */
|
|
90
|
+
readonly cwe?: string;
|
|
91
|
+
|
|
92
|
+
/** Where the weakness is. */
|
|
93
|
+
readonly location: string;
|
|
94
|
+
|
|
95
|
+
/** The specific parameter implicated, where there is one. */
|
|
96
|
+
readonly parameter?: string;
|
|
97
|
+
|
|
98
|
+
/** Lifecycle state. */
|
|
99
|
+
readonly status: IssueStatus;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Severity as most recently detected.
|
|
103
|
+
*
|
|
104
|
+
* Follows the evidence. Changing it emits `severity_detected_changed`.
|
|
105
|
+
*/
|
|
106
|
+
readonly detectedSeverity: Severity;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Severity that reports show, and that the SLA deadline derives from.
|
|
110
|
+
*
|
|
111
|
+
* Follows {@link Issue.detectedSeverity} unless a human has overridden it. A
|
|
112
|
+
* null override means the two move together.
|
|
113
|
+
*/
|
|
114
|
+
readonly effectiveSeverity: Severity;
|
|
115
|
+
|
|
116
|
+
/** Why the severity was overridden. Present only when it was. */
|
|
117
|
+
readonly severityOverrideReason?: string;
|
|
118
|
+
|
|
119
|
+
/** Who overrode it. */
|
|
120
|
+
readonly severityOverriddenBy?: string;
|
|
121
|
+
|
|
122
|
+
/** When it was overridden. */
|
|
123
|
+
readonly severityOverriddenAt?: Date;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* When this issue was first seen.
|
|
127
|
+
*
|
|
128
|
+
* **Never reset** (invariant 4) — not by a regression, not by a severity
|
|
129
|
+
* change, not by anything. It is what "how long has this been open" means,
|
|
130
|
+
* and resetting it would quietly erase the age of the oldest problems.
|
|
131
|
+
*/
|
|
132
|
+
readonly firstSeen: Date;
|
|
133
|
+
|
|
134
|
+
/** When it was most recently detected. Keeps updating even while ignored. */
|
|
135
|
+
readonly lastSeen: Date;
|
|
136
|
+
|
|
137
|
+
/** When it was resolved, if it has been. */
|
|
138
|
+
readonly resolvedAt?: Date;
|
|
139
|
+
|
|
140
|
+
/** When it most recently regressed, if it has. */
|
|
141
|
+
readonly reopenedAt?: Date;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Consecutive covering runs that did not detect this issue.
|
|
145
|
+
*
|
|
146
|
+
* The auto-resolve counter, and the flapping guard: a single missed
|
|
147
|
+
* detection from a timeout or a rate limit should not close an issue. Reset
|
|
148
|
+
* to zero the moment the issue is seen again.
|
|
149
|
+
*/
|
|
150
|
+
readonly consecutiveMisses: number;
|
|
151
|
+
|
|
152
|
+
/** How the issue came into existence. */
|
|
153
|
+
readonly origin: IssueOrigin;
|
|
154
|
+
|
|
155
|
+
/** Who it is assigned to, if anyone. */
|
|
156
|
+
readonly assignee?: string;
|
|
157
|
+
|
|
158
|
+
/** Key in an external tracker, set by the export. */
|
|
159
|
+
readonly externalRef?: string;
|
|
160
|
+
|
|
161
|
+
/** Why it is suppressed. Present only while `status` is `ignored`. */
|
|
162
|
+
readonly ignoreReason?: IgnoreReason;
|
|
163
|
+
|
|
164
|
+
/** Free-text justification for the suppression. Required when ignoring. */
|
|
165
|
+
readonly ignoreComment?: string;
|
|
166
|
+
|
|
167
|
+
/** How widely the suppression applies. */
|
|
168
|
+
readonly ignoreScope?: IgnoreScope;
|
|
169
|
+
|
|
170
|
+
/** When the suppression lapses, after which the issue re-surfaces. */
|
|
171
|
+
readonly ignoreExpiresAt?: Date;
|
|
172
|
+
|
|
173
|
+
/** Who suppressed it. */
|
|
174
|
+
readonly ignoredBy?: string;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Detected severity at the moment it was ignored.
|
|
178
|
+
*
|
|
179
|
+
* Recorded so that a *detected increase* re-surfaces the issue
|
|
180
|
+
* automatically: accepting the risk of a medium is not accepting the risk of
|
|
181
|
+
* the critical it later turns out to be.
|
|
182
|
+
*/
|
|
183
|
+
readonly severityAtIgnore?: Severity;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* When remediation is due, derived from {@link Issue.effectiveSeverity}.
|
|
187
|
+
*
|
|
188
|
+
* Recomputed whenever the effective severity changes. `null` where the
|
|
189
|
+
* severity carries no deadline.
|
|
190
|
+
*/
|
|
191
|
+
readonly slaDueAt?: Date | null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Something that happened to an issue.
|
|
196
|
+
*
|
|
197
|
+
* The audit trail and the source of every analytic. Nothing scans findings at
|
|
198
|
+
* request time: "issues resolved this quarter" and "regressions caught" are
|
|
199
|
+
* both counts over this log.
|
|
200
|
+
*/
|
|
201
|
+
export type IssueEventType =
|
|
202
|
+
| 'created'
|
|
203
|
+
| 'seen'
|
|
204
|
+
| 'severity_detected_changed'
|
|
205
|
+
| 'severity_overridden'
|
|
206
|
+
| 'resolved'
|
|
207
|
+
| 'reopened'
|
|
208
|
+
| 'ignored'
|
|
209
|
+
| 'unignored'
|
|
210
|
+
| 'commented'
|
|
211
|
+
| 'assigned'
|
|
212
|
+
| 'merged'
|
|
213
|
+
| 'exported';
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* An append-only record of a single change to an issue.
|
|
217
|
+
*
|
|
218
|
+
* Append-only is load-bearing rather than stylistic: it is what makes the
|
|
219
|
+
* history trustworthy as evidence, and what lets analytics be a query rather
|
|
220
|
+
* than a recomputation.
|
|
221
|
+
*/
|
|
222
|
+
export interface IssueEvent {
|
|
223
|
+
/** Unique id. */
|
|
224
|
+
readonly id: string;
|
|
225
|
+
|
|
226
|
+
/** Organisation this event belongs to. */
|
|
227
|
+
readonly orgId: string;
|
|
228
|
+
|
|
229
|
+
/** The issue it happened to. */
|
|
230
|
+
readonly issueId: string;
|
|
231
|
+
|
|
232
|
+
/** The run that caused it, where a run did. Absent for human actions. */
|
|
233
|
+
readonly runId?: string;
|
|
234
|
+
|
|
235
|
+
/** What happened. */
|
|
236
|
+
readonly type: IssueEventType;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Who or what did it — a user id, an API key id, or the reconciler.
|
|
240
|
+
*
|
|
241
|
+
* Never optional: an audit trail that cannot say who is not one.
|
|
242
|
+
*/
|
|
243
|
+
readonly actor: string;
|
|
244
|
+
|
|
245
|
+
/** Type-specific detail, e.g. the old and new severity. */
|
|
246
|
+
readonly payload?: Readonly<Record<string, unknown>>;
|
|
247
|
+
|
|
248
|
+
/** When it happened. */
|
|
249
|
+
readonly createdAt: Date;
|
|
17
250
|
}
|