From c608746910c9ff4d1b044a90972ec8f27d65a48e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 09:05:44 +0000 Subject: [PATCH] fix: surface sentinel analyzer findings --- .../src/hwlab-node-web-sentinel-p5-observe.ts | 46 +++++- scripts/src/hwlab-node-web-sentinel-p5.ts | 67 ++++++++- .../src/hwlab-node-web-sentinel-service.ts | 139 ++++++++++++++++-- 3 files changed, 232 insertions(+), 20 deletions(-) diff --git a/scripts/src/hwlab-node-web-sentinel-p5-observe.ts b/scripts/src/hwlab-node-web-sentinel-p5-observe.ts index 85aa9e56..2a5b951d 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5-observe.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5-observe.ts @@ -318,10 +318,14 @@ export function runSentinelQuickVerify(state: SentinelCicdState, reason: string, const controlFindings = quickVerifyControlFindings(null, promptIndex, turnSummary, traceFrame); const artifactSummaryRecord = record(artifactSummary); const artifactFindings = Array.isArray(artifactSummaryRecord.findings) ? artifactSummaryRecord.findings.map(record) : []; + const visibilityFindings = quickVerifyAnalysisVisibilityFindings(analysis, artifactSummary); const cleanupStep = stopQuickVerifyObserver(state, observerId, "observe-stop-after-terminal"); const cleanupFindings = quickVerifyCleanupFindings(cleanupStep); const findings = mergeFindingRecords( - mergeFindingRecords(artifactFindings, controlFindings), + mergeFindingRecords( + mergeFindingRecords(artifactFindings, controlFindings), + visibilityFindings, + ), cleanupFindings, ); const blockingFindings = findings.filter(isQuickVerifyBlockingFinding); @@ -352,7 +356,7 @@ export function runSentinelQuickVerify(state: SentinelCicdState, reason: string, steps: [...steps, cleanupStep], analysis: artifactSummary, views: { - summary: { renderedText: renderQuickVerifySummary({ runId, scenarioId, observerId, artifactSummary, steps, publicOrigin: stringAt(state.publicExposure, "publicBaseUrl") }) }, + summary: { renderedText: renderQuickVerifySummary({ runId, scenarioId, observerId, artifactSummary, findings, findingCount: findings.length, steps, publicOrigin: stringAt(state.publicExposure, "publicBaseUrl") }) }, "auth-session-switch-summary": { renderedText: renderAuthSessionSwitchQuickVerifySummary({ runId, scenarioId, observerId, artifactSummary, steps, findings, accountEnv: accountEnv.summary, publicOrigin: stringAt(state.publicExposure, "publicBaseUrl") }) }, "turn-summary": { renderedText: typeof turnSummary.renderedText === "string" ? turnSummary.renderedText : null, ok: turnSummary.ok }, "trace-frame": { renderedText: typeof traceFrame.renderedText === "string" ? traceFrame.renderedText : null, ok: traceFrame.ok }, @@ -513,7 +517,11 @@ function finalizeQuickVerifyFailure(state: SentinelCicdState, input: { const controlFindings = quickVerifyControlFindings(input.failure, input.promptIndex, turnSummary, traceFrame); const artifactSummaryRecord = record(artifactSummary); const artifactFindings = Array.isArray(artifactSummaryRecord.findings) ? artifactSummaryRecord.findings.map(record) : []; - const findings = mergeFindingRecords(artifactFindings, controlFindings); + const visibilityFindings = quickVerifyAnalysisVisibilityFindings(analysis, artifactSummary); + const findings = mergeFindingRecords( + mergeFindingRecords(artifactFindings, controlFindings), + visibilityFindings, + ); const blockingFindings = findings.filter(isQuickVerifyBlockingFinding); const recoveredWaitFailure = durableBusinessTurn && isRecoverableQuickVerifyWaitFailure(input.failure) @@ -539,7 +547,7 @@ function finalizeQuickVerifyFailure(state: SentinelCicdState, input: { steps: [...input.steps, ...cleanupSteps], analysis: artifactSummary, views: { - summary: { renderedText: renderQuickVerifySummary({ runId: input.runId, scenarioId: input.scenarioId, observerId: input.observerId, artifactSummary, steps: input.steps, publicOrigin: stringAt(state.publicExposure, "publicBaseUrl") }) }, + summary: { renderedText: renderQuickVerifySummary({ runId: input.runId, scenarioId: input.scenarioId, observerId: input.observerId, artifactSummary, findings, findingCount: findings.length, steps: input.steps, publicOrigin: stringAt(state.publicExposure, "publicBaseUrl") }) }, "auth-session-switch-summary": { renderedText: renderAuthSessionSwitchQuickVerifySummary({ runId: input.runId, scenarioId: input.scenarioId, observerId: input.observerId, artifactSummary, steps: input.steps, findings, publicOrigin: stringAt(state.publicExposure, "publicBaseUrl") }) }, "turn-summary": { renderedText: typeof turnSummary.renderedText === "string" ? turnSummary.renderedText : null, ok: turnSummary.ok }, "trace-frame": { renderedText: typeof traceFrame.renderedText === "string" ? traceFrame.renderedText : null, ok: traceFrame.ok }, @@ -580,6 +588,26 @@ function quickVerifyCleanupFindings(cleanupStep: Record): Recor }]; } +function quickVerifyAnalysisVisibilityFindings(analysis: ChildCliResult, artifactSummary: Record): Record[] { + if (analysis.ok !== true) return []; + if (stringAtNullable(artifactSummary, "reportJsonSha256") !== null) return []; + const result = record(analysis.result); + const stdout = stringAtNullable(result, "stdoutPreview") ?? stringAtNullable(result, "stdoutTail"); + const reportPathMatch = stdout === null ? null : stdout.match(/analysis\/report\.(?:json|md)/u); + return [{ + id: "quick-verify-analysis-summary-unreadable", + severity: "red", + count: 1, + summary: "quick verify analyze exited successfully, but sentinel could not read analysis/report.json before recording the run.", + rootCause: "The report index would otherwise contain only control blockers such as WBC-003 and hide analyzer red/amber findings.", + rootCauseStatus: "confirmed", + rootCauseConfidence: "high", + evidenceSummary: `analyze ok=true reportJsonSha256=missing stdoutMentionsReport=${reportPathMatch === null ? "false" : "true"}`, + nextAction: "Read stateDir/analysis/report.json again or rehydrate the report from stateDir before trusting WBC-003 as the only visible finding.", + valuesRedacted: true, + }]; +} + function callSentinelService(state: SentinelCicdState, method: "GET" | "POST", pathWithQuery: string, body: Record | null, timeoutSeconds: number): Record { const namespace = stringAt(state.runtime, "namespace"); const serviceName = stringAt(state.runtime, "serviceName"); @@ -1540,6 +1568,7 @@ function isQuickVerifyBlockingFinding(item: Record): boolean { if (id === "observer-command-failed") return observerCommandFailureBlocks(item); return [ "quick-verify-no-business-turn", + "quick-verify-analysis-summary-unreadable", "quick-verify-command-sequence-failed", "quick-verify-observer-start-failed", "quick-verify-account-secret-missing", @@ -1798,12 +1827,17 @@ function compactCommandWithTail(result: CommandResult): CompactCommandResult & { function renderQuickVerifySummary(input: Record): string { const artifact = record(input.artifactSummary); - const findings = Array.isArray(artifact.findings) ? artifact.findings.map(record).slice(0, 8) : []; + const findings = Array.isArray(input.findings) + ? input.findings.map(record).slice(0, 8) + : Array.isArray(artifact.findings) + ? artifact.findings.map(record).slice(0, 8) + : []; + const findingCount = numberAtNullable(input, "findingCount") ?? numberAtNullable(artifact, "findingCount") ?? findings.length; return [ "Web Probe Sentinel Quick Verify", "=======================================================", `run=${input.runId ?? "-"} scenario=${input.scenarioId ?? "-"} observer=${input.observerId ?? "-"}`, - `report=${artifact.reportJsonSha256 ?? "-"} artifacts=${artifact.artifactCount ?? "-"} findings=${artifact.findingCount ?? findings.length}`, + `report=${artifact.reportJsonSha256 ?? "-"} artifacts=${artifact.artifactCount ?? "-"} findings=${findingCount}`, `publicOrigin=${input.publicOrigin ?? "-"}`, "", "Findings", diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts index 71f7d910..e8656fd0 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5.ts @@ -242,6 +242,11 @@ export function runSentinelReport(state: SentinelCicdState, options: Extract Object.keys(record(item.rootCauseSignals)).length > 0) .slice(0, 8) @@ -279,7 +288,9 @@ function compactSentinelReportRawPayload( observerId: run.observer_id ?? run.observerId ?? null, stateDir: run.state_dir ?? run.stateDir ?? null, reportJsonSha256: run.report_json_sha256 ?? run.reportJsonSha256 ?? artifact.reportJsonSha256 ?? null, - findingCount: run.finding_count ?? run.findingCount ?? findings.length, + findingCount: visibleFindingCount, + storedFindingCount, + artifactFindingCount, artifactCount: run.artifact_count ?? run.artifactCount ?? artifact.artifactCount ?? null, durationMinutes: run.durationMinutes ?? run.runDurationMinutes ?? record(run.timing).durationMinutes ?? null, runDurationMinutes: run.runDurationMinutes ?? run.durationMinutes ?? record(run.timing).durationMinutes ?? null, @@ -292,13 +303,15 @@ function compactSentinelReportRawPayload( valuesRedacted: true, }, summary: pickFields(record(body.summary), ["reason", "status", "businessStatus", "failure", "valuesRedacted"]), - findings: findings.slice(0, 12).map(compactSentinelReportFinding), + findings: visibleFindings.slice(0, 12).map(compactSentinelReportFinding), artifactSummary: Object.keys(artifact).length === 0 ? null : { ok: artifact.ok === true, reportOk: artifact.reportOk === true ? true : artifact.reportOk === false ? false : null, reportJsonPath: artifact.reportJsonPath ?? null, reportJsonSha256: artifact.reportJsonSha256 ?? null, reportMdSha256: artifact.reportMdSha256 ?? null, + findingCount: artifactFindingCount, + findings: artifactFindings.slice(0, 12).map(compactSentinelReportFinding), screenshot: record(artifact.screenshot), counts: record(artifact.counts), analysisWindow: compactSentinelAnalysisWindow(artifact.analysisWindow), @@ -315,6 +328,27 @@ function compactSentinelReportRawPayload( }; } +function mergeSentinelReportFindings(primary: readonly Record[], artifact: readonly Record[]): Record[] { + const merged: Record[] = []; + const seen = new Set(); + for (const item of [...primary, ...artifact]) { + const id = sentinelReportFindingIdentity(item); + const key = `${id ?? JSON.stringify(item).slice(0, 160)}:${stringAtNullable(item, "severity") ?? stringAtNullable(item, "level") ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(item); + } + return merged; +} + +function sentinelReportFindingIdentity(item: Record): string | null { + return stringAtNullable(item, "finding_id") + ?? stringAtNullable(item, "findingId") + ?? stringAtNullable(item, "id") + ?? stringAtNullable(item, "kind") + ?? stringAtNullable(item, "code"); +} + function compactSentinelReportFinding(value: Record): Record { const result: Record = { id: stringAtNullable(value, "finding_id") ?? stringAtNullable(value, "findingId") ?? stringAtNullable(value, "id") ?? stringAtNullable(value, "kind") ?? stringAtNullable(value, "code"), @@ -415,6 +449,35 @@ function renderSentinelReportSummary(payload: Record, state: Se ].join("\n"); } +function renderSentinelReportFindings(payload: Record): string { + const run = record(payload.run); + const artifactSummary = record(payload.artifactSummary); + const findings = Array.isArray(payload.findings) ? payload.findings.map(record) : []; + const reportSha = stringAtNullable(run, "reportJsonSha256") ?? stringAtNullable(artifactSummary, "reportJsonSha256"); + return [ + "Web Probe Sentinel Findings", + "=======================================================", + `run=${run.id ?? "-"} report=${reportSha ?? "-"} findings=${run.findingCount ?? findings.length}`, + findings.length === 0 ? "-" : findings.map(formatSentinelReportFindingLine).join("\n"), + ].join("\n"); +} + +function formatSentinelReportFindingLine(item: Record): string { + const check = record(item.check); + const code = stringAtNullable(check, "code") ?? stringAtNullable(item, "id") ?? "-"; + const title = stringAtNullable(check, "titleZh") ?? ""; + const summary = reportText(item.summary, 180) ?? ""; + const rootCause = reportText(item.rootCause, 180); + const evidence = reportText(item.evidenceSummary, 180); + const nextAction = reportText(item.nextAction, 180); + return [ + `${item.severity ?? "-"} ${code} ${title} count=${item.count ?? "-"} ${summary}`.trim(), + rootCause === null ? null : `rootCause=${rootCause}`, + evidence === null ? null : `evidence=${evidence}`, + nextAction === null ? null : `next=${nextAction}`, + ].filter((part): part is string => part !== null && part.length > 0).join(" | "); +} + function compactRootCauseSignals(value: unknown): Record | null { const item = record(value); const keys = [ diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 82d7889c..2dbc3e6b 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -916,8 +916,10 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, const row = readRunRow(config, db, runId); if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true }; const stored = readMetadata(db, `run.report.${runId}`) ?? {}; - const findings = findingsForRun(config, db, runId, dashboardMaxPageSize(config)); + const artifactSummary = readAnalysisArtifactSummaryForRun(config, row, dashboardMaxPageSize(config)); + const findings = visibleFindingsForRun(config, db, row, dashboardMaxPageSize(config), artifactSummary); const views = record(stored.views); + const reportJsonSha256 = stringOrNull(row.report_json_sha256) ?? stringOrNull(artifactSummary.reportJsonSha256); return { ok: true, contractVersion: DASHBOARD_CONTRACT_VERSION, @@ -929,7 +931,8 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, viewsAvailable: Object.keys(views), artifacts: { artifactCount: numberOr(row.artifact_count, 0), - reportJsonSha256: stringOrNull(row.report_json_sha256), + reportJsonSha256, + analysisReport: artifactSummary, screenshot: compactArtifactRef(stored.screenshot), publicOrigin: stringOrNull(stored.publicOrigin), }, @@ -1264,14 +1267,17 @@ function shortDashboardText(value: string, max: number): string { function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database, row: Record): Record { const id = stringOrNull(row.id); - const severityCounts = id === null ? {} : severityCountsForRun(config, db, id); + const artifactSummary = readAnalysisArtifactSummaryForRun(config, row, 50); + const findings = visibleFindingsForRun(config, db, row, 50, artifactSummary); + const severityCounts = checkLevelCounts(config, findings); const maxSeverity = maxSeverityFromCounts(severityCounts); - const findingTypeCount = numberOr(row.finding_count, 0); + const findingTypeCount = visibleFindingTypeCount(row, findings, artifactSummary); const findingSampleCount = Object.values(severityCounts).reduce((sum, value) => sum + numberOr(value, 0), 0); const findingAlertSampleCount = alertSeveritySampleCount(severityCounts); const stored = id === null ? {} : record(readMetadata(db, `run.report.${id}`)); const scenarioId = stringOrNull(row.scenario_id); const timing = dashboardRunTiming(config, row, stored); + const reportJsonSha256 = stringOrNull(row.report_json_sha256) ?? stringOrNull(artifactSummary.reportJsonSha256); return { id, runId: id, @@ -1285,8 +1291,8 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database observerId: stringOrNull(row.observer_id), state_dir: stringOrNull(row.state_dir), stateDir: stringOrNull(row.state_dir), - report_json_sha256: stringOrNull(row.report_json_sha256), - reportJsonSha256: stringOrNull(row.report_json_sha256), + report_json_sha256: reportJsonSha256, + reportJsonSha256, finding_count: findingTypeCount, findingCount: findingTypeCount, findingTypeCount, @@ -1511,6 +1517,98 @@ function findingsForRun(config: WebProbeSentinelServiceConfig, db: Database, run return rows.map((row) => enrichFindingRowWithStoredDetail(config, db, runId, row)); } +function visibleFindingsForRun( + config: WebProbeSentinelServiceConfig, + db: Database, + row: Record, + limit: number, + artifactSummary: Record = readAnalysisArtifactSummaryForRun(config, row, limit), +): readonly Record[] { + const runId = stringOrNull(row.id); + const storedFindings = runId === null ? [] : findingsForRun(config, db, runId, limit); + const artifactFindings = arrayRecords(artifactSummary.findings) + .slice(0, limit) + .map((item) => enrichFindingWithCheck(config, compactStoredFinding(item))); + return mergeVisibleFindings(storedFindings, artifactFindings, limit); +} + +function mergeVisibleFindings(primary: readonly Record[], artifact: readonly Record[], limit: number): readonly Record[] { + const merged: Record[] = []; + const seen = new Set(); + for (const item of [...primary, ...artifact]) { + const key = `${findingIdentity(item) ?? JSON.stringify(item).slice(0, 160)}:${stringOrNull(item.severity) ?? stringOrNull(item.level) ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(item); + if (merged.length >= limit) break; + } + return merged; +} + +function findingIdentity(item: Record): string | null { + return stringOrNull(item.finding_id) + ?? stringOrNull(item.findingId) + ?? stringOrNull(item.id) + ?? stringOrNull(item.kind) + ?? stringOrNull(item.code); +} + +function visibleFindingTypeCount(row: Record, findings: readonly Record[], artifactSummary: Record): number { + return Math.max( + numberOr(row.finding_count, 0), + numberOr(artifactSummary.findingCount, 0), + findings.length, + ); +} + +function readAnalysisArtifactSummaryForRun(config: WebProbeSentinelServiceConfig, row: Record, limit: number): Record { + const stateDir = stringOrNull(row.state_dir) ?? stringOrNull(row.stateDir); + if (stateDir === null) return { ok: false, reason: "state-dir-missing", findings: [], valuesRedacted: true }; + if (!isSafeDashboardStateDir(stateDir)) return { ok: false, reason: "unsafe-state-dir", stateDir, findings: [], valuesRedacted: true }; + const reportJsonPath = join(config.stateRoot, stateDir, "analysis", "report.json"); + const reportMdPath = join(config.stateRoot, stateDir, "analysis", "report.md"); + const jsonBuffer = readFileBuffer(reportJsonPath); + const mdBuffer = readFileBuffer(reportMdPath); + let parsed: unknown = null; + if (jsonBuffer !== null) { + try { + parsed = JSON.parse(jsonBuffer.toString("utf8")) as unknown; + } catch { + parsed = null; + } + } + const report = record(parsed); + const reportFindings = arrayRecords(report.findings); + const archiveFindings = arrayRecords(record(report.archiveSummary).redFindings); + const findings = (reportFindings.length > 0 ? reportFindings : archiveFindings) + .slice(0, limit) + .map(compactStoredFinding); + return { + ok: parsed !== null, + reason: parsed === null ? "analysis-report-json-missing-or-invalid" : null, + stateDir, + reportJsonPath, + reportJsonSha256: sha256Buffer(jsonBuffer), + reportMdSha256: sha256Buffer(mdBuffer), + findingCount: numberOr(report.findingCount, findings.length), + findings, + counts: record(report.counts), + valuesRedacted: true, + }; +} + +function readFileBuffer(path: string): Buffer | null { + try { + return readFileSync(path); + } catch { + return null; + } +} + +function sha256Buffer(value: Buffer | null): string | null { + return value === null ? null : `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + function enrichFindingRowWithStoredDetail(config: WebProbeSentinelServiceConfig, db: Database, runId: string, row: Record): Record { const detail = storedFindingDetailForRow(db, row, runId); return enrichFindingWithCheck(config, { @@ -1984,10 +2082,17 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view const selectedRunId = stringOrNull(row.id); if (selectedRunId === null) return { ok: false, error: "report-run-id-missing", view, valuesRedacted: true }; const stored = readMetadata(db, `run.report.${selectedRunId}`) ?? {}; - const findings = findingsForRun(config, db, selectedRunId, 50); + const artifactSummary = readAnalysisArtifactSummaryForRun(config, row, 50); + const findings = visibleFindingsForRun(config, db, row, 50, artifactSummary); const views = record(stored.views); const storedView = record(views[view]); - const renderedText = view === "findings" ? renderStoredFindings(row, findings) : typeof storedView.renderedText === "string" ? storedView.renderedText : view === "summary" ? renderStoredSummary(config, row, stored, findings) : null; + const renderedText = view === "findings" + ? renderStoredFindings(row, findings, artifactSummary) + : view === "summary" + ? renderStoredSummary(config, row, stored, findings, artifactSummary) + : typeof storedView.renderedText === "string" + ? storedView.renderedText + : null; if (renderedText === null) { return { ok: false, error: "report-view-not-indexed", runId: selectedRunId, view, availableViews: Object.keys(views), valuesRedacted: true }; } @@ -1997,6 +2102,7 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view run: dashboardRunSummary(config, db, row), summary: record(stored.summary), findings, + artifactSummary, renderedText, valuesRedacted: true, }; @@ -2033,15 +2139,23 @@ function formatStoredFindingTiming(item: Record): string | null return `timing=${pieces.join(" ")}`; } -function renderStoredSummary(config: WebProbeSentinelServiceConfig, row: Record, stored: Record, findings: readonly Record[]): string { +function renderStoredSummary( + config: WebProbeSentinelServiceConfig, + row: Record, + stored: Record, + findings: readonly Record[], + artifactSummary: Record, +): string { const summary = record(stored.summary); const timing = dashboardRunTiming(config, row, stored); + const reportSha = stringOrNull(row.report_json_sha256) ?? stringOrNull(artifactSummary.reportJsonSha256); + const findingCount = visibleFindingTypeCount(row, findings, artifactSummary); return [ "Web Probe Sentinel Report", "=======================================================", `run=${stringOrNull(row.id) ?? "-"} scenario=${stringOrNull(row.scenario_id) ?? "-"} status=${stringOrNull(row.status) ?? "-"}`, `observer=${stringOrNull(row.observer_id) ?? "-"} stateDir=${stringOrNull(row.state_dir) ?? "-"}`, - `report=${stringOrNull(row.report_json_sha256) ?? "-"} artifacts=${String(row.artifact_count ?? 0)} findings=${String(row.finding_count ?? findings.length)}`, + `report=${reportSha ?? "-"} artifacts=${String(row.artifact_count ?? 0)} findings=${String(findingCount)}`, `timing=${formatRunTimingSummary(timing)}`, `publicOrigin=${stringOrNull(stored.publicOrigin) ?? "-"}`, `analysisWindow=${formatStoredAnalysisWindow(summary.analysisWindow)}`, @@ -2051,11 +2165,12 @@ function renderStoredSummary(config: WebProbeSentinelServiceConfig, row: Record< ].join("\n"); } -function renderStoredFindings(row: Record, findings: readonly Record[]): string { +function renderStoredFindings(row: Record, findings: readonly Record[], artifactSummary: Record): string { + const reportSha = stringOrNull(row.report_json_sha256) ?? stringOrNull(artifactSummary.reportJsonSha256); return [ "Web Probe Sentinel Findings", "=======================================================", - `run=${stringOrNull(row.id) ?? "-"} report=${stringOrNull(row.report_json_sha256) ?? "-"}`, + `run=${stringOrNull(row.id) ?? "-"} report=${reportSha ?? "-"} findings=${String(visibleFindingTypeCount(row, findings, artifactSummary))}`, findings.length === 0 ? "-" : findings.map(formatStoredFindingLine).join("\n"), ].join("\n"); }