@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.
- package/README.md +2 -1
- package/dist/finding.d.ts +9 -8
- package/dist/finding.d.ts.map +1 -1
- package/dist/fingerprint.d.ts +12 -5
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +74 -8
- package/dist/fingerprint.js.map +1 -1
- package/dist/import/nessus.d.ts +9 -6
- package/dist/import/nessus.d.ts.map +1 -1
- package/dist/import/nessus.js +9 -6
- package/dist/import/nessus.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/issue.d.ts +1 -1
- package/dist/issue.d.ts.map +1 -1
- package/dist/reconcile.d.ts +71 -1
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +64 -4
- package/dist/reconcile.js.map +1 -1
- package/dist/refingerprint.d.ts +116 -0
- package/dist/refingerprint.d.ts.map +1 -0
- package/dist/refingerprint.js +190 -0
- package/dist/refingerprint.js.map +1 -0
- package/dist/report/anchors.d.ts +39 -0
- package/dist/report/anchors.d.ts.map +1 -0
- package/dist/report/anchors.js +73 -0
- package/dist/report/anchors.js.map +1 -0
- package/dist/report/html.d.ts.map +1 -1
- package/dist/report/html.js +126 -22
- package/dist/report/html.js.map +1 -1
- package/dist/report/markdown.d.ts.map +1 -1
- package/dist/report/markdown.js +112 -26
- package/dist/report/markdown.js.map +1 -1
- package/dist/report/model.d.ts +227 -1
- package/dist/report/model.d.ts.map +1 -1
- package/dist/report/model.js +87 -8
- package/dist/report/model.js.map +1 -1
- package/package.json +1 -1
- package/src/finding.ts +9 -8
- package/src/fingerprint.ts +76 -10
- package/src/import/nessus.ts +9 -6
- package/src/index.ts +14 -2
- package/src/issue.ts +1 -0
- package/src/reconcile.ts +121 -5
- package/src/refingerprint.ts +282 -0
- package/src/report/anchors.ts +96 -0
- package/src/report/html.ts +146 -26
- package/src/report/markdown.ts +125 -25
- package/src/report/model.ts +344 -7
package/src/report/html.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
${
|
|
441
|
-
${
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
}
|
package/src/report/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
475
|
+
return withContents(lines, model);
|
|
394
476
|
}
|
|
395
477
|
|
|
396
478
|
if (model.sections.length === 0) {
|
|
397
|
-
|
|
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)
|
|
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
|
|
534
|
+
return withContents(lines, model);
|
|
435
535
|
}
|