@secureport/core 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/index.d.ts +9 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/issue.d.ts +1 -1
  6. package/dist/issue.d.ts.map +1 -1
  7. package/dist/reconcile.d.ts +46 -1
  8. package/dist/reconcile.d.ts.map +1 -1
  9. package/dist/reconcile.js +34 -1
  10. package/dist/reconcile.js.map +1 -1
  11. package/dist/report/anchors.d.ts +39 -0
  12. package/dist/report/anchors.d.ts.map +1 -0
  13. package/dist/report/anchors.js +73 -0
  14. package/dist/report/anchors.js.map +1 -0
  15. package/dist/report/csv.d.ts +41 -0
  16. package/dist/report/csv.d.ts.map +1 -0
  17. package/dist/report/csv.js +152 -0
  18. package/dist/report/csv.js.map +1 -0
  19. package/dist/report/html.d.ts.map +1 -1
  20. package/dist/report/html.js +126 -22
  21. package/dist/report/html.js.map +1 -1
  22. package/dist/report/json.d.ts +15 -0
  23. package/dist/report/json.d.ts.map +1 -1
  24. package/dist/report/json.js +1 -0
  25. package/dist/report/json.js.map +1 -1
  26. package/dist/report/markdown.d.ts +24 -2
  27. package/dist/report/markdown.d.ts.map +1 -1
  28. package/dist/report/markdown.js +133 -31
  29. package/dist/report/markdown.js.map +1 -1
  30. package/dist/report/model.d.ts +288 -2
  31. package/dist/report/model.d.ts.map +1 -1
  32. package/dist/report/model.js +153 -21
  33. package/dist/report/model.js.map +1 -1
  34. package/dist/report/sarif.d.ts +60 -0
  35. package/dist/report/sarif.d.ts.map +1 -0
  36. package/dist/report/sarif.js +125 -0
  37. package/dist/report/sarif.js.map +1 -0
  38. package/dist/report/stream.d.ts +108 -0
  39. package/dist/report/stream.d.ts.map +1 -0
  40. package/dist/report/stream.js +534 -0
  41. package/dist/report/stream.js.map +1 -0
  42. package/package.json +4 -2
  43. package/src/index.ts +11 -2
  44. package/src/issue.ts +1 -0
  45. package/src/reconcile.ts +86 -2
  46. package/src/report/anchors.ts +96 -0
  47. package/src/report/csv.ts +180 -0
  48. package/src/report/html.ts +146 -26
  49. package/src/report/json.ts +17 -0
  50. package/src/report/markdown.ts +146 -30
  51. package/src/report/model.ts +433 -21
  52. package/src/report/sarif.ts +166 -0
  53. package/src/report/stream.ts +702 -0
package/src/reconcile.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Finding } from './finding.js';
2
2
  import { FINGERPRINT_VERSION } from './fingerprint.js';
3
- import type { Issue, IssueEvent } from './issue.js';
3
+ import type { IgnoreReason, IgnoreScope, Issue, IssueEvent } from './issue.js';
4
4
  import type { Run, RunKind, RunSummary, SeverityCounts } from './run.js';
