@secureport/core 2.3.0 → 2.3.1
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/report/csv.d.ts.map +1 -1
- package/dist/report/csv.js +6 -0
- package/dist/report/csv.js.map +1 -1
- package/dist/report/fonts.d.ts +53 -0
- package/dist/report/fonts.d.ts.map +1 -0
- package/dist/report/fonts.js +53 -0
- package/dist/report/fonts.js.map +1 -0
- package/dist/report/html.d.ts.map +1 -1
- package/dist/report/html.js +48 -13
- package/dist/report/html.js.map +1 -1
- package/dist/report/markdown.d.ts +9 -0
- package/dist/report/markdown.d.ts.map +1 -1
- package/dist/report/markdown.js +45 -10
- package/dist/report/markdown.js.map +1 -1
- package/dist/report/model.d.ts +47 -0
- package/dist/report/model.d.ts.map +1 -1
- package/dist/report/model.js +85 -1
- package/dist/report/model.js.map +1 -1
- package/dist/report/stream.d.ts +13 -2
- package/dist/report/stream.d.ts.map +1 -1
- package/dist/report/stream.js +17 -5
- package/dist/report/stream.js.map +1 -1
- package/package.json +3 -3
- package/src/report/csv.ts +6 -0
- package/src/report/fonts.ts +55 -0
- package/src/report/html.ts +57 -14
- package/src/report/markdown.ts +57 -9
- package/src/report/model.ts +123 -1
- package/src/report/stream.ts +30 -5
package/src/report/markdown.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Snapshot, SnapshotIssue } from '../snapshot.js';
|
|
|
5
5
|
import {
|
|
6
6
|
buildReportModel,
|
|
7
7
|
type EvidenceVerbosity,
|
|
8
|
+
timelineFor,
|
|
8
9
|
VERDICT_LABELS,
|
|
9
10
|
type ReportModel,
|
|
10
11
|
type ReportOptions,
|
|
@@ -166,6 +167,20 @@ export function issueRow(entry: SnapshotIssue): string {
|
|
|
166
167
|
return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | \`${cell(location)}\` | ${entry.change.replace('_', ' ')} | ${entry.daysOpen} | ${entry.slaStatus.replace('_', ' ')} |`;
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/**
|
|
171
|
+
* An issue's remediation chronology, as a single table cell.
|
|
172
|
+
*
|
|
173
|
+
* Exported for the same reason {@link issueRow} is: the streaming renderer
|
|
174
|
+
* formats a row the moment it sees it, and needs the identical rendering of
|
|
175
|
+
* {@link timelineFor}'s events — a second join-and-escape is a second one
|
|
176
|
+
* free to drift.
|
|
177
|
+
*/
|
|
178
|
+
export function timelineCell(entry: SnapshotIssue): string {
|
|
179
|
+
return timelineFor(entry)
|
|
180
|
+
.map((event) => cell(event.statement))
|
|
181
|
+
.join(' · ');
|
|
182
|
+
}
|
|
183
|
+
|
|
169
184
|
/** The Retest body: the difference between two runs, and nothing else. */
|
|
170
185
|
function retestBody(model: ReportModel): string[] {
|
|
171
186
|
// `buildReportModel` refuses to build a retest without one.
|
|
@@ -228,13 +243,13 @@ function retestBody(model: ReportModel): string[] {
|
|
|
228
243
|
lines.push(
|
|
229
244
|
'## Issue by issue',
|
|
230
245
|
'',
|
|
231
|
-
'| Issue | Severity | Verdict | Location |
|
|
246
|
+
'| Issue | Severity | Verdict | Location | Timeline |',
|
|
232
247
|
'| --- | --- | --- | --- | --- |',
|
|
233
248
|
...retest.entries.map((entry) => {
|
|
234
249
|
const { issue } = entry.issue;
|
|
235
250
|
const location =
|
|
236
251
|
issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
|
|
237
|
-
return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[entry.verdict]} | \`${cell(location)}\` | ${entry.issue
|
|
252
|
+
return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[entry.verdict]} | \`${cell(location)}\` | ${timelineCell(entry.issue)} |`;
|
|
238
253
|
}),
|
|
239
254
|
'',
|
|
240
255
|
);
|
|
@@ -343,6 +358,27 @@ function executiveBody(model: ReportModel): string[] {
|
|
|
343
358
|
return lines;
|
|
344
359
|
}
|
|
345
360
|
|
|
361
|
+
/**
|
|
362
|
+
* "What this report does not establish" — every kind but the Attestation
|
|
363
|
+
* Letter, which states its limits inline as part of the formal statement
|
|
364
|
+
* itself (`attestationBody`) rather than as a separate section (B109).
|
|
365
|
+
*
|
|
366
|
+
* Placed immediately before the suppressed appendix in every caller, which is
|
|
367
|
+
* also where the streaming Markdown renderer (`stream.ts`) is forced to put
|
|
368
|
+
* it: `describeLimitations` needs the suppressed count and the omitted-below-
|
|
369
|
+
* floor count, both fold-dependent and only known once the issue cursor is
|
|
370
|
+
* drained. The array renderer has no such constraint but uses the same
|
|
371
|
+
* position, so the two cannot drift apart on placement.
|
|
372
|
+
*/
|
|
373
|
+
function limitationsSection(model: ReportModel): string[] {
|
|
374
|
+
return [
|
|
375
|
+
'## What this report does not establish',
|
|
376
|
+
'',
|
|
377
|
+
...model.limitations.map((l) => `- ${l}`),
|
|
378
|
+
'',
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
|
|
346
382
|
/** The Attestation Letter body: a formal statement, with its limits stated. */
|
|
347
383
|
function attestationBody(model: ReportModel): string[] {
|
|
348
384
|
const { snapshot } = model;
|
|
@@ -482,12 +518,22 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
482
518
|
lines.push(...changeSummary(model), '');
|
|
483
519
|
|
|
484
520
|
if (model.kind === 'exec') {
|
|
485
|
-
lines.push(
|
|
521
|
+
lines.push(
|
|
522
|
+
...executiveBody(model),
|
|
523
|
+
...limitationsSection(model),
|
|
524
|
+
...suppressedAppendix(snapshot),
|
|
525
|
+
'',
|
|
526
|
+
);
|
|
486
527
|
return withContents(lines, model);
|
|
487
528
|
}
|
|
488
529
|
|
|
489
530
|
if (model.kind === 'retest') {
|
|
490
|
-
lines.push(
|
|
531
|
+
lines.push(
|
|
532
|
+
...retestBody(model),
|
|
533
|
+
...limitationsSection(model),
|
|
534
|
+
...suppressedAppendix(snapshot),
|
|
535
|
+
'',
|
|
536
|
+
);
|
|
491
537
|
return withContents(lines, model);
|
|
492
538
|
}
|
|
493
539
|
|
|
@@ -524,10 +570,12 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
524
570
|
lines.push('');
|
|
525
571
|
}
|
|
526
572
|
|
|
527
|
-
// Printed here, beside the section it is about,
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
573
|
+
// Printed here too, beside the section it is about, even though it also
|
|
574
|
+
// appears in `limitationsSection` below (B109). A reader looking at a short
|
|
575
|
+
// Findings section learns why it is short at the point of confusion rather
|
|
576
|
+
// than having to find the register at the end; the register entry is what
|
|
577
|
+
// keeps the disclosure surviving there once the reader has moved past this
|
|
578
|
+
// section. Two honest statements, not a duplicate to trim.
|
|
531
579
|
if (model.omitted !== undefined) lines.push(`_${model.omitted.statement}_`, '');
|
|
532
580
|
|
|
533
581
|
if (model.resolved.length > 0) {
|
|
@@ -546,6 +594,6 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
|
|
|
546
594
|
);
|
|
547
595
|
}
|
|
548
596
|
|
|
549
|
-
lines.push(...suppressedAppendix(snapshot), '');
|
|
597
|
+
lines.push(...limitationsSection(model), ...suppressedAppendix(snapshot), '');
|
|
550
598
|
return withContents(lines, model);
|
|
551
599
|
}
|
package/src/report/model.ts
CHANGED
|
@@ -277,6 +277,10 @@ export interface TestingBasis {
|
|
|
277
277
|
* The issues are still open, still counted and still the target's problem;
|
|
278
278
|
* only the pages describing them are gone. That distinction is the reason this
|
|
279
279
|
* is reported at all rather than silently applied.
|
|
280
|
+
*
|
|
281
|
+
* **Never set for a `retest` report.** Its own per-issue table lists every
|
|
282
|
+
* carried issue regardless of the floor (7.9a) — a floor changes nothing a
|
|
283
|
+
* reader sees, so there is nothing to disclose.
|
|
280
284
|
*/
|
|
281
285
|
export interface OmittedIssues {
|
|
282
286
|
/** How many outstanding issues were left out. Never `0` — the field is absent instead. */
|
|
@@ -545,6 +549,120 @@ export function verdictFor(entry: SnapshotIssue): RetestVerdict {
|
|
|
545
549
|
return entry.findings.length > 0 ? 'still_present' : 'not_retested';
|
|
546
550
|
}
|
|
547
551
|
|
|
552
|
+
// What kind of moment a `TimelineEvent` records. Four of the five are things
|
|
553
|
+
// that already happened, in the order they happened. `deadline` is not: it is
|
|
554
|
+
// a standing obligation that can be in the future, and `timelineFor` never
|
|
555
|
+
// sorts it in among the other four — see that function's own doc comment for
|
|
556
|
+
// why. Not exported: nothing outside this module names the union, only the
|
|
557
|
+
// `kind` field it types.
|
|
558
|
+
type TimelineEventKind = 'opened' | 'resolved' | 'returned' | 'last_seen' | 'deadline';
|
|
559
|
+
|
|
560
|
+
/** One moment in an issue's remediation history, worded ready to print. */
|
|
561
|
+
export interface TimelineEvent {
|
|
562
|
+
/** What kind of moment this is. */
|
|
563
|
+
readonly kind: TimelineEventKind;
|
|
564
|
+
|
|
565
|
+
/** When it happened, or — for `deadline` alone — when it is due. */
|
|
566
|
+
readonly at: Date;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* One sentence, ready to print, so no two renderers word it differently.
|
|
570
|
+
*
|
|
571
|
+
* `deadline`'s wording follows {@link SnapshotIssue.slaStatus}, never a
|
|
572
|
+
* second comparison of `at` against `now` — `slaStatus` is already the
|
|
573
|
+
* derived answer to "is this overdue", and a fresh comparison here would be
|
|
574
|
+
* a second one free to disagree.
|
|
575
|
+
*/
|
|
576
|
+
readonly statement: string;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* An issue's remediation chronology, derived only from dates already on
|
|
581
|
+
* {@link Issue} — no `issue_events` read, no migration.
|
|
582
|
+
*
|
|
583
|
+
* Exported for the same reason {@link verdictFor} is: it is per-issue and
|
|
584
|
+
* needs nothing but the one entry, which is exactly what the streaming
|
|
585
|
+
* Markdown renderer can offer it a row at a time.
|
|
586
|
+
*
|
|
587
|
+
* **`deadline` is appended last, unconditionally — never sorted in among the
|
|
588
|
+
* other four.** Those four are things that already happened; a remediation
|
|
589
|
+
* deadline is a standing obligation, not an event, and it can be in the past
|
|
590
|
+
* (breached) or the future (within the window). Sorting it by `at` would let
|
|
591
|
+
* a breached deadline land in the middle of the chronology, worded as though
|
|
592
|
+
* it were a fact that occurred between two sightings — this function's
|
|
593
|
+
* version of the mistake `not_retested` exists to stop: reporting something
|
|
594
|
+
* that has not happened as though it had.
|
|
595
|
+
*
|
|
596
|
+
* **No `deadline` event for a resolved issue**, even one whose `slaDueAt` has
|
|
597
|
+
* passed. A deadline is a claim about outstanding remediation; printing
|
|
598
|
+
* "overdue since …" beside a `fixed` verdict would report finished work as
|
|
599
|
+
* work still owed — the same dishonesty from the other direction. None
|
|
600
|
+
* either when {@link Issue.slaDueAt} is `null` (an `advisory` under
|
|
601
|
+
* {@link DEFAULT_SLA_POLICY}), since there is no deadline to state.
|
|
602
|
+
*/
|
|
603
|
+
export function timelineFor(entry: SnapshotIssue): readonly TimelineEvent[] {
|
|
604
|
+
const { issue } = entry;
|
|
605
|
+
const events: TimelineEvent[] = [
|
|
606
|
+
{
|
|
607
|
+
kind: 'opened',
|
|
608
|
+
at: issue.firstSeen,
|
|
609
|
+
statement: `opened ${formatIsoDate(issue.firstSeen)} (${entry.daysOpen} days)`,
|
|
610
|
+
},
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
if (issue.resolvedAt !== undefined) {
|
|
614
|
+
events.push({
|
|
615
|
+
kind: 'resolved',
|
|
616
|
+
at: issue.resolvedAt,
|
|
617
|
+
statement: `resolved ${formatIsoDate(issue.resolvedAt)}`,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
if (issue.reopenedAt !== undefined) {
|
|
621
|
+
events.push({
|
|
622
|
+
kind: 'returned',
|
|
623
|
+
at: issue.reopenedAt,
|
|
624
|
+
statement: `returned ${formatIsoDate(issue.reopenedAt)}`,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Deduplicated by exact timestamp: `reconcile()` sets `firstSeen` and
|
|
629
|
+
// `lastSeen` to the same instant when it creates an issue, and an issue
|
|
630
|
+
// that regressed this run has `reopenedAt === lastSeen` — without this
|
|
631
|
+
// check the same moment would print twice under two different names.
|
|
632
|
+
if (!events.some((e) => e.at.getTime() === issue.lastSeen.getTime())) {
|
|
633
|
+
events.push({
|
|
634
|
+
kind: 'last_seen',
|
|
635
|
+
at: issue.lastSeen,
|
|
636
|
+
statement: `last seen ${formatIsoDate(issue.lastSeen)}`,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Sorted, not pushed in field-declaration order: `lastSeen` is the last
|
|
641
|
+
// time the issue was *detected*, which for a resolved issue predates
|
|
642
|
+
// `resolvedAt` — the date reconciliation stopped seeing it and started
|
|
643
|
+
// counting misses, not the date it stopped existing. Pushing in the order
|
|
644
|
+
// above would print "resolved … last seen …" backwards.
|
|
645
|
+
events.sort((a, b) => a.at.getTime() - b.at.getTime());
|
|
646
|
+
|
|
647
|
+
if (issue.slaDueAt !== null && issue.status !== 'resolved') {
|
|
648
|
+
const due = formatIsoDate(issue.slaDueAt);
|
|
649
|
+
const statement =
|
|
650
|
+
entry.slaStatus === 'breached'
|
|
651
|
+
? `overdue since ${due}`
|
|
652
|
+
: entry.slaStatus === 'due_soon'
|
|
653
|
+
? `due ${due} (due soon)`
|
|
654
|
+
: `due ${due}`;
|
|
655
|
+
events.push({ kind: 'deadline', at: issue.slaDueAt, statement });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return events;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** A date, printed the same way everywhere in this module. */
|
|
662
|
+
function formatIsoDate(date: Date): string {
|
|
663
|
+
return date.toISOString().slice(0, 10);
|
|
664
|
+
}
|
|
665
|
+
|
|
548
666
|
/**
|
|
549
667
|
* Works out what a report cannot establish, from the run rather than a
|
|
550
668
|
* template.
|
|
@@ -784,7 +902,11 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
|
|
|
784
902
|
}
|
|
785
903
|
|
|
786
904
|
if (SEVERITY_ORDER.indexOf(severity) > floorAt) {
|
|
787
|
-
|
|
905
|
+
// B208: `retest`'s own table lists every carried issue regardless of
|
|
906
|
+
// the floor (7.9a) — nothing is actually left out of the document for
|
|
907
|
+
// this kind, so counting it here would make `limitations` state an
|
|
908
|
+
// omission the very next section contradicts.
|
|
909
|
+
if (options.kind !== 'retest') omittedBelowFloor++;
|
|
788
910
|
continue;
|
|
789
911
|
}
|
|
790
912
|
|
package/src/report/stream.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { RunSummary } from '../run.js';
|
|
|
2
2
|
import { SEVERITY_ORDER } from '../severity.js';
|
|
3
3
|
import type { Severity } from '../severity.js';
|
|
4
4
|
import type { SnapshotIssue, SuppressedIssue, Target } from '../snapshot.js';
|
|
5
|
-
import { cell, formatDate, issueDetail, issueRow } from './markdown.js';
|
|
5
|
+
import { cell, formatDate, issueDetail, issueRow, timelineCell } from './markdown.js';
|
|
6
6
|
import {
|
|
7
7
|
brandingProblem,
|
|
8
8
|
describeBasis,
|
|
@@ -263,8 +263,9 @@ export async function* renderJsonStream(
|
|
|
263
263
|
* (title, scope, the New/Still open/Regressed/Resolved/Suppressed table,
|
|
264
264
|
* which all read straight off `RunSummary`) and moves everything that needs
|
|
265
265
|
* a full pass — the basis statement, the remediation-deadline count, what a
|
|
266
|
-
* severity floor omitted, the retest verdict counts
|
|
267
|
-
* `## Summary` section, written
|
|
266
|
+
* severity floor omitted, the retest verdict counts, and what the report
|
|
267
|
+
* does not establish (B109) — into a closing `## Summary` section, written
|
|
268
|
+
* once both cursors are exhausted.
|
|
268
269
|
*
|
|
269
270
|
* **`sections` grouped by severity do not exist here**, for the reason given
|
|
270
271
|
* in {@link renderJsonStream}: `issues` and `suppressed` are trusted to
|
|
@@ -284,6 +285,16 @@ export async function* renderJsonStream(
|
|
|
284
285
|
* that one list (the accepted-risk register, ordinarily a minority of the
|
|
285
286
|
* total) is the one deliberate exception to "never hold more than a row".
|
|
286
287
|
*
|
|
288
|
+
* **"What this report does not establish" (B109) lands in the closing
|
|
289
|
+
* `## Summary` section, after the Suppressed findings table rather than
|
|
290
|
+
* before it as in `renderMarkdown`.** `describeLimitations` needs
|
|
291
|
+
* `state.suppressedCount`, complete only once the `suppressed` cursor is
|
|
292
|
+
* drained — and that drain, and the table it produces, happen earlier in
|
|
293
|
+
* this stream than the summary does. The array renderer has no such
|
|
294
|
+
* ordering constraint and keeps limitations immediately before the
|
|
295
|
+
* appendix; forcing the same document order here would mean buffering the
|
|
296
|
+
* suppressed rows a second time for no reason `renderJsonStream` needs to.
|
|
297
|
+
*
|
|
287
298
|
* @param header - The run, its baseline, and the target. Never scales with
|
|
288
299
|
* issue count.
|
|
289
300
|
* @param issues - Every issue in scope, most urgent first: open/regressed
|
|
@@ -464,7 +475,7 @@ export async function* renderMarkdownStream(
|
|
|
464
475
|
yield [
|
|
465
476
|
'## Issue by issue',
|
|
466
477
|
'',
|
|
467
|
-
'| Issue | Severity | Verdict | Location |
|
|
478
|
+
'| Issue | Severity | Verdict | Location | Timeline |',
|
|
468
479
|
'| --- | --- | --- | --- | --- |',
|
|
469
480
|
].join('\n') + '\n';
|
|
470
481
|
openedFindings = true;
|
|
@@ -472,7 +483,7 @@ export async function* renderMarkdownStream(
|
|
|
472
483
|
const { issue } = entry;
|
|
473
484
|
const location =
|
|
474
485
|
issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
|
|
475
|
-
yield `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[verdict as RetestVerdict]} | \`${cell(location)}\` | ${entry
|
|
486
|
+
yield `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[verdict as RetestVerdict]} | \`${cell(location)}\` | ${timelineCell(entry)} |\n`;
|
|
476
487
|
continue;
|
|
477
488
|
}
|
|
478
489
|
|
|
@@ -615,6 +626,20 @@ export async function* renderMarkdownStream(
|
|
|
615
626
|
);
|
|
616
627
|
}
|
|
617
628
|
}
|
|
629
|
+
|
|
630
|
+
summary.push(
|
|
631
|
+
'## What this report does not establish',
|
|
632
|
+
'',
|
|
633
|
+
...describeLimitations(
|
|
634
|
+
header.run.coverage.paths,
|
|
635
|
+
header.run.createdAt,
|
|
636
|
+
basis,
|
|
637
|
+
state.suppressedCount,
|
|
638
|
+
omitted,
|
|
639
|
+
).map((l) => `- ${l}`),
|
|
640
|
+
'',
|
|
641
|
+
);
|
|
642
|
+
|
|
618
643
|
yield summary.join('\n') + '\n';
|
|
619
644
|
}
|
|
620
645
|
|