@secureport/core 1.0.0 → 2.2.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 (51) hide show
  1. package/README.md +2 -1
  2. package/dist/finding.d.ts +9 -8
  3. package/dist/finding.d.ts.map +1 -1
  4. package/dist/fingerprint.d.ts +12 -5
  5. package/dist/fingerprint.d.ts.map +1 -1
  6. package/dist/fingerprint.js +74 -8
  7. package/dist/fingerprint.js.map +1 -1
  8. package/dist/import/nessus.d.ts +9 -6
  9. package/dist/import/nessus.d.ts.map +1 -1
  10. package/dist/import/nessus.js +9 -6
  11. package/dist/import/nessus.js.map +1 -1
  12. package/dist/index.d.ts +6 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/issue.d.ts +1 -1
  17. package/dist/issue.d.ts.map +1 -1
  18. package/dist/reconcile.d.ts +71 -1
  19. package/dist/reconcile.d.ts.map +1 -1
  20. package/dist/reconcile.js +64 -4
  21. package/dist/reconcile.js.map +1 -1
  22. package/dist/refingerprint.d.ts +116 -0
  23. package/dist/refingerprint.d.ts.map +1 -0
  24. package/dist/refingerprint.js +190 -0
  25. package/dist/refingerprint.js.map +1 -0
  26. package/dist/report/anchors.d.ts +39 -0
  27. package/dist/report/anchors.d.ts.map +1 -0
  28. package/dist/report/anchors.js +73 -0
  29. package/dist/report/anchors.js.map +1 -0
  30. package/dist/report/html.d.ts.map +1 -1
  31. package/dist/report/html.js +126 -22
  32. package/dist/report/html.js.map +1 -1
  33. package/dist/report/markdown.d.ts.map +1 -1
  34. package/dist/report/markdown.js +112 -26
  35. package/dist/report/markdown.js.map +1 -1
  36. package/dist/report/model.d.ts +227 -1
  37. package/dist/report/model.d.ts.map +1 -1
  38. package/dist/report/model.js +87 -8
  39. package/dist/report/model.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/finding.ts +9 -8
  42. package/src/fingerprint.ts +76 -10
  43. package/src/import/nessus.ts +9 -6
  44. package/src/index.ts +14 -2
  45. package/src/issue.ts +1 -0
  46. package/src/reconcile.ts +121 -5
  47. package/src/refingerprint.ts +282 -0
  48. package/src/report/anchors.ts +96 -0
  49. package/src/report/html.ts +146 -26
  50. package/src/report/markdown.ts +125 -25
  51. package/src/report/model.ts +344 -7
@@ -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
  }
@@ -1,7 +1,11 @@
1
+ import type { Finding } from '../finding.js';
2
+ import { markdownHeadings } 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,
@@ -51,7 +55,7 @@ function changeSummary(model: ReportModel): string[] {
51
55
  }
52
56
 
53
57
  /** One issue, in full. The Penetration Test presentation. */
