@secureport/core 2.2.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 +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- 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/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 +22 -6
- package/dist/report/markdown.js.map +1 -1
- package/dist/report/model.d.ts +61 -1
- package/dist/report/model.d.ts.map +1 -1
- package/dist/report/model.js +88 -35
- 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 +7 -1
- package/src/report/csv.ts +180 -0
- package/src/report/json.ts +17 -0
- package/src/report/markdown.ts +22 -6
- package/src/report/model.ts +119 -44
- package/src/report/sarif.ts +166 -0
- package/src/report/stream.ts +702 -0
|
@@ -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
|
+
}
|
package/src/report/json.ts
CHANGED
|
@@ -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,
|
package/src/report/markdown.ts
CHANGED
|
@@ -16,8 +16,14 @@ export function formatDate(date: Date): string {
|
|
|
16
16
|
return date.toISOString().slice(0, 10);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
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 {
|
|
21
27
|
return value.replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -54,8 +60,14 @@ function changeSummary(model: ReportModel): string[] {
|
|
|
54
60
|
];
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
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[] {
|
|
59
71
|
const { issue } = entry;
|
|
60
72
|
const lines: string[] = [
|
|
61
73
|
`#### ${issue.title}`,
|
|
@@ -142,8 +154,12 @@ function evidenceLine(finding: Finding): string {
|
|
|
142
154
|
return `${where} — ${detail.join('; ')}`;
|
|
143
155
|
}
|
|
144
156
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
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 {
|
|
147
163
|
const { issue } = entry;
|
|
148
164
|
const location =
|
|
149
165
|
issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
|
package/src/report/model.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RunSummary } from '../run.js';
|
|
1
|
+
import type { RunKind, RunSummary } from '../run.js';
|
|
2
2
|
import type { Severity } from '../severity.js';
|
|
3
3
|
import { SEVERITY_ORDER } from '../severity.js';
|
|
4
4
|
import type { Snapshot, SnapshotIssue } from '../snapshot.js';
|
|
@@ -534,18 +534,31 @@ function describeRetest(snapshot: Snapshot): RetestOutcome | undefined {
|
|
|
534
534
|
* an issue left open because the run never covered it looks identical to one
|
|
535
535
|
* left open because the run found it again, and telling a reader those are the
|
|
536
536
|
* same thing is the failure this report exists to avoid.
|
|
537
|
+
*
|
|
538
|
+
* Exported: it is per-issue and needs nothing but the one entry, which is
|
|
539
|
+
* exactly what the streaming machine renderers can offer it a row at a time.
|
|
537
540
|
*/
|
|
538
|
-
function verdictFor(entry: SnapshotIssue): RetestVerdict {
|
|
541
|
+
export function verdictFor(entry: SnapshotIssue): RetestVerdict {
|
|
539
542
|
if (entry.change === 'resolved') return 'fixed';
|
|
540
543
|
if (entry.change === 'regressed') return 'returned';
|
|
541
544
|
if (entry.change === 'new') return 'new';
|
|
542
545
|
return entry.findings.length > 0 ? 'still_present' : 'not_retested';
|
|
543
546
|
}
|
|
544
547
|
|
|
545
|
-
/**
|
|
546
|
-
|
|
547
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Works out what a report cannot establish, from the run rather than a
|
|
550
|
+
* template.
|
|
551
|
+
*
|
|
552
|
+
* Takes primitives rather than a {@link Snapshot} so the streaming machine
|
|
553
|
+
* renderers can produce the same wording from a fold over a cursor, without
|
|
554
|
+
* needing the eagerly-materialised issue array this package's PDF/HTML path
|
|
555
|
+
* builds.
|
|
556
|
+
*/
|
|
557
|
+
export function describeLimitations(
|
|
558
|
+
coveragePaths: readonly string[],
|
|
559
|
+
runCreatedAt: Date,
|
|
548
560
|
basis: TestingBasis,
|
|
561
|
+
suppressedCount: number,
|
|
549
562
|
omitted: OmittedIssues | undefined,
|
|
550
563
|
): string[] {
|
|
551
564
|
const limitations: string[] = [];
|
|
@@ -561,11 +574,11 @@ function describeLimitations(
|
|
|
561
574
|
'upon as audit evidence.',
|
|
562
575
|
);
|
|
563
576
|
limitations.push(
|
|
564
|
-
`Testing covered only ${
|
|
577
|
+
`Testing covered only ${coveragePaths.join(', ')}. Anything outside that ` +
|
|
565
578
|
'was not examined, and its absence from this document is not evidence that it is sound.',
|
|
566
579
|
);
|
|
567
580
|
limitations.push(
|
|
568
|
-
`This reflects the state of the target as of ${
|
|
581
|
+
`This reflects the state of the target as of ${runCreatedAt
|
|
569
582
|
.toISOString()
|
|
570
583
|
.slice(0, 10)}. It says nothing about the target before or after that date.`,
|
|
571
584
|
);
|
|
@@ -573,9 +586,9 @@ function describeLimitations(
|
|
|
573
586
|
'Automated testing cannot establish the absence of a vulnerability. A clean result means ' +
|
|
574
587
|
'nothing was detected, not that nothing is there.',
|
|
575
588
|
);
|
|
576
|
-
if (
|
|
589
|
+
if (suppressedCount > 0) {
|
|
577
590
|
limitations.push(
|
|
578
|
-
`${
|
|
591
|
+
`${suppressedCount} finding${suppressedCount === 1 ? ' has' : 's have'} been ` +
|
|
579
592
|
'suppressed and excluded from the counts above. They are listed in full in the appendix.',
|
|
580
593
|
);
|
|
581
594
|
}
|
|
@@ -597,7 +610,74 @@ function describeLimitations(
|
|
|
597
610
|
*/
|
|
598
611
|
const HEX_COLOUR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
|
|
599
612
|
|
|
600
|
-
|
|
613
|
+
/**
|
|
614
|
+
* Why this branding cannot be rendered, or `undefined` if it can.
|
|
615
|
+
*
|
|
616
|
+
* **Exported so the rules have one home rather than two.** {@link buildReportModel}
|
|
617
|
+
* enforces these at render time, which is the last possible moment — by then the
|
|
618
|
+
* value has been stored, and the caller who set it is long gone. A hosted
|
|
619
|
+
* service wants to refuse it at the boundary instead, with a `400` naming the
|
|
620
|
+
* field. That needs the same two rules in two places, and a colour pattern
|
|
621
|
+
* copied into an API schema is a copy free to drift from the one that actually
|
|
622
|
+
* protects the stylesheet.
|
|
623
|
+
*
|
|
624
|
+
* So both callers ask this. The messages are identical wherever the value is
|
|
625
|
+
* rejected, because there is only one place that composes them.
|
|
626
|
+
*
|
|
627
|
+
* The white-label rule is deliberately **not** here: it depends on the report
|
|
628
|
+
* kind and on `preparedBy`, so it is a property of the options rather than of
|
|
629
|
+
* the branding, and only the renderer can decide it.
|
|
630
|
+
*/
|
|
631
|
+
export function brandingProblem(branding: Branding | undefined): string | undefined {
|
|
632
|
+
const colour = branding?.primaryColour;
|
|
633
|
+
if (colour !== undefined && !HEX_COLOUR.test(colour)) {
|
|
634
|
+
return (
|
|
635
|
+
`primaryColour must be a hex triplet such as #0a7 or #00aa77, not ${JSON.stringify(colour)}: ` +
|
|
636
|
+
'it is interpolated into the document stylesheet, where anything else could end the element'
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const logo = branding?.logo;
|
|
641
|
+
if (logo !== undefined && !logo.startsWith('data:image/')) {
|
|
642
|
+
return (
|
|
643
|
+
`logo must be a data: URI, not ${JSON.stringify(logo.slice(0, 40))}: ` +
|
|
644
|
+
'a linked image would make the report depend on a network at render time, ' +
|
|
645
|
+
'which is the guarantee that lets it be rendered to PDF reproducibly'
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Who the document says carried out the testing, resolved once so the PDF/HTML
|
|
654
|
+
* path and the streaming machine path cannot word it two different ways.
|
|
655
|
+
*
|
|
656
|
+
* @throws TypeError A white-labelled attestation naming nobody: a formal
|
|
657
|
+
* statement that testing was carried out has to say who carried it out.
|
|
658
|
+
*/
|
|
659
|
+
export function resolveAttestor(
|
|
660
|
+
options: Pick<ReportOptions, 'kind' | 'preparedBy' | 'branding'>,
|
|
661
|
+
): string {
|
|
662
|
+
const attestor = options.preparedBy ?? options.branding?.companyName;
|
|
663
|
+
// An attestation is a statement that a named party carried out testing. With
|
|
664
|
+
// Secureport's name removed and nothing put in its place there is no such
|
|
665
|
+
// party, and the document would assert something on nobody's behalf.
|
|
666
|
+
if (
|
|
667
|
+
attestor === undefined &&
|
|
668
|
+
options.branding?.whiteLabel === true &&
|
|
669
|
+
options.kind === 'attest'
|
|
670
|
+
) {
|
|
671
|
+
throw new TypeError(
|
|
672
|
+
'a white-labelled attestation must name who carried out the testing: ' +
|
|
673
|
+
'set branding.companyName or preparedBy',
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
return attestor ?? 'Secureport';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** The title printed when {@link ReportOptions.title} is not given. */
|
|
680
|
+
export const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
|
|
601
681
|
pen: 'Penetration Test Report',
|
|
602
682
|
vap: 'Vulnerability Assessment Report',
|
|
603
683
|
exec: 'Executive Summary',
|
|
@@ -605,11 +685,19 @@ const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
|
|
|
605
685
|
retest: 'Retest Report',
|
|
606
686
|
});
|
|
607
687
|
|
|
608
|
-
/**
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Works out how the findings were produced, from the run rather than a claim.
|
|
690
|
+
*
|
|
691
|
+
* Takes `kind`/`engines`/`includesManual` as primitives, the same reason
|
|
692
|
+
* {@link describeLimitations} does: the streaming machine renderers know
|
|
693
|
+
* `includesManual` only once a fold over the issue cursor finishes, and have
|
|
694
|
+
* no `Snapshot` to read `run.kind`/`run.engines` from.
|
|
695
|
+
*/
|
|
696
|
+
export function describeBasis(
|
|
697
|
+
kind: RunKind,
|
|
698
|
+
engines: readonly string[],
|
|
699
|
+
includesManual: boolean,
|
|
700
|
+
): TestingBasis {
|
|
613
701
|
const automated = kind === 'scan';
|
|
614
702
|
const uploaded = kind === 'upload';
|
|
615
703
|
|
|
@@ -733,7 +821,11 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
|
|
|
733
821
|
};
|
|
734
822
|
|
|
735
823
|
const outstandingTotal = SEVERITY_ORDER.reduce((sum, s) => sum + outstanding[s], 0);
|
|
736
|
-
const basis = describeBasis(
|
|
824
|
+
const basis = describeBasis(
|
|
825
|
+
snapshot.run.kind,
|
|
826
|
+
snapshot.run.engines,
|
|
827
|
+
snapshot.issues.some((i) => i.issue.origin === 'manual'),
|
|
828
|
+
);
|
|
737
829
|
const retest = describeRetest(snapshot);
|
|
738
830
|
|
|
739
831
|
// Rendering a retest of nothing is the over-claim §7 warns about: the
|
|
@@ -745,33 +837,10 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
|
|
|
745
837
|
}
|
|
746
838
|
|
|
747
839
|
const branding = options.branding;
|
|
748
|
-
const
|
|
749
|
-
if (
|
|
750
|
-
throw new TypeError(
|
|
751
|
-
`primaryColour must be a hex triplet such as #0a7 or #00aa77, not ${JSON.stringify(colour)}: ` +
|
|
752
|
-
'it is interpolated into the document stylesheet, where anything else could end the element',
|
|
753
|
-
);
|
|
754
|
-
}
|
|
840
|
+
const problem = brandingProblem(branding);
|
|
841
|
+
if (problem !== undefined) throw new TypeError(problem);
|
|
755
842
|
|
|
756
|
-
const
|
|
757
|
-
if (logo !== undefined && !logo.startsWith('data:image/')) {
|
|
758
|
-
throw new TypeError(
|
|
759
|
-
`logo must be a data: URI, not ${JSON.stringify(logo.slice(0, 40))}: ` +
|
|
760
|
-
'a linked image would make the report depend on a network at render time, ' +
|
|
761
|
-
'which is the guarantee that lets it be rendered to PDF reproducibly',
|
|
762
|
-
);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
const attestor = options.preparedBy ?? branding?.companyName;
|
|
766
|
-
// An attestation is a statement that a named party carried out testing. With
|
|
767
|
-
// Secureport's name removed and nothing put in its place there is no such
|
|
768
|
-
// party, and the document would assert something on nobody's behalf.
|
|
769
|
-
if (attestor === undefined && branding?.whiteLabel === true && options.kind === 'attest') {
|
|
770
|
-
throw new TypeError(
|
|
771
|
-
'a white-labelled attestation must name who carried out the testing: ' +
|
|
772
|
-
'set branding.companyName or preparedBy',
|
|
773
|
-
);
|
|
774
|
-
}
|
|
843
|
+
const attestor = resolveAttestor(options);
|
|
775
844
|
|
|
776
845
|
return {
|
|
777
846
|
kind: options.kind,
|
|
@@ -780,13 +849,19 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
|
|
|
780
849
|
generatedAt: options.now,
|
|
781
850
|
...(options.preparedBy === undefined ? {} : { preparedBy: options.preparedBy }),
|
|
782
851
|
...(options.preparedFor === undefined ? {} : { preparedFor: options.preparedFor }),
|
|
783
|
-
attestor
|
|
852
|
+
attestor,
|
|
784
853
|
coverPage: options.coverPage === true,
|
|
785
854
|
tableOfContents: options.tableOfContents === true,
|
|
786
855
|
...(branding === undefined ? {} : { branding }),
|
|
787
856
|
basis,
|
|
788
857
|
evidenceVerbosity: options.evidenceVerbosity ?? 'summary',
|
|
789
|
-
limitations: describeLimitations(
|
|
858
|
+
limitations: describeLimitations(
|
|
859
|
+
snapshot.run.coverage.paths,
|
|
860
|
+
snapshot.run.createdAt,
|
|
861
|
+
basis,
|
|
862
|
+
snapshot.suppressed.length,
|
|
863
|
+
omitted,
|
|
864
|
+
),
|
|
790
865
|
sections,
|
|
791
866
|
resolved: options.includeResolved === false ? [] : resolved,
|
|
792
867
|
...(omitted === undefined ? {} : { omitted }),
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Severity } from '../severity.js';
|
|
2
|
+
import type { SnapshotIssue } from '../snapshot.js';
|
|
3
|
+
import type { ReportHeader } from './stream.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a SARIF export can be asked for.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately its own type rather than {@link ReportOptions}: SARIF has no
|
|
9
|
+
* `kind` — it is always "the issues open right now", never a retest or an
|
|
10
|
+
* attestation — and no branding, cover page or evidence verbosity. Accepting
|
|
11
|
+
* `ReportOptions` and silently ignoring most of its fields would be a worse
|
|
12
|
+
* API than a type that only offers what SARIF actually uses.
|
|
13
|
+
*/
|
|
14
|
+
export interface SarifOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Keep issues below this severity out of `results`.
|
|
17
|
+
*
|
|
18
|
+
* Unlike the other formats, there is no `omitted` count to report: SARIF
|
|
19
|
+
* has no prose to carry a disclosure sentence in, and a consumer asking
|
|
20
|
+
* for `critical` only is asking to scope a code-scanning feed, not
|
|
21
|
+
* reading a document that owes it an explanation of what it left out.
|
|
22
|
+
*/
|
|
23
|
+
readonly severityFloor?: Severity;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SARIF's four severity levels, from the SARIF 2.1.0 spec §3.27.10. */
|
|
27
|
+
type SarifLevel = 'error' | 'warning' | 'note' | 'none';
|
|
28
|
+
|
|
29
|
+
const SARIF_LEVEL: Readonly<Record<Severity, SarifLevel>> = Object.freeze({
|
|
30
|
+
critical: 'error',
|
|
31
|
+
high: 'error',
|
|
32
|
+
medium: 'warning',
|
|
33
|
+
low: 'note',
|
|
34
|
+
advisory: 'note',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `security-severity` GitHub code scanning reads to rank an alert, as a
|
|
39
|
+
* string 0.0–10.0 — GitHub's convention, not part of the SARIF spec itself.
|
|
40
|
+
* Fixed bands rather than a CVSS score, because not every finding has one
|
|
41
|
+
* and two issues at the same {@link Severity} should rank the same.
|
|
42
|
+
*/
|
|
43
|
+
const SECURITY_SEVERITY: Readonly<Record<Severity, string>> = Object.freeze({
|
|
44
|
+
critical: '9.0',
|
|
45
|
+
high: '7.5',
|
|
46
|
+
medium: '5.0',
|
|
47
|
+
low: '3.0',
|
|
48
|
+
advisory: '1.0',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** `SEVERITY_ORDER`, duplicated as an index rather than imported, to keep this file's only dependency on `severity.ts` the type. */
|
|
52
|
+
const FLOOR_RANK: Readonly<Record<Severity, number>> = Object.freeze({
|
|
53
|
+
critical: 0,
|
|
54
|
+
high: 1,
|
|
55
|
+
medium: 2,
|
|
56
|
+
low: 3,
|
|
57
|
+
advisory: 4,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** `"key":value`, run through `JSON.stringify` so nothing is hand-escaped. */
|
|
61
|
+
function field(key: string, value: unknown): string {
|
|
62
|
+
return `${JSON.stringify(key)}:${JSON.stringify(value)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Renders a snapshot's open issues as SARIF 2.1.0, from a header and a
|
|
67
|
+
* cursor — the machine path's format for GitHub code scanning (P8), and the
|
|
68
|
+
* streaming sibling of {@link renderJsonStream} and
|
|
69
|
+
* {@link renderMarkdownStream}. See {@link renderJsonStream} for the memory
|
|
70
|
+
* argument.
|
|
71
|
+
*
|
|
72
|
+
* **Resolved issues never appear, unconditionally — there is no
|
|
73
|
+
* `includeResolved` option.** SARIF is not a document a person reads; it is
|
|
74
|
+
* the input to GitHub's own diffing, which closes an alert when a new
|
|
75
|
+
* upload no longer contains it. Including a resolved issue would tell
|
|
76
|
+
* GitHub the finding is still open, which is the opposite of what resolving
|
|
77
|
+
* it meant.
|
|
78
|
+
*
|
|
79
|
+
* **Suppressed issues never appear either, and take no `suppressed`
|
|
80
|
+
* parameter at all.** An accepted risk is not a code-scanning alert; SARIF
|
|
81
|
+
* has no accepted-risk register to carry it in the way the appendix does
|
|
82
|
+
* for the human and other machine formats.
|
|
83
|
+
*
|
|
84
|
+
* **`ruleId` is `Issue.vulnKey`, not `Finding.sourceRuleId` or
|
|
85
|
+
* `sourceEngine`.** `vulnKey` is engine-independent by construction
|
|
86
|
+
* (`fingerprint.ts`), and naming a scanner in a customer-facing artefact is
|
|
87
|
+
* the thing this package's report options have twice had to stop doing
|
|
88
|
+
* (`ReportOptions.evidenceVerbosity`'s own TSDoc records it). `rules[]` is
|
|
89
|
+
* built from the same stream, deduplicated on `vulnKey` — bounded by the
|
|
90
|
+
* number of distinct weakness classes, not by issue count, so buffering it
|
|
91
|
+
* (unlike `results`) costs nothing worth avoiding.
|
|
92
|
+
*
|
|
93
|
+
* @param header - The run and the target. Never scales with issue count.
|
|
94
|
+
* @param issues - Every open issue, in any order — SARIF results are an
|
|
95
|
+
* unordered set, so this format is the one place in the streaming
|
|
96
|
+
* contract with no ordering requirement on its input.
|
|
97
|
+
* @param options - A severity floor, and nothing else.
|
|
98
|
+
* @returns Chunks of JSON text; concatenated, they are one SARIF document.
|
|
99
|
+
*/
|
|
100
|
+
export async function* renderSarifStream(
|
|
101
|
+
header: ReportHeader,
|
|
102
|
+
issues: AsyncIterable<SnapshotIssue>,
|
|
103
|
+
options: SarifOptions = {},
|
|
104
|
+
): AsyncGenerator<string> {
|
|
105
|
+
const floor = options.severityFloor;
|
|
106
|
+
const floorAt = floor === undefined ? 4 : FLOOR_RANK[floor];
|
|
107
|
+
// Small — bounded by distinct weakness classes, not by issue count — and
|
|
108
|
+
// known only once the stream is exhausted. So `results` (the one array
|
|
109
|
+
// that must never be buffered) is written first and `tool.driver.rules`
|
|
110
|
+
// last, the one key order that lets both be true without a second pass
|
|
111
|
+
// over `issues`: JSON does not care which key comes first.
|
|
112
|
+
const rules = new Map<string, { readonly title: string; readonly cwe: string | undefined }>();
|
|
113
|
+
|
|
114
|
+
yield '{';
|
|
115
|
+
// The schema's own `id`, not a GitHub raw link — checked by hand, because
|
|
116
|
+
// an unreachable $schema is a broken document a validator would still call
|
|
117
|
+
// valid, and getting it wrong once cost the trip to the OASIS repo to
|
|
118
|
+
// learn its real path (`tests/fixtures/README.md` records that trip).
|
|
119
|
+
yield field(
|
|
120
|
+
'$schema',
|
|
121
|
+
'https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json',
|
|
122
|
+
);
|
|
123
|
+
yield ',' + field('version', '2.1.0');
|
|
124
|
+
yield ',"runs":[{"results":[';
|
|
125
|
+
|
|
126
|
+
let firstResult = true;
|
|
127
|
+
for await (const entry of issues) {
|
|
128
|
+
const { issue } = entry;
|
|
129
|
+
if (issue.status === 'resolved') continue;
|
|
130
|
+
if (FLOOR_RANK[issue.effectiveSeverity] > floorAt) continue;
|
|
131
|
+
|
|
132
|
+
if (!rules.has(issue.vulnKey)) {
|
|
133
|
+
rules.set(issue.vulnKey, { title: issue.title, cwe: issue.cwe });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const message = entry.findings[0]?.description ?? issue.title;
|
|
137
|
+
const result = {
|
|
138
|
+
ruleId: issue.vulnKey,
|
|
139
|
+
level: SARIF_LEVEL[issue.effectiveSeverity],
|
|
140
|
+
message: { text: message },
|
|
141
|
+
locations: [
|
|
142
|
+
{
|
|
143
|
+
physicalLocation: {
|
|
144
|
+
artifactLocation: { uri: issue.location },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
partialFingerprints: { secureportFingerprint: issue.fingerprint },
|
|
149
|
+
properties: { 'security-severity': SECURITY_SEVERITY[issue.effectiveSeverity] },
|
|
150
|
+
};
|
|
151
|
+
yield (firstResult ? '' : ',') + JSON.stringify(result);
|
|
152
|
+
firstResult = false;
|
|
153
|
+
}
|
|
154
|
+
yield ']';
|
|
155
|
+
|
|
156
|
+
const ruleDefs = [...rules.entries()].map(([vulnKey, rule]) => ({
|
|
157
|
+
id: vulnKey,
|
|
158
|
+
shortDescription: { text: rule.title },
|
|
159
|
+
...(rule.cwe === undefined ? {} : { properties: { tags: [rule.cwe] } }),
|
|
160
|
+
}));
|
|
161
|
+
yield ',"tool":{"driver":{';
|
|
162
|
+
yield field('name', 'Secureport');
|
|
163
|
+
yield ',' + field('informationUri', 'https://secureport.io/');
|
|
164
|
+
yield ',"rules":' + JSON.stringify(ruleDefs);
|
|
165
|
+
yield '}}}]}';
|
|
166
|
+
}
|