@secureport/core 0.2.1 → 0.4.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 (100) hide show
  1. package/README.md +146 -33
  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/burp.d.ts +19 -0
  15. package/dist/import/burp.d.ts.map +1 -0
  16. package/dist/import/burp.js +114 -0
  17. package/dist/import/burp.js.map +1 -0
  18. package/dist/import/generic.d.ts +90 -0
  19. package/dist/import/generic.d.ts.map +1 -0
  20. package/dist/import/generic.js +159 -0
  21. package/dist/import/generic.js.map +1 -0
  22. package/dist/import/nessus.d.ts +32 -0
  23. package/dist/import/nessus.d.ts.map +1 -0
  24. package/dist/import/nessus.js +125 -0
  25. package/dist/import/nessus.js.map +1 -0
  26. package/dist/import/nuclei.d.ts +39 -0
  27. package/dist/import/nuclei.d.ts.map +1 -0
  28. package/dist/import/nuclei.js +115 -0
  29. package/dist/import/nuclei.js.map +1 -0
  30. package/dist/import/xml.d.ts +47 -0
  31. package/dist/import/xml.d.ts.map +1 -0
  32. package/dist/import/xml.js +157 -0
  33. package/dist/import/xml.js.map +1 -0
  34. package/dist/import/zap.d.ts +26 -0
  35. package/dist/import/zap.d.ts.map +1 -0
  36. package/dist/import/zap.js +119 -0
  37. package/dist/import/zap.js.map +1 -0
  38. package/dist/index.d.ts +41 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +29 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/issue.d.ts +190 -11
  43. package/dist/issue.d.ts.map +1 -1
  44. package/dist/reconcile.d.ts +115 -10
  45. package/dist/reconcile.d.ts.map +1 -1
  46. package/dist/reconcile.js +306 -12
  47. package/dist/reconcile.js.map +1 -1
  48. package/dist/report/html.d.ts +21 -0
  49. package/dist/report/html.d.ts.map +1 -0
  50. package/dist/report/html.js +324 -0
  51. package/dist/report/html.js.map +1 -0
  52. package/dist/report/json.d.ts +81 -0
  53. package/dist/report/json.d.ts.map +1 -0
  54. package/dist/report/json.js +47 -0
  55. package/dist/report/json.js.map +1 -0
  56. package/dist/report/markdown.d.ts +24 -0
  57. package/dist/report/markdown.d.ts.map +1 -0
  58. package/dist/report/markdown.js +304 -0
  59. package/dist/report/markdown.js.map +1 -0
  60. package/dist/report/model.d.ts +215 -0
  61. package/dist/report/model.d.ts.map +1 -0
  62. package/dist/report/model.js +197 -0
  63. package/dist/report/model.js.map +1 -0
  64. package/dist/run.d.ts +134 -0
  65. package/dist/run.d.ts.map +1 -0
  66. package/dist/run.js +2 -0
  67. package/dist/run.js.map +1 -0
  68. package/dist/severity.d.ts +191 -0
  69. package/dist/severity.d.ts.map +1 -0
  70. package/dist/severity.js +171 -0
  71. package/dist/severity.js.map +1 -0
  72. package/dist/snapshot-builder.d.ts +78 -0
  73. package/dist/snapshot-builder.d.ts.map +1 -0
  74. package/dist/snapshot-builder.js +172 -0
  75. package/dist/snapshot-builder.js.map +1 -0
  76. package/dist/snapshot.d.ts +125 -0
  77. package/dist/snapshot.d.ts.map +1 -0
  78. package/dist/snapshot.js +2 -0
  79. package/dist/snapshot.js.map +1 -0
  80. package/package.json +5 -4
  81. package/src/coverage.ts +65 -0
  82. package/src/finding.ts +112 -0
  83. package/src/fingerprint.ts +315 -0
  84. package/src/import/burp.ts +126 -0
  85. package/src/import/generic.ts +258 -0
  86. package/src/import/nessus.ts +136 -0
  87. package/src/import/nuclei.ts +173 -0
  88. package/src/import/xml.ts +187 -0
  89. package/src/import/zap.ts +161 -0
  90. package/src/index.ts +75 -2
  91. package/src/issue.ts +244 -11
  92. package/src/reconcile.ts +421 -17
  93. package/src/report/html.ts +449 -0
  94. package/src/report/json.ts +134 -0
  95. package/src/report/markdown.ts +435 -0
  96. package/src/report/model.ts +462 -0
  97. package/src/run.ts +163 -0
  98. package/src/severity.ts +250 -0
  99. package/src/snapshot-builder.ts +225 -0
  100. package/src/snapshot.ts +146 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * A parsed XML element.
