@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.
@@ -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
+ }
@@ -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,
@@ -16,8 +16,14 @@ export function formatDate(date: Date): string {
16
16
  return date.toISOString().slice(0, 10);
17
17
  }
18
18
 
19
- /** Escapes the characters that would break out of a Markdown table cell. */
20
- function cell(value: string): string {
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
- /** One issue, in full. The Penetration Test presentation. */
58
- function issueDetail(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string[] {
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
- /** One issue, as a table row. The Vulnerability Assessment presentation. */
146
- function issueRow(entry: SnapshotIssue): string {
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})`;
@@ -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
- /** Works out what a report cannot establish, from the run rather than a template. */
546
- function describeLimitations(
547
- snapshot: Snapshot,
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 ${snapshot.run.coverage.paths.join(', ')}. Anything outside that ` +
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 ${snapshot.run.createdAt
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 (snapshot.suppressed.length > 0) {
589
+ if (suppressedCount > 0) {
577
590
  limitations.push(
578
- `${snapshot.suppressed.length} finding${snapshot.suppressed.length === 1 ? ' has' : 's have'} been ` +
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
- const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
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
- /** Works out how the findings were produced, from the run rather than a claim. */
609
- function describeBasis(snapshot: Snapshot): TestingBasis {
610
- const kind = snapshot.run.kind;
611
- const engines = snapshot.run.engines;
612
- const includesManual = snapshot.issues.some((i) => i.issue.origin === 'manual');
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(snapshot);
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 colour = branding?.primaryColour;
749
- if (colour !== undefined && !HEX_COLOUR.test(colour)) {
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 logo = branding?.logo;
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: attestor ?? 'Secureport',
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(snapshot, basis, omitted),
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
+ }