diff --git a/packages/cli/src/commands/scan/output-scan-report.mts b/packages/cli/src/commands/scan/output-scan-report.mts index 0f8133565e..6db17145b7 100644 --- a/packages/cli/src/commands/scan/output-scan-report.mts +++ b/packages/cli/src/commands/scan/output-scan-report.mts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises' import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' +import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' import { generateReport } from './generate-report.mts' import { @@ -35,6 +36,85 @@ export type OutputScanReportConfig = { short: boolean } +export type ReportAlertRow = { + alertType: string + introducedBy: string + manifest: string + packageName: string + policy: string + url: string +} + +/** + * Flatten the nested ecosystem/package/version alert maps into one row per + * alert. Both the markdown and the plain-text renderer read the same rows so + * the two formats can never drift apart. + */ +export function flattenReportAlerts(report: ScanReport): ReportAlertRow[] { + return Array.from(walkNestedMap(report.alerts)).map( + ({ keys, value }: { keys: string[]; value: ReportLeafNode }) => { + const { manifest, policy, type, url } = value + return { + alertType: type, + introducedBy: keys[2] || '', + manifest: joinAnd(manifest), + packageName: keys[1] || '', + policy, + url, + } + }, + ) +} + +/** + * Lay the alerts out as space-padded columns with each alert's URL on its own + * indented line, so a long URL cannot stretch the table past a readable width. + */ +export function formatAlertTable(rows: ReportAlertRow[]): string[] { + const headers = [ + 'POLICY', + 'ALERT TYPE', + 'PACKAGE', + 'INTRODUCED BY', + 'MANIFEST FILE', + ] + const cells = rows.map(row => [ + row.policy, + row.alertType, + row.packageName, + row.introducedBy, + row.manifest, + ]) + const widths = headers.map((header, i) => + Math.max(header.length, ...cells.map(cell => (cell[i] ?? '').length)), + ) + const toRow = (values: string[]) => + ` ${values + .map((value, i) => value.padEnd(widths[i] ?? 0)) + .join(' ') + .trimEnd()}` + + const out = [toRow(headers), toRow(widths.map(width => '-'.repeat(width)))] + for (let i = 0, { length } = cells; i < length; i += 1) { + out.push(toRow(cells[i]!)) + const { url } = rows[i]! + if (url) { + out.push(` ${url}`) + } + } + return out +} + +/** + * Space-pad `Label:` prefixes so the values line up in a column. + */ +export function formatLabelledPairs(pairs: Array<[string, string]>): string[] { + const width = Math.max(...pairs.map(pair => pair[0].length)) + return pairs.map( + ([label, value]) => ` ${`${label}:`.padEnd(width + 1)} ${value}`, + ) +} + export async function outputScanReport( result: CResult<{ scan: SocketArtifact[] @@ -141,7 +221,9 @@ export async function outputScanReport( if (short) { logger.log(scanReport.data.healthy ? 'OK' : 'ERR') } else { - logger.dir(scanReport.data, { depth: undefined }) + logger.log( + toPlainTextReport(scanReport.data as ScanReport, includeLicensePolicy), + ) } } @@ -180,19 +262,14 @@ export function toMarkdownReport( ? 'none' : `up to ${report.options.fold}` - const flatData = Array.from(walkNestedMap(report.alerts)).map( - ({ keys, value }: { keys: string[]; value: ReportLeafNode }) => { - const { manifest, policy, type, url } = value - return { - 'Alert Type': type, - Package: keys[1] || '', - 'Introduced by': keys[2] || '', - url, - 'Manifest file': joinAnd(manifest), - Policy: policy, - } - }, - ) + const flatData = flattenReportAlerts(report).map(row => ({ + 'Alert Type': row.alertType, + Package: row.packageName, + 'Introduced by': row.introducedBy, + url: row.url, + 'Manifest file': row.manifest, + Policy: row.policy, + })) const minPolicyLevel = reportLevel === REPORT_LEVEL_DEFER ? 'everything' : reportLevel @@ -245,3 +322,65 @@ ${ return md } + +/** + * Render the report as plain text for a terminal or a CI/CD log. + * + * Log viewers show one long stream of monospaced lines, so this sticks to + * labelled sections and space-padded columns. There is no colour, no + * box-drawing, and no character outside printable ASCII, which keeps the + * output readable when a log is piped to a file, replayed without a TTY, or + * ingested by a log aggregator. + */ +// socket-lint: allow boolean-trap -- matches the toJsonReport / toMarkdownReport +// signatures this sits beside; changing one alone would split the trio. +export function toPlainTextReport( + report: ScanReport, + includeLicensePolicy?: boolean | undefined, +): string { + const { reportLevel } = report.options + const policyWord = includeLicensePolicy ? 'security and license' : 'security' + const alertFolding = + report.options.fold === FOLD_SETTING_NONE + ? 'none' + : `up to ${report.options.fold}` + const minPolicyLevel = + reportLevel === REPORT_LEVEL_DEFER ? 'everything' : reportLevel + + const lines = [ + 'Socket scan policy report', + '', + 'Health status', + report.healthy + ? ` PASSES all requirements set by your ${policyWord} policy.` + : ' VIOLATES one or more policies set to the "error" level.', + '', + 'Settings', + ...formatLabelledPairs([ + ['Organization', report.orgSlug], + ['Scan ID', report.scanId], + ['Alert folding', alertFolding], + ['Minimum policy level', minPolicyLevel], + ['Include license alerts', includeLicensePolicy ? 'yes' : 'no'], + ]), + '', + 'Alerts', + ] + + const rows = flattenReportAlerts(report) + if (!rows.length) { + lines.push( + ` No alerts with a policy set to at least "${reportLevel}".`, + '', + ) + return lines.join('\n') + } + + lines.push( + ` ${rows.length} ${pluralize('alert', { count: rows.length })} with a policy set to at least "${reportLevel}".`, + '', + ...formatAlertTable(rows), + '', + ) + return lines.join('\n') +} diff --git a/packages/cli/test/unit/commands/scan/output-scan-report-text.test.mts b/packages/cli/test/unit/commands/scan/output-scan-report-text.test.mts new file mode 100644 index 0000000000..9c5227ab89 --- /dev/null +++ b/packages/cli/test/unit/commands/scan/output-scan-report-text.test.mts @@ -0,0 +1,232 @@ +/** + * Unit tests for the plain-text scan report renderer. + * + * Purpose: The default (non-json, non-markdown) scan report is read in CI/CD + * logs, where there is no TTY and no interactive pager. These tests pin the + * properties that make that output legible rather than snapshotting the whole + * blob, so the assertions say what the contract is. + * + * Related Files: - src/commands/scan/output-scan-report.mts (implementation) + */ + +import { describe, expect, it } from 'vitest' + +import { + flattenReportAlerts, + toPlainTextReport, +} from '../../../../src/commands/scan/output-scan-report.mts' + +import type { ScanReport } from '../../../../src/commands/scan/generate-report.mts' + +// Matches any ANSI escape sequence, the codes that show up as literal noise +// like "[32m" in a log viewer that does not interpret them. +// oxlint-disable-next-line no-control-regex -- matching control characters is the point. +const ANSI_PATTERN = /\[[0-9;]*m/ + +function buildReport(config: { + alerts: ScanReport['alerts'] + healthy: boolean +}) { + const { alerts, healthy } = config + return { + alerts, + healthy, + options: { fold: 'none', reportLevel: 'error' }, + orgSlug: 'acme', + scanId: 'scan-abc-123', + } as ScanReport +} + +function buildAlerts(): ScanReport['alerts'] { + return new Map([ + [ + 'npm', + new Map([ + [ + 'acme-widget', + new Map([ + [ + '1.0.0', + { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: 'https://socket.dev/npm/package/acme-widget', + }, + ], + ]), + ], + ]), + ], + ]) as unknown as ScanReport['alerts'] +} + +describe('toPlainTextReport', () => { + it('emits no ANSI escape codes', () => { + const text = toPlainTextReport( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + + expect(text).not.toMatch(ANSI_PATTERN) + }) + + it('emits only printable ASCII, so no glyph can mangle in a log viewer', () => { + const text = toPlainTextReport( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + + // Everything outside printable ASCII plus newline. + // oxlint-disable-next-line no-control-regex -- asserting the absence of control characters. + expect(text).not.toMatch(/[^\n\x20-\x7E]/) + }) + + it('never renders a raw JavaScript object dump', () => { + const text = toPlainTextReport( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + + // The old renderer used logger.dir, which printed nested Maps as + // "Map(1) { 'npm' => Map(1) { ... } }". + expect(text).not.toContain('Map(') + expect(text).not.toContain('=>') + expect(text).not.toContain('[Object') + }) + + it('labels each setting on its own line', () => { + const text = toPlainTextReport( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + + expect(text).toContain('Organization:') + expect(text).toContain('acme') + expect(text).toContain('Scan ID:') + expect(text).toContain('scan-abc-123') + expect(text).toContain('Alert folding:') + expect(text).toContain('Minimum policy level:') + }) + + it('states the health status in words', () => { + expect( + toPlainTextReport(buildReport({ alerts: buildAlerts(), healthy: false })), + ).toContain('VIOLATES') + expect( + toPlainTextReport(buildReport({ alerts: new Map(), healthy: true })), + ).toContain('PASSES') + }) + + it('lists each alert with its package, source and manifest', () => { + const text = toPlainTextReport( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + + expect(text).toContain('acme-widget') + expect(text).toContain('envVars') + expect(text).toContain('package.json') + expect(text).toContain('https://socket.dev/npm/package/acme-widget') + }) + + it('aligns the alert columns so the table scans vertically', () => { + // Two packages of very different name lengths. Without padding the second + // row's cells slide left and stop lining up under the headers. + const shortName = 'acme-a' + const longName = 'acme-a-much-longer-package-name' + const alerts = new Map([ + [ + 'npm', + new Map([ + [ + shortName, + new Map([ + [ + '1.0.0', + { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: '', + }, + ], + ]), + ], + [ + longName, + new Map([ + [ + '2.0.0', + { + manifest: ['package.json'], + policy: 'warn', + type: 'telemetry', + url: '', + }, + ], + ]), + ], + ]), + ], + ]) as unknown as ScanReport['alerts'] + + const lines = toPlainTextReport( + buildReport({ alerts, healthy: false }), + ).split('\n') + const header = lines.find(line => line.includes('ALERT TYPE'))! + const shortRow = lines.find(line => line.includes('envVars'))! + const longRow = lines.find(line => line.includes('telemetry'))! + + // Each cell must begin at exactly the offset its header begins at. + const packageColumn = header.indexOf('PACKAGE') + expect( + shortRow.slice(packageColumn, packageColumn + shortName.length), + ).toBe(shortName) + expect(longRow.slice(packageColumn, packageColumn + longName.length)).toBe( + longName, + ) + + const manifestColumn = header.indexOf('MANIFEST FILE') + expect( + shortRow.slice(manifestColumn, manifestColumn + 'package.json'.length), + ).toBe('package.json') + expect( + longRow.slice(manifestColumn, manifestColumn + 'package.json'.length), + ).toBe('package.json') + }) + + it('keeps every line within a readable width', () => { + const text = toPlainTextReport( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + const overlong = text + .split('\n') + .filter(line => !line.trim().startsWith('https://') && line.length > 100) + + expect(overlong).toEqual([]) + }) + + it('says so plainly when there are no alerts', () => { + const text = toPlainTextReport( + buildReport({ alerts: new Map(), healthy: true }), + ) + + expect(text).toContain('No alerts') + expect(text).not.toContain('ALERT TYPE') + }) +}) + +describe('flattenReportAlerts', () => { + it('returns one row per alert with the nesting keys resolved', () => { + const rows = flattenReportAlerts( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + + expect(rows).toEqual([ + { + alertType: 'envVars', + introducedBy: '1.0.0', + manifest: 'package.json', + packageName: 'acme-widget', + policy: 'error', + url: 'https://socket.dev/npm/package/acme-widget', + }, + ]) + }) +}) diff --git a/packages/cli/test/unit/commands/scan/output-scan-report.test.mts b/packages/cli/test/unit/commands/scan/output-scan-report.test.mts index 5db5c8b06a..edb2b688f7 100644 --- a/packages/cli/test/unit/commands/scan/output-scan-report.test.mts +++ b/packages/cli/test/unit/commands/scan/output-scan-report.test.mts @@ -138,7 +138,10 @@ describe('output-scan-report', () => { await outputScanReport(successResult, baseConfig) expect(process.exitCode).toBeUndefined() - expect(mockLogger.dir).toHaveBeenCalled() + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Socket scan policy report'), + ) + expect(mockLogger.dir).not.toHaveBeenCalled() }) it('should set exit code 1 for unhealthy report', async () => {