@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.
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +46 -1
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +34 -1
- package/dist/reconcile.js.map +1 -1
- 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/csv.d.ts +41 -0
- package/dist/report/csv.d.ts.map +1 -0
- package/dist/report/csv.js +152 -0
- package/dist/report/csv.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/json.d.ts +15 -0
- package/dist/report/json.d.ts.map +1 -1
- package/dist/report/json.js +1 -0
- package/dist/report/json.js.map +1 -1
- package/dist/report/markdown.d.ts +24 -2
- package/dist/report/markdown.d.ts.map +1 -1
- package/dist/report/markdown.js +133 -31
- package/dist/report/markdown.js.map +1 -1
- package/dist/report/model.d.ts +288 -2
- package/dist/report/model.d.ts.map +1 -1
- package/dist/report/model.js +153 -21
- package/dist/report/model.js.map +1 -1
- package/dist/report/sarif.d.ts +60 -0
- package/dist/report/sarif.d.ts.map +1 -0
- package/dist/report/sarif.js +125 -0
- package/dist/report/sarif.js.map +1 -0
- package/dist/report/stream.d.ts +108 -0
- package/dist/report/stream.d.ts.map +1 -0
- package/dist/report/stream.js +534 -0
- package/dist/report/stream.js.map +1 -0
- package/package.json +4 -2
- package/src/index.ts +11 -2
- package/src/issue.ts +1 -0
- package/src/reconcile.ts +86 -2
- package/src/report/anchors.ts +96 -0
- package/src/report/csv.ts +180 -0
- package/src/report/html.ts +146 -26
- package/src/report/json.ts +17 -0
- package/src/report/markdown.ts +146 -30
- package/src/report/model.ts +433 -21
- package/src/report/sarif.ts +166 -0
- package/src/report/stream.ts +702 -0
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,
|
|
@@ -12,8 +16,14 @@ export function formatDate(date: Date): string {
|
|
|
12
16
|
return date.toISOString().slice(0, 10);
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Escapes the characters that would break out of a Markdown table cell.
|
|
21
|
+
*
|
|
22
|
+
* Exported: the streaming renderer formats a row the moment it sees it, and
|
|
23
|
+
* needs the exact same escaping — a second copy is a copy free to miss the
|
|
24
|
+
* next character somebody discovers breaks a table.
|
|
25
|
+
*/
|
|
26
|
+
export function cell(value: string): string {
|
|
17
27
|
return value.replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
|
|
18
28
|
}
|
|
19
29
|
|
|
@@ -50,8 +60,14 @@ function changeSummary(model: ReportModel): string[] {
|
|
|
50
60
|
];
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
63
|
+
/**
|
|
64
|
+
* One issue, in full. The Penetration Test presentation.
|
|
65
|
+
*
|
|
66
|
+
* Exported: it is already a pure per-issue function needing nothing but the
|
|
67
|
+
* one entry, which is exactly what the streaming renderer can offer it a row
|
|
68
|
+
* at a time — the same reason `verdictFor` is exported from `model.ts`.
|
|
69
|
+
*/
|
|
70
|
+
export function issueDetail(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string[] {
|
|
55
71
|
const { issue } = entry;
|
|
56
72
|
const lines: string[] = [
|
|
57
73
|
`#### ${issue.title}`,
|
|
@@ -81,39 +97,75 @@ function issueDetail(entry: SnapshotIssue): string[] {
|
|
|
81
97
|
lines.push('**Recommendation.** ' + first.recommendation, '');
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
// The count or the detail, never the engine. Which scanner produced a
|
|
101
|
+
// finding is an implementation detail of the assessment, and naming the
|
|
102
|
+
// stack in a customer-facing document gives away more than it explains —
|
|
103
|
+
// it has leaked into rendered output twice, so no branch below reads
|
|
104
|
+
// `sourceEngine` or `sourceRuleId`.
|
|
105
|
+
if (entry.findings.length > 0 && verbosity === 'summary') {
|
|
88
106
|
lines.push(
|
|
89
107
|
`_Evidence: ${entry.findings.length} finding${entry.findings.length === 1 ? '' : 's'} recorded._`,
|
|
90
108
|
'',
|
|
91
109
|
);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
} else if (entry.findings.length > 0 && verbosity === 'full') {
|
|
111
|
+
lines.push('**Evidence**', '');
|
|
112
|
+
for (const finding of entry.findings) lines.push(`- ${evidenceLine(finding)}`);
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Outside the verbosity branch: an advisory is external reading, not
|
|
117
|
+
// evidence, so `none` still lists it.
|
|
118
|
+
const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
|
|
119
|
+
if (references.length > 0) {
|
|
120
|
+
lines.push(...references.map((r) => `- ${r}`), '');
|
|
96
121
|
}
|
|
97
122
|
return lines;
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
125
|
+
/**
|
|
126
|
+
* One supporting finding, in a sentence.
|
|
127
|
+
*
|
|
128
|
+
* Every field here is something that was *found*. Nothing identifies what
|
|
129
|
+
* found it.
|
|
130
|
+
*/
|
|
131
|
+
function evidenceLine(finding: Finding): string {
|
|
132
|
+
const where =
|
|
133
|
+
finding.parameter === undefined
|
|
134
|
+
? `\`${cell(finding.location)}\``
|
|
135
|
+
: `\`${cell(finding.location)}\` (\`${cell(finding.parameter)}\`)`;
|
|
136
|
+
|
|
137
|
+
const detail: string[] = [`${finding.detectedSeverity}, seen ${formatDate(finding.createdAt)}`];
|
|
138
|
+
if (finding.cve !== undefined) detail.push(cell(finding.cve));
|
|
139
|
+
if (finding.cvssScore !== undefined) {
|
|
140
|
+
detail.push(
|
|
141
|
+
finding.cvssVector === undefined
|
|
142
|
+
? `CVSS ${finding.cvssScore}`
|
|
143
|
+
: `CVSS ${finding.cvssScore} \`${cell(finding.cvssVector)}\``,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (finding.confidence !== undefined) {
|
|
147
|
+
detail.push(`confidence ${Math.round(finding.confidence * 100)}%`);
|
|
148
|
+
}
|
|
149
|
+
const captures = finding.evidenceUri ?? [];
|
|
150
|
+
if (captures.length > 0) {
|
|
151
|
+
detail.push(`${captures.length} capture${captures.length === 1 ? '' : 's'} stored`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return `${where} — ${detail.join('; ')}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One issue, as a table row. The Vulnerability Assessment presentation.
|
|
159
|
+
*
|
|
160
|
+
* Exported for the same reason {@link issueDetail} is.
|
|
161
|
+
*/
|
|
162
|
+
export function issueRow(entry: SnapshotIssue): string {
|
|
102
163
|
const { issue } = entry;
|
|
103
164
|
const location =
|
|
104
165
|
issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
|
|
105
166
|
return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | \`${cell(location)}\` | ${entry.change.replace('_', ' ')} | ${entry.daysOpen} | ${entry.slaStatus.replace('_', ' ')} |`;
|
|
106
167
|
}
|
|
107
168
|
|
|
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
169
|
/** The Retest body: the difference between two runs, and nothing else. */
|
|
118
170
|
function retestBody(model: ReportModel): string[] {
|
|
119
171
|
// `buildReportModel` refuses to build a retest without one.
|
|
@@ -299,7 +351,7 @@ function attestationBody(model: ReportModel): string[] {
|
|
|
299
351
|
return [
|
|
300
352
|
'## Statement',
|
|
301
353
|
'',
|
|
302
|
-
`${model.
|
|
354
|
+
`${model.attestor} carried out security testing of **${snapshot.target.name}**`,
|
|
303
355
|
`(${snapshot.target.url}) on ${formatDate(snapshot.run.createdAt)}.`,
|
|
304
356
|
'',
|
|
305
357
|
model.basis.statement,
|
|
@@ -324,6 +376,37 @@ function attestationBody(model: ReportModel): string[] {
|
|
|
324
376
|
];
|
|
325
377
|
}
|
|
326
378
|
|
|
379
|
+
/**
|
|
380
|
+
* The contents list, inserted above the first section.
|
|
381
|
+
*
|
|
382
|
+
* Computed from the finished document rather than from a list kept alongside
|
|
383
|
+
* it, for the reason `anchors.ts` explains: a contents list assembled
|
|
384
|
+
* separately from the headings it points at is one that can point at nothing,
|
|
385
|
+
* and the failure is silent.
|
|
386
|
+
*
|
|
387
|
+
* Markdown headings carry no explicit id, so the links rely on the anchor
|
|
388
|
+
* every common Markdown renderer derives from the heading text. `slug` matches
|
|
389
|
+
* that convention, which is the only way this can work at all.
|
|
390
|
+
*/
|
|
391
|
+
function withContents(lines: readonly string[], model: ReportModel): string {
|
|
392
|
+
if (!model.tableOfContents) return lines.join('\n');
|
|
393
|
+
|
|
394
|
+
// Before inserting, so the contents list does not list itself.
|
|
395
|
+
const toc = markdownHeadings(lines);
|
|
396
|
+
if (toc.length === 0) return lines.join('\n');
|
|
397
|
+
|
|
398
|
+
const block = [
|
|
399
|
+
'## Contents',
|
|
400
|
+
'',
|
|
401
|
+
...toc.map((entry) => `${entry.level === 3 ? ' ' : ''}- [${entry.text}](#${entry.id})`),
|
|
402
|
+
'',
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
const firstSection = lines.findIndex((line) => /^##\s/u.test(line));
|
|
406
|
+
const at = firstSection < 0 ? lines.length : firstSection;
|
|
407
|
+
return [...lines.slice(0, at), ...block, ...lines.slice(at)].join('\n');
|
|
408
|
+
}
|
|
409
|
+
|
|
327
410
|
/**
|
|
328
411
|
* Renders a snapshot as Markdown.
|
|
329
412
|
*
|
|
@@ -347,9 +430,24 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
347
430
|
const { target } = snapshot;
|
|
348
431
|
|
|
349
432
|
const lines: string[] = [
|
|
433
|
+
// Markdown has no pages and no rotation, so the stamp the HTML puts across
|
|
434
|
+
// every page becomes a line at the top. Same statement, in the only form
|
|
435
|
+
// this format has for it.
|
|
436
|
+
...(model.branding?.watermark === undefined ? [] : [`**${model.branding.watermark}**`, '']),
|
|
350
437
|
`# ${model.title}`,
|
|
351
438
|
'',
|
|
352
439
|
`**${target.name}** — ${target.url}`,
|
|
440
|
+
// Markdown has no pages, so the cover is a block under the title rather
|
|
441
|
+
// than a page of its own. The same facts, in the only form this format
|
|
442
|
+
// has for them.
|
|
443
|
+
...(model.coverPage
|
|
444
|
+
? [
|
|
445
|
+
'',
|
|
446
|
+
model.attestor,
|
|
447
|
+
...(model.branding?.companyDetails ?? []),
|
|
448
|
+
...(model.preparedFor === undefined ? [] : [`Prepared for ${model.preparedFor}`]),
|
|
449
|
+
].map((line, i) => (i === 0 ? line : `_${line}_`))
|
|
450
|
+
: []),
|
|
353
451
|
'',
|
|
354
452
|
`| | |`,
|
|
355
453
|
`| --- | --- |`,
|
|
@@ -366,7 +464,7 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
366
464
|
// reader stops trusting.
|
|
367
465
|
if (model.kind === 'attest') {
|
|
368
466
|
lines.push(...attestationBody(model), ...suppressedAppendix(snapshot), '');
|
|
369
|
-
return lines
|
|
467
|
+
return withContents(lines, model);
|
|
370
468
|
}
|
|
371
469
|
|
|
372
470
|
lines.push('## Basis of testing', '', model.basis.statement, '');
|
|
@@ -385,21 +483,33 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
385
483
|
|
|
386
484
|
if (model.kind === 'exec') {
|
|
387
485
|
lines.push(...executiveBody(model), ...suppressedAppendix(snapshot), '');
|
|
388
|
-
return lines
|
|
486
|
+
return withContents(lines, model);
|
|
389
487
|
}
|
|
390
488
|
|
|
391
489
|
if (model.kind === 'retest') {
|
|
392
490
|
lines.push(...retestBody(model), ...suppressedAppendix(snapshot), '');
|
|
393
|
-
return lines
|
|
491
|
+
return withContents(lines, model);
|
|
394
492
|
}
|
|
395
493
|
|
|
396
494
|
if (model.sections.length === 0) {
|
|
397
|
-
|
|
495
|
+
// "No issues were found" is false when a floor is what emptied the
|
|
496
|
+
// section, and it is the most dangerous sentence in the document to get
|
|
497
|
+
// wrong — a reader would take it as a clean result.
|
|
498
|
+
lines.push(
|
|
499
|
+
'## Findings',
|
|
500
|
+
'',
|
|
501
|
+
model.omitted === undefined
|
|
502
|
+
? '_No issues were found._'
|
|
503
|
+
: '_No issues at or above the reporting threshold were found._',
|
|
504
|
+
'',
|
|
505
|
+
);
|
|
398
506
|
} else if (model.kind === 'pen') {
|
|
399
507
|
lines.push('## Findings', '');
|
|
400
508
|
for (const section of model.sections) {
|
|
401
509
|
lines.push(`### ${section.severity} (${section.issues.length})`, '');
|
|
402
|
-
for (const entry of section.issues)
|
|
510
|
+
for (const entry of section.issues) {
|
|
511
|
+
lines.push(...issueDetail(entry, model.evidenceVerbosity));
|
|
512
|
+
}
|
|
403
513
|
}
|
|
404
514
|
} else {
|
|
405
515
|
lines.push(
|
|
@@ -414,6 +524,12 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
414
524
|
lines.push('');
|
|
415
525
|
}
|
|
416
526
|
|
|
527
|
+
// Printed here, beside the section it is about, rather than left to the
|
|
528
|
+
// limitations list — which only the Attestation Letter renders (B109). A
|
|
529
|
+
// floor that removed issues from this section and said so nowhere the reader
|
|
530
|
+
// will look is the quiet form of the over-claim §7 exists to stop.
|
|
531
|
+
if (model.omitted !== undefined) lines.push(`_${model.omitted.statement}_`, '');
|
|
532
|
+
|
|
417
533
|
if (model.resolved.length > 0) {
|
|
418
534
|
lines.push(
|
|
419
535
|
'## Resolved since the last run',
|
|
@@ -431,5 +547,5 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
431
547
|
}
|
|
432
548
|
|
|
433
549
|
lines.push(...suppressedAppendix(snapshot), '');
|
|
434
|
-
return lines
|
|
550
|
+
return withContents(lines, model);
|
|
435
551
|
}
|