diff --git a/src/commands/scan/output-scan-report-text.test.mts b/src/commands/scan/output-scan-report-text.test.mts new file mode 100644 index 000000000..71410f75b --- /dev/null +++ b/src/commands/scan/output-scan-report-text.test.mts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest' + +import { + flattenReportAlerts, + toJsonReport, + toMarkdownReport, + toPlainTextReport, +} from './output-scan-report.mts' +import { + FOLD_SETTING_NONE, + REPORT_LEVEL_DEFER, + REPORT_LEVEL_ERROR, +} from '../../constants.mts' + +import type { ReportLeafNode, ScanReport } from './generate-report.mts' + +const ANSI_ESCAPE = String.fromCharCode(0x1b) + +function alert( + policy: ReportLeafNode['policy'], + type: string, + manifest: string, + url: string, +): ReportLeafNode { + return { manifest: [manifest], policy, type, url } +} + +function buildReport( + config: { + alerts?: ScanReport['alerts'] + fold?: ScanReport['options']['fold'] + healthy?: boolean + reportLevel?: ScanReport['options']['reportLevel'] + } = {}, +): ScanReport { + const { + alerts = new Map(), + fold = FOLD_SETTING_NONE, + healthy = true, + reportLevel = REPORT_LEVEL_ERROR, + } = config + return { + alerts, + healthy, + options: { fold, reportLevel }, + orgSlug: 'acme', + scanId: 'scan-abc-123', + } +} + +function buildAlerts(): ScanReport['alerts'] { + return new Map([ + [ + 'npm', + new Map([ + [ + 'acme-widget-π', + new Map([ + [ + '1.0.0', + alert( + 'error', + 'envVars', + 'package.json', + 'https://socket.dev/npm/package/acme-widget', + ), + ], + ]), + ], + ]), + ], + ]) +} + +describe('toPlainTextReport', () => { + it('renders a labelled report without ANSI escapes or raw object dumps', () => { + const text = toPlainTextReport( + buildReport({ alerts: buildAlerts(), healthy: false }), + ) + + expect(text.includes(ANSI_ESCAPE)).toBe(false) + expect(text).not.toContain('Map(') + expect(text).not.toContain('=>') + expect(text).not.toContain('[Object') + expect(text).toContain('VIOLATES') + 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('preserves Unicode package values and prints alert details', () => { + const text = toPlainTextReport(buildReport({ alerts: buildAlerts() })) + + expect(text).toContain('PASSES') + expect(text).toContain('acme-widget-π') + expect(text).toContain('envVars') + expect(text).toContain('1.0.0') + expect(text).toContain('package.json') + expect(text).toContain('https://socket.dev/npm/package/acme-widget') + }) + + it('aligns columns for one alert and keeps its URL on a separate line', () => { + const text = toPlainTextReport(buildReport({ alerts: buildAlerts() })) + const lines = text.split(/\r?\n/) + const header = lines.find(line => line.includes('ALERT TYPE'))! + const row = lines.find(line => line.includes('envVars'))! + const packageColumn = header.indexOf('PACKAGE') + + expect( + row.slice(packageColumn, packageColumn + 'acme-widget-π'.length), + ).toBe('acme-widget-π') + expect(text).toContain('1 alert with a policy') + expect(lines).toContain(' https://socket.dev/npm/package/acme-widget') + }) + + it('aligns short and long package names across multiple rows', () => { + const alerts: ScanReport['alerts'] = new Map([ + [ + 'npm', + new Map([ + [ + 'pkg-a', + new Map([['1.0.0', alert('error', 'envVars', 'package.json', '')]]), + ], + [ + 'pkg-a-much-longer-name', + new Map([ + ['2.0.0', alert('warn', 'telemetry', 'package-lock.json', '')], + ]), + ], + ]), + ], + ]) + const lines = toPlainTextReport(buildReport({ alerts })).split(/\r?\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'))! + const packageColumn = header.indexOf('PACKAGE') + const manifestColumn = header.indexOf('MANIFEST FILE') + + expect(shortRow.slice(packageColumn, packageColumn + 5)).toBe('pkg-a') + expect(longRow.slice(packageColumn, packageColumn + 22)).toBe( + 'pkg-a-much-longer-name', + ) + expect(shortRow.slice(manifestColumn, manifestColumn + 12)).toBe( + 'package.json', + ) + expect(longRow.slice(manifestColumn, manifestColumn + 18)).toBe( + 'package-lock.json', + ) + expect( + lines.find(line => line.includes('2 alerts with a policy')), + ).toBeDefined() + }) + + it('describes empty alerts and omits the alert table', () => { + const text = toPlainTextReport(buildReport()) + + expect(text).toContain('PASSES') + expect(text).toContain('No alerts') + expect(text).not.toContain('ALERT TYPE') + }) + + it('includes license, fold and deferred-level settings', () => { + const text = toPlainTextReport( + buildReport({ + alerts: buildAlerts(), + fold: 'version', + reportLevel: REPORT_LEVEL_DEFER, + }), + true, + ) + + expect(text).toContain('security and license policy') + expect( + text.split('\n').find(line => line.includes('Alert folding:')), + ).toContain('up to version') + expect( + text.split('\n').find(line => line.includes('Minimum policy level:')), + ).toContain('everything') + expect( + text.split('\n').find(line => line.includes('Include license alerts:')), + ).toContain('yes') + }) + + it('renders a large alert report without argument-count failures', () => { + const alertRow = alert('error', 'envVars', 'package.json', '') + const packages = new Map>() + const alertCount = 200_000 + for (let i = 0; i < alertCount; i += 1) { + packages.set( + `package-${String(i).padStart(6, '0')}`, + new Map([['1.0.0', alertRow]]), + ) + } + const alerts: ScanReport['alerts'] = new Map([['npm', packages]]) + const text = toPlainTextReport(buildReport({ alerts })) + + expect(text).toContain('200000 alerts with a policy') + expect(text).toContain('package-000000') + expect(text).toContain('package-199999') + }) +}) + +describe('structured scan reports', () => { + it('keeps nested alert data and the license flag in JSON', () => { + const json = toJsonReport(buildReport({ alerts: buildAlerts() }), true) + + expect(JSON.parse(json)).toMatchObject({ + ok: true, + data: { + alerts: { + npm: { + 'acme-widget-π': { + '1.0.0': { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: 'https://socket.dev/npm/package/acme-widget', + }, + }, + }, + }, + includeLicensePolicy: true, + }, + }) + }) + + it('keeps alert fields and URLs in Markdown', () => { + const markdown = toMarkdownReport( + buildReport({ alerts: buildAlerts() }), + true, + ) + + expect(markdown).toContain('security or license policy') + expect(markdown).toContain('acme-widget-π') + expect(markdown).toContain('envVars') + expect(markdown).toContain('package.json') + expect(markdown).toContain('https://socket.dev/npm/package/acme-widget') + }) + + it('flattens one row per report alert', () => { + expect(flattenReportAlerts(buildReport({ alerts: buildAlerts() }))).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/src/commands/scan/output-scan-report.mts b/src/commands/scan/output-scan-report.mts index 35965bf96..e9c22048e 100644 --- a/src/commands/scan/output-scan-report.mts +++ b/src/commands/scan/output-scan-report.mts @@ -2,12 +2,15 @@ import fs from 'node:fs/promises' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { pluralize } from '@socketsecurity/registry/lib/words' import { generateReport } from './generate-report.mts' import constants, { EXT_JSON, + FOLD_SETTING_NONE, OUTPUT_JSON, OUTPUT_TEXT, + REPORT_LEVEL_DEFER, } from '../../constants.mts' import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' import { mapToObject } from '../../utils/map-to-object.mts' @@ -32,6 +35,76 @@ export type OutputScanReportConfig = { short: boolean } +export type ReportAlertRow = { + alertType: string + introducedBy: string + manifest: string + packageName: string + policy: string + url: string +} + +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, + } + }, + ) +} + +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 => header.length) + for (const cell of cells) { + for (let i = 0, { length } = cell; i < length; i += 1) { + widths[i] = Math.max(widths[i] ?? 0, cell[i]?.length ?? 0) + } + } + 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 +} + +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[] @@ -137,7 +210,9 @@ export async function outputScanReport( if (short) { logger.log(scanReport.data.healthy ? 'OK' : 'ERR') } else { - logger.dir(scanReport.data, { depth: null }) + logger.log( + toPlainTextReport(scanReport.data as ScanReport, includeLicensePolicy), + ) } } @@ -166,26 +241,21 @@ export function toMarkdownReport( const reportLevel = report.options.reportLevel const alertFolding = - report.options.fold === constants.FOLD_SETTING_NONE + report.options.fold === FOLD_SETTING_NONE ? '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 === constants.REPORT_LEVEL_DEFER ? 'everything' : reportLevel + reportLevel === REPORT_LEVEL_DEFER ? 'everything' : reportLevel const md = ` @@ -236,3 +306,56 @@ ${ return md } + +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', rows.length)} with a policy set to at least "${reportLevel}".`, + '', + ) + for (const line of formatAlertTable(rows)) { + lines.push(line) + } + lines.push('') + return lines.join('\n') +}