54
- function issueDetail(entry: SnapshotIssue): string[] {
58
+ function issueDetail(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string[] {
55
59
  const { issue } = entry;
56
60
  const lines: string[] = [
57
61
  `#### ${issue.title}`,
@@ -81,22 +85,63 @@ function issueDetail(entry: SnapshotIssue): string[] {
81
85
  lines.push('**Recommendation.** ' + first.recommendation, '');
82
86
  }
83
87
 
84
- if (entry.findings.length > 0) {
85
- // The count, not the engine. Which scanner produced a finding is an
86
- // implementation detail of the assessment, and naming the stack in a
87
- // customer-facing document gives away more than it explains.
88
+ // The count or the detail, never the engine. Which scanner produced a
89
+ // finding is an implementation detail of the assessment, and naming the
90
+ // stack in a customer-facing document gives away more than it explains —
91
+ // it has leaked into rendered output twice, so no branch below reads
92
+ // `sourceEngine` or `sourceRuleId`.
93
+ if (entry.findings.length > 0 && verbosity === 'summary') {
88
94
  lines.push(
89
95
  `_Evidence: ${entry.findings.length} finding${entry.findings.length === 1 ? '' : 's'} recorded._`,
90
96
  '',
91
97
  );
92
- const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
93
- if (references.length > 0) {
94
- lines.push(...references.map((r) => `- ${r}`), '');
95
- }
98
+ } else if (entry.findings.length > 0 && verbosity === 'full') {
99
+ lines.push('**Evidence**', '');
100
+ for (const finding of entry.findings) lines.push(`- ${evidenceLine(finding)}`);
101
+ lines.push('');
102
+ }
103
+
104
+ // Outside the verbosity branch: an advisory is external reading, not
105
+ // evidence, so `none` still lists it.
106
+ const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
107
+ if (references.length > 0) {
108
+ lines.push(...references.map((r) => `- ${r}`), '');
96
109
  }
97
110
  return lines;
98
111
  }
99
112
 
113
+ /**
114
+ * One supporting finding, in a sentence.
115
+ *
116
+ * Every field here is something that was *found*. Nothing identifies what
117
+ * found it.
118
+ */
119
+ function evidenceLine(finding: Finding): string {
120
+ const where =
121
+ finding.parameter === undefined
122
+ ? `\`${cell(finding.location)}\``
123
+ : `\`${cell(finding.location)}\` (\`${cell(finding.parameter)}\`)`;
124
+
125
+ const detail: string[] = [`${finding.detectedSeverity}, seen ${formatDate(finding.createdAt)}`];
126
+ if (finding.cve !== undefined) detail.push(cell(finding.cve));
127
+ if (finding.cvssScore !== undefined) {
128
+ detail.push(
129
+ finding.cvssVector === undefined
130
+ ? `CVSS ${finding.cvssScore}`
131
+ : `CVSS ${finding.cvssScore} \`${cell(finding.cvssVector)}\``,
132
+ );
133
+ }
134
+ if (finding.confidence !== undefined) {
135
+ detail.push(`confidence ${Math.round(finding.confidence * 100)}%`);
136
+ }
137
+ const captures = finding.evidenceUri ?? [];
138
+ if (captures.length > 0) {
139
+ detail.push(`${captures.length} capture${captures.length === 1 ? '' : 's'} stored`);
140
+ }
141
+
142
+ return `${where} — ${detail.join('; ')}`;
143
+ }
144
+
100
145
  /** One issue, as a table row. The Vulnerability Assessment presentation. */
101
146
  function issueRow(entry: SnapshotIssue): string {
102
147
  const { issue } = entry;
@@ -105,15 +150,6 @@ function issueRow(entry: SnapshotIssue): string {
105
150
  return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | \`${cell(location)}\` | ${entry.change.replace('_', ' ')} | ${entry.daysOpen} | ${entry.slaStatus.replace('_', ' ')} |`;
106
151
  }
107
152
 
108
- /** How a verdict is printed. The dashes are an implementation detail. */
109
- const VERDICT_LABELS: Readonly<Record<RetestVerdict, string>> = Object.freeze({
110
- fixed: 'fixed',
111
- still_present: 'still present',
112
- returned: 'returned',
113
- not_retested: 'not retested',
114
- new: 'new since',
115
- });
116
-
117
153
  /** The Retest body: the difference between two runs, and nothing else. */
118
154
  function retestBody(model: ReportModel): string[] {
119
155
  // `buildReportModel` refuses to build a retest without one.
@@ -299,7 +335,7 @@ function attestationBody(model: ReportModel): string[] {
299
335
  return [
300
336
  '## Statement',
301
337
  '',
302
- `${model.preparedBy ?? 'Secureport'} carried out security testing of **${snapshot.target.name}**`,
338
+ `${model.attestor} carried out security testing of **${snapshot.target.name}**`,
303
339
  `(${snapshot.target.url}) on ${formatDate(snapshot.run.createdAt)}.`,
304
340
  '',
305
341
  model.basis.statement,
@@ -324,6 +360,37 @@ function attestationBody(model: ReportModel): string[] {
324
360
  ];
325
361
  }
326
362
 
363
+ /**
364
+ * The contents list, inserted above the first section.
365
+ *
366
+ * Computed from the finished document rather than from a list kept alongside
367
+ * it, for the reason `anchors.ts` explains: a contents list assembled
368
+ * separately from the headings it points at is one that can point at nothing,
369
+ * and the failure is silent.
370
+ *
371
+ * Markdown headings carry no explicit id, so the links rely on the anchor
372
+ * every common Markdown renderer derives from the heading text. `slug` matches
373
+ * that convention, which is the only way this can work at all.
374
+ */
375
+ function withContents(lines: readonly string[], model: ReportModel): string {
376
+ if (!model.tableOfContents) return lines.join('\n');
377
+
378
+ // Before inserting, so the contents list does not list itself.
379
+ const toc = markdownHeadings(lines);
380
+ if (toc.length === 0) return lines.join('\n');
381
+
382
+ const block = [
383
+ '## Contents',
384
+ '',
385
+ ...toc.map((entry) => `${entry.level === 3 ? ' ' : ''}- [${entry.text}](#${entry.id})`),
386
+ '',
387
+ ];
388
+
389
+ const firstSection = lines.findIndex((line) => /^##\s/u.test(line));
390
+ const at = firstSection < 0 ? lines.length : firstSection;
391
+ return [...lines.slice(0, at), ...block, ...lines.slice(at)].join('\n');
392
+ }
393
+
327
394
  /**
328
395
  * Renders a snapshot as Markdown.
329
396
  *
@@ -347,9 +414,24 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
347
414
  const { target } = snapshot;
348
415
 
349
416
  const lines: string[] = [
417
+ // Markdown has no pages and no rotation, so the stamp the HTML puts across
418
+ // every page becomes a line at the top. Same statement, in the only form
419
+ // this format has for it.
420
+ ...(model.branding?.watermark === undefined ? [] : [`**${model.branding.watermark}**`, '']),
350
421
  `# ${model.title}`,
351
422
  '',
352
423
  `**${target.name}** — ${target.url}`,
424
+ // Markdown has no pages, so the cover is a block under the title rather
425
+ // than a page of its own. The same facts, in the only form this format
426
+ // has for them.
427
+ ...(model.coverPage
428
+ ? [
429
+ '',
430
+ model.attestor,
431
+ ...(model.branding?.companyDetails ?? []),
432
+ ...(model.preparedFor === undefined ? [] : [`Prepared for ${model.preparedFor}`]),
433
+ ].map((line, i) => (i === 0 ? line : `_${line}_`))
434
+ : []),
353
435
  '',
354
436
  `| | |`,
355
437
  `| --- | --- |`,
@@ -366,7 +448,7 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
366
448
  // reader stops trusting.
367
449
  if (model.kind === 'attest') {
368
450
  lines.push(...attestationBody(model), ...suppressedAppendix(snapshot), '');
369
- return lines.join('\n');
451
+ return withContents(lines, model);
370
452
  }
371
453
 
372
454
  lines.push('## Basis of testing', '', model.basis.statement, '');
@@ -385,21 +467,33 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
385
467
 
386
468
  if (model.kind === 'exec') {
387
469
  lines.push(...executiveBody(model), ...suppressedAppendix(snapshot), '');
388
- return lines.join('\n');
470
+ return withContents(lines, model);
389
471
  }
390
472
 
391
473
  if (model.kind === 'retest') {
392
474
  lines.push(...retestBody(model), ...suppressedAppendix(snapshot), '');
393
- return lines.join('\n');
475
+ return withContents(lines, model);
394
476
  }
395
477
 
396
478
  if (model.sections.length === 0) {
397
- lines.push('## Findings', '', '_No issues were found._', '');
479
+ // "No issues were found" is false when a floor is what emptied the
480
+ // section, and it is the most dangerous sentence in the document to get
481
+ // wrong — a reader would take it as a clean result.
482
+ lines.push(
483
+ '## Findings',
484
+ '',
485
+ model.omitted === undefined
486
+ ? '_No issues were found._'
487
+ : '_No issues at or above the reporting threshold were found._',
488
+ '',
489
+ );
398
490
  } else if (model.kind === 'pen') {
399
491
  lines.push('## Findings', '');
400
492
  for (const section of model.sections) {
401
493
  lines.push(`### ${section.severity} (${section.issues.length})`, '');
402
- for (const entry of section.issues) lines.push(...issueDetail(entry));
494
+ for (const entry of section.issues) {
495
+ lines.push(...issueDetail(entry, model.evidenceVerbosity));
496
+ }
403
497
  }
404
498
  } else {
405
499
  lines.push(
@@ -414,6 +508,12 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
414
508
  lines.push('');
415
509
  }
416
510
 
511
+ // Printed here, beside the section it is about, rather than left to the
512
+ // limitations list — which only the Attestation Letter renders (B109). A
513
+ // floor that removed issues from this section and said so nowhere the reader
514
+ // will look is the quiet form of the over-claim §7 exists to stop.
515
+ if (model.omitted !== undefined) lines.push(`_${model.omitted.statement}_`, '');
516
+
417
517
  if (model.resolved.length > 0) {
418
518
  lines.push(
419
519
  '## Resolved since the last run',
@@ -431,5 +531,5 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
431
531
  }
432
532
 
433
533
  lines.push(...suppressedAppendix(snapshot), '');
434
- return lines.join('\n');
534
+ return withContents(lines, model);
435
535
  }