From cf93334ce39062c89a924997d30b885a7c61f61c Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 12 Nov 2026 09:06:07 -0500 Subject: [PATCH] fix(report): clarify scan output and package versions --- .../scan/output-scan-report-text.test.mts | 410 ++++++++++++++++++ src/commands/scan/output-scan-report.mts | 160 ++++++- src/commands/scan/output-scan-report.test.mts | 12 +- 3 files changed, 558 insertions(+), 24 deletions(-) create mode 100644 src/commands/scan/output-scan-report-text.test.mts 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..5da02fe0d --- /dev/null +++ b/src/commands/scan/output-scan-report-text.test.mts @@ -0,0 +1,410 @@ +import { describe, expect, it } from 'vitest' + +import { + flattenReportAlerts, + toJsonReport, + toMarkdownReport, + toPlainTextReport, +} from './output-scan-report.mts' +import { + FOLD_SETTING_NONE, + FOLD_SETTING_PKG, + 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'] { + const leaf = alert( + 'error', + 'envVars', + 'package.json', + 'https://socket.dev/npm/package/acme-widget', + ) + return new Map([ + [ + 'npm', + new Map([ + [ + 'acme-widget-π', + new Map([ + [ + '1.0.0', + new Map([['package.json', new Map([['envVars at 1:2', leaf]])]]), + ], + ]), + ], + ]), + ], + ]) +} + +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') + expect(text).toContain('VERSION') + expect(text).not.toContain('INTRODUCED BY') + }) + + 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') + expect(text).not.toContain('VERSION') + }) + + 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') + }) + + it('keeps report fields aligned across formats and fold modes', () => { + const leaf = alert( + 'error', + 'envVars', + 'package.json', + 'https://socket.dev/npm/package/demo-transitive', + ) + const versionFoldAlerts: ScanReport['alerts'] = new Map([ + ['npm', new Map([['demo-transitive', new Map([['2.3.4', leaf]])]])], + ]) + const fileFoldAlerts: ScanReport['alerts'] = new Map([ + [ + 'npm', + new Map([ + [ + 'demo-transitive', + new Map([ + [ + '2.3.4', + new Map([ + ['package.json', leaf], + [ + 'src/index.js', + alert( + 'warn', + 'telemetry', + 'package-lock.json', + 'https://socket.dev/npm/package/demo-transitive', + ), + ], + ]), + ], + ]), + ], + ]), + ], + ]) + const packageFoldAlerts: ScanReport['alerts'] = new Map([ + ['npm', new Map([['demo-transitive', leaf]])], + ]) + const twoVersionAlerts: ScanReport['alerts'] = new Map([ + [ + 'npm', + new Map([ + [ + 'demo-transitive', + new Map([ + ['2.3.4', leaf], + [ + '2.3.5', + alert( + 'warn', + 'telemetry', + 'package.json', + 'https://socket.dev/npm/package/demo-transitive', + ), + ], + ]), + ], + ]), + ], + ]) + const versionFoldReport = buildReport({ + alerts: versionFoldAlerts, + fold: 'version', + healthy: false, + }) + const fileFoldReport = buildReport({ + alerts: fileFoldAlerts, + fold: 'file', + healthy: false, + }) + const packageFoldReport = buildReport({ + alerts: packageFoldAlerts, + fold: FOLD_SETTING_PKG, + healthy: false, + }) + const defaultFoldReport = buildReport({ alerts: buildAlerts() }) + const twoVersionReport = buildReport({ + alerts: twoVersionAlerts, + fold: 'version', + }) + + expect(flattenReportAlerts(versionFoldReport)[0]?.packageVersion).toBe( + '2.3.4', + ) + expect(toPlainTextReport(versionFoldReport)).toContain('VERSION') + expect(toPlainTextReport(versionFoldReport)).toContain('2.3.4') + expect(flattenReportAlerts(fileFoldReport)).toEqual([ + expect.objectContaining({ + manifest: 'package.json', + packageVersion: '2.3.4', + }), + expect.objectContaining({ + manifest: 'package-lock.json', + packageVersion: '2.3.4', + }), + ]) + expect(toPlainTextReport(fileFoldReport)).toContain('VERSION') + expect(toPlainTextReport(fileFoldReport)).toContain('2.3.4') + expect(toPlainTextReport(fileFoldReport)).toContain('package-lock.json') + expect(flattenReportAlerts(packageFoldReport)[0]?.packageVersion).toBe( + '', + ) + expect(toPlainTextReport(packageFoldReport)).toContain('') + expect(flattenReportAlerts(twoVersionReport).map(row => row.packageVersion)) + .toEqual(['2.3.4', '2.3.5']) + const reports = [ + defaultFoldReport, + versionFoldReport, + fileFoldReport, + packageFoldReport, + twoVersionReport, + ] + + for (const report of reports) { + const rows = flattenReportAlerts(report) + const text = toPlainTextReport(report) + const markdown = toMarkdownReport(report) + const markdownHeader = markdown + .split('\n') + .find(line => line.includes('| Policy |')) + + expect(text).toContain('VERSION') + expect(markdownHeader).toContain('Version') + expect(markdownHeader).toContain('URL') + for (const row of rows) { + expect(text).toContain(row.packageVersion) + expect(markdown).toContain(row.packageVersion) + expect(text).toContain(row.manifest) + expect(markdown).toContain(row.manifest) + if (row.url) { + expect(text).toContain(row.url) + expect(markdown).toContain(row.url) + } + } + } + }) +}) + +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': { + 'package.json': { + 'envVars at 1:2': { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: 'https://socket.dev/npm/package/acme-widget', + }, + }, + }, + }, + }, + }, + includeLicensePolicy: true, + }, + }) + expect(JSON.parse(json)).not.toHaveProperty( + 'data.alerts.npm.acme-widget-π.1.0.0.packageVersion', + ) + }) + + 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', + manifest: 'package.json', + packageName: 'acme-widget-π', + packageVersion: '1.0.0', + 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 b2e3a9112..2af41c1c1 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' @@ -31,6 +34,77 @@ export type OutputScanReportConfig = { short: boolean } +export type ReportAlertRow = { + alertType: string + manifest: string + packageName: string + packageVersion: 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, + manifest: joinAnd(manifest), + packageName: keys[1] || '', + // The third map key is the package version shown in report output. + packageVersion: keys[2] || '', + policy, + url, + } + }, + ) +} + +export function formatAlertTable(rows: ReportAlertRow[]): string[] { + const headers = [ + 'POLICY', + 'ALERT TYPE', + 'PACKAGE', + 'VERSION', + 'MANIFEST FILE', + ] + const cells = rows.map(row => [ + row.policy, + row.alertType, + row.packageName, + row.packageVersion, + 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[] @@ -134,7 +208,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), + ) } } @@ -163,26 +239,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, + Version: row.packageVersion, + 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 = ` @@ -224,8 +295,8 @@ ${ 'Policy', 'Alert Type', 'Package', - 'Introduced by', - 'url', + 'Version', + 'URL', 'Manifest file', ]) } @@ -233,3 +304,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') +} diff --git a/src/commands/scan/output-scan-report.test.mts b/src/commands/scan/output-scan-report.test.mts index 38f813985..49b41e7b6 100644 --- a/src/commands/scan/output-scan-report.test.mts +++ b/src/commands/scan/output-scan-report.test.mts @@ -137,12 +137,12 @@ describe('output-scan-report', () => { All the alerts from the scan with a policy set to at least "warn". - | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | - | Policy | Alert Type | Package | Introduced by | url | Manifest file | - | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | - | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | - | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | - | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | + | ------ | ---------- | ------- | ------- | ------------------------------------------- | ----------------- | + | Policy | Alert Type | Package | Version | URL | Manifest file | + | ------ | ---------- | ------- | ------- | ------------------------------------------- | ----------------- | + | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | + | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | + | ------ | ---------- | ------- | ------- | ------------------------------------------- | ----------------- | " `) })