5
5
  import {
6
6
  DEFAULT_SLA_POLICY,
@@ -73,6 +73,56 @@ export interface ReconcileInput {
73
73
 
74
74
  /** How long the run took, for the summary. */
75
75
  readonly durationMs?: number;
76
+
77
+ /**
78
+ * Consulted for each issue this run would newly open, to suppress it at
79
+ * birth — a standing rule rather than a decision about one issue.
80
+ *
81
+ * **It has to live here, and B89 records why the obvious alternative fails.**
82
+ * `reconcile()` computes the run summary, so an issue it opens is counted
83
+ * `new`. A caller that suppressed that issue after the fact would produce a
84
+ * report whose opening line said `new` while its own appendix said
85
+ * `suppressed`, about the same issue, in the same document. Filtering the
86
+ * findings before reconciling is no better: the issue then vanishes from the
87
+ * summary entirely rather than being suppressed, and invariant 7 says a
88
+ * suppressed issue is never absent from the appendix.
89
+ *
90
+ * Return `undefined` to open the issue normally. The suppressed issue is
91
+ * counted `ignored` in the same pass that counts everything else, which the
92
+ * summary already does correctly because it tests `status` before it tests
93
+ * whether the run created the issue.
94
+ *
95
+ * **Consulted only for new issues**, never for one that already exists: an
96
+ * issue a person has looked at is theirs, and a rule written afterwards does
97
+ * not get to reach back and hide it.
98
+ */
99
+ readonly suppress?: (issue: Issue) => SuppressionDecision | undefined;
100
+ }
101
+
102
+ /**
103
+ * Why a standing rule suppressed an issue at the moment it was opened.
104
+ *
105
+ * @see {@link ReconcileInput.suppress}
106
+ */
107
+ export interface SuppressionDecision {
108
+ /** Which of the domain's ignore reasons applies. */
109
+ readonly reason: IgnoreReason;
110
+
111
+ /** The justification an auditor reads in the suppressed appendix. */
112
+ readonly comment?: string;
113
+
114
+ /** How widely the suppression applies. */
115
+ readonly scope?: IgnoreScope;
116
+
117
+ /**
118
+ * Who accepted the risk. Defaults to {@link ReconcileInput.actor}.
119
+ *
120
+ * Worth passing. The appendix is an accepted-risk register, and a register
121
+ * whose every row says `reconciler` answers "who accepted this?" with "the
122
+ * clock did" — which `00-DOMAIN.md` §3 is explicit is not an identity. The
123
+ * rule that matched has an author; this is where to name them.
124
+ */
125
+ readonly by?: string;
76
126
  }
77
127
 
78
128
  /**
@@ -319,8 +369,42 @@ export function reconcile(input: ReconcileInput): ReconcileResult {
319
369
  origin: run.kind,
320
370
  slaDueAt: slaDueAt(worst.detectedSeverity, now, slaPolicy),
321
371
  };
322
- updated.set(key, issue);
372
+ const decision = input.suppress?.(issue);
373
+ if (decision === undefined) {
374
+ updated.set(key, issue);
375
+ emit(id, 'created', { findingIds, severity: worst.detectedSeverity });
376
+ continue;
377
+ }
378
+
379
+ // Both events, in order. The issue was opened and then suppressed; a
380
+ // history that recorded only the suppression would have an issue whose
381
+ // first event is `ignored`, and invariant 2 wants every state change
382
+ // accounted for rather than the net result.
383
+ //
384
+ // `severityAtIgnore` is set deliberately, which is what makes a rule
385
+ // liftable: the existing branch above un-ignores an issue whose detected
386
+ // severity rises past what was accepted, and a standing rule should not
387
+ // be more permanent than a person's own decision. Accepting the risk of
388
+ // a medium is not accepting the risk of a critical, however the
389
+ // acceptance was expressed. **A merge survivor is the deliberate
390
+ // exception and omits it** (P6, B98) — that is a statement about
391
+ // identity rather than risk, and it has to survive a severity rise.
392
+ const suppressed: Issue = {
393
+ ...issue,
394
+ status: 'ignored',
395
+ ignoreReason: decision.reason,
396
+ ...(decision.comment === undefined ? {} : { ignoreComment: decision.comment }),
397
+ ...(decision.scope === undefined ? {} : { ignoreScope: decision.scope }),
398
+ ignoredBy: decision.by ?? actor,
399
+ severityAtIgnore: worst.detectedSeverity,
400
+ };
401
+ updated.set(key, suppressed);
323
402
  emit(id, 'created', { findingIds, severity: worst.detectedSeverity });
403
+ emit(id, 'ignored', {
404
+ reason: decision.reason,
405
+ ...(decision.comment === undefined ? {} : { comment: decision.comment }),
406
+ bornSuppressed: true,
407
+ });
324
408
  continue;
325
409
  }
326
410
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Heading anchors and the contents list, produced together.
3
+ *
4
+ * **One pass over the rendered body, not two walks over the document.** A
5
+ * contents list built separately from the ids it links to is a list that can
6
+ * point at nothing — the failure is silent, it only shows up when somebody
7
+ * clicks, and it would appear the moment a heading's wording changed in one
8
+ * place and not the other. Here the ids and the entries come out of the same
9
+ * scan, so a heading that exists has an anchor and an entry, and one that does
10
+ * not exist has neither.
11
+ *
12
+ * This post-processes the renderer's **own** output, inside the same function
13
+ * that produced it. That is a different thing from the string surgery P7.1
14
+ * rules out: the rule there is against forking a published template, and this
15
+ * never reads one — every heading it matches was emitted a few lines earlier by
16
+ * the renderer calling it.
17
+ */
18
+
19
+ /** One line of the contents list. */
20
+ export interface TocEntry {
21
+ /** Heading level, 2 or 3. */
22
+ readonly level: number;
23
+
24
+ /** The anchor it links to, without the `#`. */
25
+ readonly id: string;
26
+
27
+ /** The heading as printed. */
28
+ readonly text: string;
29
+ }
30
+
31
+ /**
32
+ * A heading's anchor: lowercase, punctuation dropped, spaces hyphenated.
33
+ *
34
+ * The same rule GitHub and most Markdown renderers use, so a Markdown
35
+ * report's contents links resolve in the places Markdown is usually read —
36
+ * which is the only way this can work at all, because Markdown headings carry
37
+ * no explicit id to point at.
38
+ */
39
+ function slug(text: string): string {
40
+ return (
41
+ text
42
+ .toLowerCase()
43
+ // Entities first: a heading rendered as `AT&T` should anchor on the
44
+ // text a reader sees, not on the escape that produced it.
45
+ .replace(/&[a-z]+;/gu, ' ')
46
+ .replace(/[^a-z0-9]+/gu, '-')
47
+ .replace(/^-+|-+$/gu, '') || 'section'
48
+ );
49
+ }
50
+
51
+ /** Makes `id` unique within a document, the way a duplicate heading is usually handled. */
52
+ function unique(id: string, taken: Map<string, number>): string {
53
+ const seen = taken.get(id) ?? 0;
54
+ taken.set(id, seen + 1);
55
+ return seen === 0 ? id : `${id}-${String(seen)}`;
56
+ }
57
+
58
+ /**
59
+ * Gives every `<h2>` and `<h3>` an id, and reports what it found.
60
+ *
61
+ * `<h4>` is deliberately left alone: it is an individual issue, and a report
62
+ * with sixty findings would have a contents list longer than its summary.
63
+ */
64
+ export function anchorHtmlHeadings(html: string): { html: string; toc: TocEntry[] } {
65
+ const toc: TocEntry[] = [];
66
+ const taken = new Map<string, number>();
67
+
68
+ // Safe over this input because it is this package's own markup rather than
69
+ // anything from outside: h2 and h3 are emitted with plain escaped text and
70
+ // never with nested elements.
71
+ const anchored = html.replace(
72
+ /<h([23])([^>]*)>([\s\S]*?)<\/h\1>/gu,
73
+ (_match, level: string, attrs: string, text: string) => {
74
+ const id = unique(slug(text), taken);
75
+ toc.push({ level: Number(level), id, text });
76
+ return `<h${level}${attrs} id="${id}">${text}</h${level}>`;
77
+ },
78
+ );
79
+
80
+ return { html: anchored, toc };
81
+ }
82
+
83
+ /** The same, for Markdown, where the anchor is implied by the heading text. */
84
+ export function markdownHeadings(lines: readonly string[]): TocEntry[] {
85
+ const toc: TocEntry[] = [];
86
+ const taken = new Map<string, number>();
87
+
88
+ for (const line of lines) {
89
+ const match = /^(#{2,3})\s+(.*)$/u.exec(line);
90
+ if (!match) continue;
91
+ const text = match[2];
92
+ toc.push({ level: match[1].length, id: unique(slug(text), taken), text });
93
+ }
94
+
95
+ return toc;
96
+ }
@@ -0,0 +1,180 @@
1
+ import type { Severity } from '../severity.js';
2
+ import type { SnapshotIssue, SuppressedIssue } from '../snapshot.js';
3
+ import { verdictFor } from './model.js';
4
+ import type { ReportHeader } from './stream.js';
5
+
6
+ /**
7
+ * What a CSV export can be asked for.
8
+ *
9
+ * Its own type rather than {@link ReportOptions}, for the reason
10
+ * {@link SarifOptions} is: a spreadsheet has no `kind`, no branding and no
11
+ * cover page, and a type offering fields it ignores is the worse API.
12
+ */
13
+ export interface CsvOptions {
14
+ /** Keep issues below this severity out of the rows. */
15
+ readonly severityFloor?: Severity;
16
+
17
+ /** Whether to include issues this run resolved. Defaults to `true`. */
18
+ readonly includeResolved?: boolean;
19
+ }
20
+
21
+ const FLOOR_RANK: Readonly<Record<Severity, number>> = Object.freeze({
22
+ critical: 0,
23
+ high: 1,
24
+ medium: 2,
25
+ low: 3,
26
+ advisory: 4,
27
+ });
28
+
29
+ /**
30
+ * The column order, which is a contract: a spreadsheet someone built a
31
+ * formula against should not have its columns move underneath them.
32
+ * Append-only — a new column goes on the end, never in the middle.
33
+ */
34
+ const COLUMNS = [
35
+ 'issue_id',
36
+ 'title',
37
+ 'status',
38
+ 'change',
39
+ 'effective_severity',
40
+ 'detected_severity',
41
+ 'location',
42
+ 'parameter',
43
+ 'cwe',
44
+ 'first_seen',
45
+ 'last_seen',
46
+ 'days_open',
47
+ 'sla_due',
48
+ 'sla_status',
49
+ 'findings',
50
+ 'retest_verdict',
51
+ 'suppressed',
52
+ 'ignore_reason',
53
+ 'ignored_by',
54
+ 'ignored_at',
55
+ 'ignore_expires',
56
+ 'justification',
57
+ ] as const;
58
+
59
+ /**
60
+ * One CSV field, escaped per RFC 4180.
61
+ *
62
+ * A field containing a comma, a quote or a newline is wrapped in quotes with
63
+ * its own quotes doubled. **Everything else is passed through unquoted**, so
64
+ * the common row stays readable in a diff.
65
+ *
66
+ * `undefined` becomes empty rather than the string "undefined", which is the
67
+ * kind of thing that reaches a customer's spreadsheet and stays there.
68
+ */
69
+ function csvField(value: string | number | boolean | undefined): string {
70
+ if (value === undefined) return '';
71
+ const text = String(value);
72
+ if (!/[",\r\n]/u.test(text)) return text;
73
+ return `"${text.replace(/"/gu, '""')}"`;
74
+ }
75
+
76
+ /** A date as `YYYY-MM-DD`, or empty. One format, no locale to disagree about. */
77
+ function csvDate(value: Date | undefined | null): string {
78
+ return value === undefined || value === null ? '' : value.toISOString().slice(0, 10);
79
+ }
80
+
81
+ function row(fields: readonly (string | number | boolean | undefined)[]): string {
82
+ return fields.map(csvField).join(',') + '\r\n';
83
+ }
84
+
85
+ /**
86
+ * Renders a snapshot's issues as CSV, streamed from a header and cursors —
87
+ * the fourth streaming machine format, alongside
88
+ * {@link renderJsonStream}, {@link renderMarkdownStream} and
89
+ * {@link renderSarifStream}. See {@link renderJsonStream} for the memory
90
+ * argument.
91
+ *
92
+ * **Suppressed issues are rows too, flagged rather than omitted.** A CSV is
93
+ * a data export, not a document with an appendix — so invariant 7's rule
94
+ * ("excluded from the metrics, never from the register") takes the form of a
95
+ * `suppressed` column plus the reason, author and justification beside it.
96
+ * Dropping them would hide an accepted risk from the one format most likely
97
+ * to be filtered and pivoted.
98
+ *
99
+ * **Line endings are CRLF, per RFC 4180.** Excel is the consumer that cares,
100
+ * and it is the consumer a CSV export exists for.
101
+ *
102
+ * @param header - The run, its baseline, and the target.
103
+ * @param issues - Every issue in scope, in the order the rows should appear.
104
+ * @param suppressed - The accepted-risk register, appended after them.
105
+ * @param options - A severity floor and whether to include resolved issues.
106
+ * @returns Chunks of CSV text; concatenated, they are one document.
107
+ */
108
+ export async function* renderCsvStream(
109
+ header: ReportHeader,
110
+ issues: AsyncIterable<SnapshotIssue>,
111
+ suppressed: AsyncIterable<SuppressedIssue>,
112
+ options: CsvOptions = {},
113
+ ): AsyncGenerator<string> {
114
+ const floorAt = options.severityFloor === undefined ? 4 : FLOOR_RANK[options.severityFloor];
115
+ const hasBaseline = header.baseline !== undefined;
116
+
117
+ yield COLUMNS.join(',') + '\r\n';
118
+
119
+ for await (const entry of issues) {
120
+ const { issue } = entry;
121
+ if (issue.status === 'resolved') {
122
+ if (options.includeResolved === false) continue;
123
+ } else if (FLOOR_RANK[issue.effectiveSeverity] > floorAt) {
124
+ continue;
125
+ }
126
+
127
+ yield row([
128
+ issue.id,
129
+ issue.title,
130
+ issue.status,
131
+ entry.change,
132
+ issue.effectiveSeverity,
133
+ issue.detectedSeverity,
134
+ issue.location,
135
+ issue.parameter,
136
+ issue.cwe,
137
+ csvDate(issue.firstSeen),
138
+ csvDate(issue.lastSeen),
139
+ entry.daysOpen,
140
+ csvDate(issue.slaDueAt),
141
+ entry.slaStatus,
142
+ entry.findings.length,
143
+ hasBaseline ? verdictFor(entry) : undefined,
144
+ false,
145
+ undefined,
146
+ undefined,
147
+ undefined,
148
+ undefined,
149
+ undefined,
150
+ ]);
151
+ }
152
+
153
+ for await (const entry of suppressed) {
154
+ const { issue } = entry;
155
+ yield row([
156
+ issue.id,
157
+ issue.title,
158
+ issue.status,
159
+ undefined,
160
+ issue.effectiveSeverity,
161
+ issue.detectedSeverity,
162
+ issue.location,
163
+ issue.parameter,
164
+ issue.cwe,
165
+ csvDate(issue.firstSeen),
166
+ csvDate(issue.lastSeen),
167
+ undefined,
168
+ csvDate(issue.slaDueAt),
169
+ undefined,
170
+ undefined,
171
+ undefined,
172
+ true,
173
+ entry.reason,
174
+ entry.ignoredBy,
175
+ csvDate(entry.ignoredAt),
176
+ csvDate(entry.expiresAt),
177
+ entry.comment,
178
+ ]);
179
+ }
180
+ }
@@ -1,7 +1,11 @@
1
+ import type { Finding } from '../finding.js';
2
+ import { anchorHtmlHeadings } from './anchors.js';
1
3
  import { SEVERITY_ORDER } from '../severity.js';
2
4
  import type { Snapshot, SnapshotIssue } from '../snapshot.js';
3
5
  import {
4
6
  buildReportModel,
7
+ type EvidenceVerbosity,
8
+ VERDICT_LABELS,
5
9
  type ReportModel,
6
10
  type ReportOptions,
7
11
  type RetestVerdict,
@@ -61,6 +65,30 @@ td code, code { font: .88em ui-monospace, SFMono-Regular, Menlo, monospace; word
61
65
  .breached { color: var(--critical); font-weight: 600; }
62
66
  .issue { break-inside: avoid; page-break-inside: avoid; margin-bottom: 1.5rem; }
63
67
  .none { color: var(--muted); font-style: italic; }
68
+ .evidence { color: var(--muted); font-size: .92em; margin: 0 0 1rem; }
69
+ /* Fixed rather than absolute: Chromium repeats a fixed element on every page
70
+ of a PDF, which is the only way one element can mark a whole document. */
71
+ .toc { margin: 2rem 0; }
72
+ .toc h2 { margin-top: 0; }
73
+ .toc ul { list-style: none; padding: 0; margin: 0; }
74
+ .toc li { margin: .2rem 0; }
75
+ .toc-3 { padding-left: 1.25rem; font-size: .94em; }
76
+ .toc a { color: inherit; text-decoration: none; border-bottom: 1px solid var(--rule); }
77
+ .cover {
78
+ min-height: 88vh; display: flex; flex-direction: column; justify-content: center;
79
+ break-after: page; page-break-after: always;
80
+ }
81
+ .cover img { max-height: 5rem; max-width: 18rem; margin-bottom: 2.5rem; }
82
+ .cover h1 { font-size: 2.6rem; margin: 0 0 .5rem; }
83
+ .cover .for { font-size: 1.15rem; margin: 0 0 3rem; }
84
+ .cover .details { color: var(--muted); font-size: .92em; }
85
+ .cover .details div { margin: .15rem 0; }
86
+ .watermark {
87
+ position: fixed; inset: 0; z-index: -1; pointer-events: none;
88
+ display: flex; align-items: center; justify-content: center;
89
+ font: 700 5.5rem/1 ui-serif, Georgia, serif; letter-spacing: .08em;
90
+ color: rgba(20, 24, 31, .07); transform: rotate(-28deg); text-transform: uppercase;
91
+ }
64
92
  footer { margin-top: 3rem; padding-top: .75rem; border-top: 1px solid var(--rule); color: var(--muted); font-size: .85em; }
65
93
 
66
94
  @media print {
@@ -101,8 +129,51 @@ function changeSummary(model: ReportModel): string {
101
129
  }</p>`;
102
130
  }
103
131
 
132
+ /**
133
+ * One supporting finding, in a sentence.
134
+ *
135
+ * Every field here is something that was *found*. Nothing identifies what
136
+ * found it — the engine name has leaked into rendered output twice.
137
+ */
138
+ function evidenceLine(finding: Finding): string {
139
+ const where =
140
+ finding.parameter === undefined
141
+ ? `<code>${esc(finding.location)}</code>`
142
+ : `<code>${esc(finding.location)}</code> (<code>${esc(finding.parameter)}</code>)`;
143
+
144
+ const detail: string[] = [`${finding.detectedSeverity}, seen ${formatDate(finding.createdAt)}`];
145
+ if (finding.cve !== undefined) detail.push(esc(finding.cve));
146
+ if (finding.cvssScore !== undefined) {
147
+ detail.push(
148
+ finding.cvssVector === undefined
149
+ ? `CVSS ${String(finding.cvssScore)}`
150
+ : `CVSS ${String(finding.cvssScore)} <code>${esc(finding.cvssVector)}</code>`,
151
+ );
152
+ }
153
+ if (finding.confidence !== undefined) {
154
+ detail.push(`confidence ${String(Math.round(finding.confidence * 100))}%`);
155
+ }
156
+ const captures = finding.evidenceUri ?? [];
157
+ if (captures.length > 0) {
158
+ detail.push(`${String(captures.length)} capture${captures.length === 1 ? '' : 's'} stored`);
159
+ }
160
+
161
+ return `${where} — ${detail.join('; ')}`;
162
+ }
163
+
164
+ /** The count, the detail, or nothing — never the engine. */
165
+ function evidenceBlock(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string {
166
+ if (entry.findings.length === 0 || verbosity === 'none') return '';
167
+ if (verbosity === 'summary') {
168
+ return `<p class="lede">Evidence: ${entry.findings.length} finding${entry.findings.length === 1 ? '' : 's'} recorded.</p>`;
169
+ }
170
+ return `<p class="lede">Evidence</p><ul class="evidence">${entry.findings
171
+ .map((f) => `<li>${evidenceLine(f)}</li>`)
172
+ .join('')}</ul>`;
173
+ }
174
+
104
175
  /** One issue in full. */
105
- function issueDetail(entry: SnapshotIssue): string {
176
+ function issueDetail(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string {
106
177
  const { issue } = entry;
107
178
  const first = entry.findings[0];
108
179
  const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
@@ -129,11 +200,7 @@ ${issue.cwe === undefined ? '' : `<tr><td>Weakness</td><td>${esc(issue.cwe)}</td
129
200
  </tbody></table>
130
201
  ${first?.description === undefined ? '' : `<p>${esc(first.description)}</p>`}
131
202
  ${first?.recommendation === undefined ? '' : `<p><strong>Recommendation.</strong> ${esc(first.recommendation)}</p>`}
132
- ${
133
- entry.findings.length === 0
134
- ? ''
135
- : `<p class="lede">Evidence: ${entry.findings.length} finding${entry.findings.length === 1 ? '' : 's'} recorded.</p>`
136
- }
203
+ ${evidenceBlock(entry, verbosity)}
137
204
  ${
138
205
  references.length === 0
139
206
  ? ''
@@ -142,15 +209,6 @@ ${
142
209
  </div>`;
143
210
  }
144
211
 
145
- /** How a verdict is printed. The dashes are an implementation detail. */
146
- const VERDICT_LABELS: Readonly<Record<RetestVerdict, string>> = Object.freeze({
147
- fixed: 'fixed',
148
- still_present: 'still present',
149
- returned: 'returned',
150
- not_retested: 'not retested',
151
- new: 'new since',
152
- });
153
-
154
212
  /** The Retest body: the difference between two runs, and nothing else. */
155
213
  function retestBody(model: ReportModel): string {
156
214
  // `buildReportModel` refuses to build a retest without one.
@@ -291,7 +349,7 @@ function attestationBody(model: ReportModel): string {
291
349
  ).join('');
292
350
 
293
351
  return `<h2>Statement</h2>
294
- <p>${esc(model.preparedBy ?? 'Secureport')} carried out security testing of <strong>${esc(
352
+ <p>${esc(model.attestor)} carried out security testing of <strong>${esc(
295
353
  snapshot.target.name,
296
354
  )}</strong> (${esc(snapshot.target.url)}) on ${formatDate(snapshot.run.createdAt)}.</p>
297
355
  <p>${esc(model.basis.statement)}</p>
@@ -360,7 +418,13 @@ ${changeSummary(model)}`;
360
418
  } else if (model.kind === 'attest') {
361
419
  findings = attestationBody(model);
362
420
  } else if (model.sections.length === 0) {
363
- findings = '<h2>Findings</h2><p class="none">No issues were found.</p>';
421
+ // "No issues were found" is false when a floor is what emptied the
422
+ // section, and it is the most dangerous sentence in the document to get
423
+ // wrong — a reader would take it as a clean result.
424
+ findings =
425
+ model.omitted === undefined
426
+ ? '<h2>Findings</h2><p class="none">No issues were found.</p>'
427
+ : '<h2>Findings</h2><p class="none">No issues at or above the reporting threshold were found.</p>';
364
428
  } else if (model.kind === 'pen') {
365
429
  findings =
366
430
  '<h2>Findings</h2>' +
@@ -368,7 +432,7 @@ ${changeSummary(model)}`;
368
432
  .map(
369
433
  (section) =>
370
434
  `<h3 class="${sevClass(section.severity)}">${section.severity} (${section.issues.length})</h3>` +
371
- section.issues.map(issueDetail).join(''),
435
+ section.issues.map((e) => issueDetail(e, model.evidenceVerbosity)).join(''),
372
436
  )
373
437
  .join('');
374
438
  } else {
@@ -388,6 +452,15 @@ ${changeSummary(model)}`;
388
452
  findings = `<h2>Findings</h2><table><thead><tr><th>Issue</th><th>Severity</th><th>Location</th><th>Change</th><th>Days open</th><th>Remediation</th></tr></thead><tbody>${rows}</tbody></table>`;
389
453
  }
390
454
 
455
+ // Beside the section it is about, rather than left to the limitations list,
456
+ // which only the Attestation Letter renders (B109). The Executive Summary
457
+ // and the Attestation have no findings section to attach it to, and both
458
+ // already print counts rather than issues, so neither is misled by its
459
+ // absence.
460
+ if (model.omitted !== undefined && model.kind !== 'exec' && model.kind !== 'attest') {
461
+ findings += `<p class="lede">${esc(model.omitted.statement)}</p>`;
462
+ }
463
+
391
464
  const resolved =
392
465
  model.kind === 'exec' ||
393
466
  model.kind === 'attest' ||
@@ -425,25 +498,72 @@ ${changeSummary(model)}`;
425
498
  )
426
499
  .join('')}</tbody></table>`;
427
500
 
501
+ // Appended rather than interpolated into STYLES, so the base stylesheet is
502
+ // one constant that every report shares and the brand is visibly an
503
+ // override. `primaryColour` is refused unless it is a hex triplet
504
+ // (`buildReportModel`), which is what makes this safe to put in a <style>
505
+ // element at all — `esc` protects text nodes, not CSS.
506
+ const accent = model.branding?.primaryColour;
507
+ const brandStyles =
508
+ accent === undefined
509
+ ? ''
510
+ : `\n:root { --accent: ${accent}; }\nh1 { color: var(--accent); }\nh2 { border-bottom-color: var(--accent); }`;
511
+
512
+ const brand = model.branding;
513
+ const cover = !model.coverPage
514
+ ? ''
515
+ : `<section class="cover">
516
+ ${brand?.logo === undefined ? '' : `<img src="${esc(brand.logo)}" alt="${esc(model.attestor)}">`}
517
+ <h1>${esc(model.title)}</h1>
518
+ <p class="for"><strong>${esc(target.name)}</strong> — ${esc(target.url)}</p>
519
+ <div class="details">
520
+ <div>${esc(model.attestor)}</div>
521
+ ${(brand?.companyDetails ?? []).map((line) => `<div>${esc(line)}</div>`).join('\n')}
522
+ <div>${formatDate(model.generatedAt)}</div>
523
+ ${model.preparedFor === undefined ? '' : `<div>Prepared for ${esc(model.preparedFor)}</div>`}
524
+ </div>
525
+ </section>`;
526
+
527
+ const watermark =
528
+ model.branding?.watermark === undefined
529
+ ? ''
530
+ : `<div class="watermark" aria-hidden="true">${esc(model.branding.watermark)}</div>`;
531
+
532
+ // One scan produces both, so an entry cannot point at an anchor that does
533
+ // not exist. Always run, even when no list is printed: the anchors are what
534
+ // Chromium turns into a PDF outline.
535
+ const body = anchorHtmlHeadings([preamble, findings, resolved, suppressed].join('\n'));
536
+
537
+ const contents = !model.tableOfContents
538
+ ? ''
539
+ : `<nav class="toc"><h2>Contents</h2><ul>${body.toc
540
+ .map(
541
+ (entry) =>
542
+ `<li class="toc-${String(entry.level)}"><a href="#${entry.id}">${entry.text}</a></li>`,
543
+ )
544
+ .join('')}</ul></nav>`;
545
+
428
546
  return `<!doctype html>
429
547
  <html lang="en">
430
548
  <head>
431
549
  <meta charset="utf-8">
432
550
  <meta name="viewport" content="width=device-width, initial-scale=1">
433
551
  <title>${esc(model.title)} — ${esc(target.name)}</title>
434
- <style>${STYLES}</style>
552
+ <style>${STYLES}${brandStyles}</style>
435
553
  </head>
436
554
  <body>
555
+ ${watermark}
556
+ ${cover}
437
557
  <h1>${esc(model.title)}</h1>
438
558
  <p class="lede"><strong>${esc(target.name)}</strong> — ${esc(target.url)}</p>
439
559
  <table class="meta"><tbody>${meta}</tbody></table>
440
- ${preamble}
441
- ${findings}
442
- ${resolved}
443
- ${suppressed}
444
- <footer>Generated by Secureport from run ${esc(snapshot.run.runId)}. Issue state as of ${formatDate(
445
- snapshot.run.createdAt,
446
- )}.</footer>
560
+ ${contents}
561
+ ${body.html}
562
+ <footer>${
563
+ model.branding?.whiteLabel === true
564
+ ? `Run ${esc(snapshot.run.runId)}`
565
+ : `Generated by Secureport from run ${esc(snapshot.run.runId)}`
566
+ }. Issue state as of ${formatDate(snapshot.run.createdAt)}.</footer>
447
567
  </body>
448
568
  </html>`;
449
569
  }
@@ -34,6 +34,22 @@ export interface JsonReport {
34
34
  /** Who it is for, if stated. */
35
35
  readonly preparedFor?: string;
36
36
 
37
+ /**
38
+ * The party that carried out the testing.
39
+ *
40
+ * **Content rather than presentation, which is why this is the one branding-
41
+ * derived value the JSON carries** (7.4b). A logo and an accent colour
42
+ * describe how a document looks and mean nothing to a consumer building a
43
+ * dashboard; who attested is the substance of an Attestation Letter, and a
44
+ * machine-readable report that omitted it would be missing the claim the
45
+ * document exists to make.
46
+ *
47
+ * Resolved the same way the prose resolves it: `preparedBy`, else the
48
+ * branding's `companyName`, else `Secureport`. Always present, because every
49
+ * report is produced by somebody.
50
+ */
51
+ readonly attestor: string;
52
+
37
53
  /**
38
54
  * How the findings were produced.
39
55
  *
@@ -104,6 +120,7 @@ export function renderJson(snapshot: Snapshot, options: ReportOptions): string {
104
120
  generatedAt: model.generatedAt.toISOString(),
105
121
  ...(model.preparedBy === undefined ? {} : { preparedBy: model.preparedBy }),
106
122
  ...(model.preparedFor === undefined ? {} : { preparedFor: model.preparedFor }),
123
+ attestor: model.attestor,
107
124
  basis: {
108
125
  automated: model.basis.automated,
109
126
  uploaded: model.basis.uploaded,