3
+ *
4
+ * Deliberately minimal: a name, its attributes, its child elements and its
5
+ * text. No namespaces, no processing instructions, no mixed-content ordering —
6
+ * none of which appears in a Burp or Nessus export.
7
+ */
8
+ export interface XmlNode {
9
+ /** The element name, as written. */
10
+ readonly name: string;
11
+
12
+ /** Attributes, with entities decoded. */
13
+ readonly attrs: Readonly<Record<string, string>>;
14
+
15
+ /** Child elements, in document order. */
16
+ readonly children: readonly XmlNode[];
17
+
18
+ /** All direct text and CDATA of this element, concatenated and trimmed. */
19
+ readonly text: string;
20
+ }
21
+
22
+ const ENTITIES: Readonly<Record<string, string>> = Object.freeze({
23
+ amp: '&',
24
+ lt: '<',
25
+ gt: '>',
26
+ quot: '"',
27
+ apos: "'",
28
+ });
29
+
30
+ /** Decodes the five XML entities and numeric character references. */
31
+ function decodeEntities(text: string): string {
32
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/giu, (whole, body: string) => {
33
+ if (body.startsWith('#')) {
34
+ const code =
35
+ body[1]?.toLowerCase() === 'x' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
36
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
37
+ ? String.fromCodePoint(code)
38
+ : whole;
39
+ }
40
+ return ENTITIES[body.toLowerCase()] ?? whole;
41
+ });
42
+ }
43
+
44
+ interface MutableNode {
45
+ name: string;
46
+ attrs: Record<string, string>;
47
+ children: MutableNode[];
48
+ text: string;
49
+ }
50
+
51
+ /** Reads the attributes out of a start tag's body. */
52
+ function parseAttrs(body: string): Record<string, string> {
53
+ const attrs: Record<string, string> = {};
54
+ const pattern = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/gu;
55
+ let match: RegExpExecArray | null;
56
+ while ((match = pattern.exec(body)) !== null) {
57
+ attrs[match[1]] = decodeEntities(match[3] ?? match[4] ?? '');
58
+ }
59
+ return attrs;
60
+ }
61
+
62
+ /**
63
+ * Parses the subset of XML that scanner exports actually use.
64
+ *
65
+ * **Deliberately narrow, and it throws rather than guesses.** Burp and Nessus
66
+ * both export machine-generated XML with a fixed shape: elements, attributes,
67
+ * text and CDATA. This handles exactly that, plus comments, the XML
68
+ * declaration, a DOCTYPE, self-closing tags and the five named entities with
69
+ * numeric character references.
70
+ *
71
+ * It does **not** handle namespaces, entity definitions or mixed-content
72
+ * ordering, and it has no opinion about schemas. Anything structurally
73
+ * unexpected — an unclosed element, a mismatched end tag, two root elements —
74
+ * is an error, because a parser that guesses at broken input produces findings
75
+ * that are quietly wrong, and a wrong finding is worse than a failed import.
76
+ *
77
+ * Hand-written rather than taken from a library because `@secureport/core` ships
78
+ * with zero runtime dependencies: it is imported by the hosted API and by third
79
+ * parties on equal terms, and a domain model that drags an XML parser in with it
80
+ * is a supply-chain decision made on everyone's behalf.
81
+ *
82
+ * @param source - The XML document.
83
+ * @returns The root element.
84
+ * @throws SyntaxError If the document is not well-formed, or has no root.
85
+ */
86
+ export function parseXml(source: string): XmlNode {
87
+ const stack: MutableNode[] = [];
88
+ let root: MutableNode | undefined;
89
+ let i = 0;
90
+
91
+ while (i < source.length) {
92
+ const lt = source.indexOf('<', i);
93
+ if (lt === -1) break;
94
+
95
+ // Text before this tag belongs to the element currently open.
96
+ if (lt > i && stack.length > 0) {
97
+ stack[stack.length - 1].text += decodeEntities(source.slice(i, lt));
98
+ }
99
+
100
+ if (source.startsWith('<!--', lt)) {
101
+ const end = source.indexOf('-->', lt);
102
+ if (end === -1) throw new SyntaxError('unterminated comment');
103
+ i = end + 3;
104
+ continue;
105
+ }
106
+
107
+ if (source.startsWith('<![CDATA[', lt)) {
108
+ const end = source.indexOf(']]>', lt);
109
+ if (end === -1) throw new SyntaxError('unterminated CDATA section');
110
+ // CDATA is literal: no entity decoding, which is the whole point of it.
111
+ if (stack.length > 0) stack[stack.length - 1].text += source.slice(lt + 9, end);
112
+ i = end + 3;
113
+ continue;
114
+ }
115
+
116
+ if (source.startsWith('<?', lt) || source.startsWith('<!', lt)) {
117
+ const end = source.indexOf('>', lt);
118
+ if (end === -1) throw new SyntaxError('unterminated declaration');
119
+ i = end + 1;
120
+ continue;
121
+ }
122
+
123
+ const gt = source.indexOf('>', lt);
124
+ if (gt === -1) throw new SyntaxError('unterminated tag');
125
+ const raw = source.slice(lt + 1, gt);
126
+
127
+ if (raw.startsWith('/')) {
128
+ const name = raw.slice(1).trim();
129
+ const open = stack.pop();
130
+ if (open === undefined) throw new SyntaxError(`unexpected closing tag </${name}>`);
131
+ if (open.name !== name) {
132
+ throw new SyntaxError(`closing tag </${name}> does not match <${open.name}>`);
133
+ }
134
+ i = gt + 1;
135
+ continue;
136
+ }
137
+
138
+ const selfClosing = raw.endsWith('/');
139
+ const body = selfClosing ? raw.slice(0, -1) : raw;
140
+ const nameEnd = body.search(/[\s/]/u);
141
+ const name = (nameEnd === -1 ? body : body.slice(0, nameEnd)).trim();
142
+ if (name === '') throw new SyntaxError('tag with no name');
143
+
144
+ const node: MutableNode = {
145
+ name,
146
+ attrs: parseAttrs(nameEnd === -1 ? '' : body.slice(nameEnd)),
147
+ children: [],
148
+ text: '',
149
+ };
150
+
151
+ if (stack.length > 0) stack[stack.length - 1].children.push(node);
152
+ else if (root === undefined) root = node;
153
+ else throw new SyntaxError('more than one root element');
154
+
155
+ if (!selfClosing) stack.push(node);
156
+ i = gt + 1;
157
+ }
158
+
159
+ if (stack.length > 0) throw new SyntaxError(`unclosed element <${stack[stack.length - 1].name}>`);
160
+ if (root === undefined) throw new SyntaxError('no root element');
161
+
162
+ const finish = (node: MutableNode): XmlNode => ({
163
+ name: node.name,
164
+ attrs: node.attrs,
165
+ children: node.children.map(finish),
166
+ text: node.text.trim(),
167
+ });
168
+ return finish(root);
169
+ }
170
+
171
+ /** Every descendant with this name, at any depth. */
172
+ export function findAll(node: XmlNode, name: string): XmlNode[] {
173
+ const found: XmlNode[] = [];
174
+ const walk = (current: XmlNode) => {
175
+ if (current.name === name) found.push(current);
176
+ for (const child of current.children) walk(child);
177
+ };
178
+ walk(node);
179
+ return found;
180
+ }
181
+
182
+ /** The text of the first direct child with this name, if it has any. */
183
+ export function childText(node: XmlNode, name: string): string | undefined {
184
+ const child = node.children.find((c) => c.name === name);
185
+ const text = child?.text.trim();
186
+ return text === undefined || text === '' ? undefined : text;
187
+ }
@@ -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 `ZAP_RISK` below 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,75 @@
1
- export * from './issue.js';
2
- export * from './reconcile.js';
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 { importBurp } from './import/burp.js';
41
+ export { importNessus } from './import/nessus.js';
42
+ export type { GenericDocument, GenericFinding } from './import/generic.js';
43
+ export { importGeneric } from './import/generic.js';
44
+ export type { BuildSnapshotInput } from './snapshot-builder.js';
45
+ export { buildSnapshot, parseSnapshot } from './snapshot-builder.js';
46
+ export type {
47
+ ReportKind,
48
+ ReportModel,
49
+ ReportOptions,
50
+ ReportSection,
51
+ RetestEntry,
52
+ RetestOutcome,
53
+ RetestVerdict,
54
+ TestingBasis,
55
+ } from './report/model.js';
56
+ export { buildReportModel } from './report/model.js';
57
+ export { renderMarkdown } from './report/markdown.js';
58
+ export { renderHtml } from './report/html.js';
59
+ export type { JsonReport } from './report/json.js';
60
+ export { renderJson } from './report/json.js';
61
+ export type { ReconcileInput, ReconcileResult } from './reconcile.js';
62
+ export { reconcile } from './reconcile.js';
63
+ export type { Coverage, Run, RunKind, RunSummary, RunTrigger, SeverityCounts } from './run.js';
64
+ export type { IssueChange, Snapshot, SnapshotIssue, SuppressedIssue, Target } from './snapshot.js';
65
+ export type { Severity, SeveritySource, SlaPolicy, SlaStatus } from './severity.js';
66
+ export {
67
+ DEFAULT_SLA_POLICY,
68
+ SEVERITY_ORDER,
69
+ SEVERITY_SOURCE_PRECEDENCE,
70
+ SEVERITY_WEIGHTS,
71
+ severityFromCvss,
72
+ severityRank,
73
+ slaDueAt,
74
+ slaStatus,
75
+ } from './severity.js';
package/src/issue.ts CHANGED
@@ -1,17 +1,250 @@
1
- /** Severity of an {@link Issue}, ordered from most to least urgent by convention. */
2
- export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
1
+ import type { Severity } from './severity.js';
2
+ import type { RunKind } from './run.js';
3
3
 
4
- /** Lifecycle state of an {@link Issue}. */
5
- export type IssueStatus = 'open' | 'triaging' | 'accepted' | 'resolved';
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
- * A tracked issue — the entity Secureport reports on. One or more findings
9
- * from one or more scanners can be merged into a single issue; the issue,
10
- * not the finding, is what carries status and severity through review.
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: string;
14
- title: string;
15
- severity: Severity;
16
- status: IssueStatus;
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
  }