diff --git a/src/command/scan/output-scan-report.mts b/src/command/scan/output-scan-report.mts index b42b096c9..33d9f11ec 100644 --- a/src/command/scan/output-scan-report.mts +++ b/src/command/scan/output-scan-report.mts @@ -38,9 +38,9 @@ export type OutputScanReportConfig = { export type ReportAlertRow = { alertType: string - introducedBy: string manifest: string packageName: string + packageVersion: string policy: string url: string } @@ -57,9 +57,9 @@ export function flattenReportAlerts(report: ScanReport): ReportAlertRow[] { return { __proto__: null, alertType: type, - introducedBy: keys[2] || '', manifest: joinAnd(manifest), packageName: keys[1] || '', + packageVersion: keys[2] || '', policy, url, } @@ -76,19 +76,34 @@ export function formatAlertTable(rows: ReportAlertRow[]): string[] { 'POLICY', 'ALERT TYPE', 'PACKAGE', - 'INTRODUCED BY', + 'VERSION', 'MANIFEST FILE', ] const cells = rows.map(row => [ row.policy, row.alertType, row.packageName, - row.introducedBy, + row.packageVersion, row.manifest, ]) - const widths = headers.map((header, i) => - Math.max(header.length, ...cells.map(cell => (cell[i] ?? '').length)), - ) + const widths = headers.map(header => header.length) + for ( + let rowIndex = 0, { length: rowCount } = cells; + rowIndex < rowCount; + rowIndex += 1 + ) { + const cell = cells[rowIndex]! + for ( + let cellIndex = 0, { length: cellLength } = cell; + cellIndex < cellLength; + cellIndex += 1 + ) { + widths[cellIndex] = Math.max( + widths[cellIndex] ?? 0, + cell[cellIndex]?.length ?? 0, + ) + } + } const toRow = (values: string[]) => ` ${values .map((value, i) => value.padEnd(widths[i] ?? 0)) @@ -116,6 +131,12 @@ export function formatLabelledPairs(pairs: Array<[string, string]>): string[] { ) } +export function isFullScanReport( + report: ScanReport | { healthy: boolean }, +): report is ScanReport { + return 'alerts' in report +} + export async function outputGeneratedScanReport( scanReport: Extract, { ok: true }>, config: Pick< @@ -159,10 +180,10 @@ export async function outputGeneratedScanReport( if (short) { logger.log(scanReport.data.healthy ? 'OK' : 'ERR') + } else if (isFullScanReport(scanReport.data)) { + logger.log(toPlainTextReport(scanReport.data, includeLicensePolicy)) } else { - logger.log( - toPlainTextReport(scanReport.data as ScanReport, includeLicensePolicy), - ) + logger.log(scanReport.data.healthy ? 'OK' : 'ERR') } } @@ -286,7 +307,7 @@ export function toMarkdownReport( __proto__: null, 'Alert Type': row.alertType, Package: row.packageName, - 'Introduced by': row.introducedBy, + Version: row.packageVersion, url: row.url, 'Manifest file': row.manifest, Policy: row.policy, @@ -334,7 +355,7 @@ ${ 'Policy', 'Alert Type', 'Package', - 'Introduced by', + 'Version', 'url', 'Manifest file', ]) @@ -401,8 +422,10 @@ export function toPlainTextReport( lines.push( ` ${rows.length} ${pluralize('alert', { count: rows.length })} with a policy set to at least "${reportLevel}".`, '', - ...formatAlertTable(rows), - '', ) + for (const line of formatAlertTable(rows)) { + lines.push(line) + } + lines.push('') return lines.join('\n') } diff --git a/test/unit/command/scan/output-scan-report-markdown.test.mts b/test/unit/command/scan/output-scan-report-markdown.test.mts index 7a22f4345..07f2f704b 100644 --- a/test/unit/command/scan/output-scan-report-markdown.test.mts +++ b/test/unit/command/scan/output-scan-report-markdown.test.mts @@ -100,12 +100,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 | + | ------ | ---------- | ------- | ------- | ------------------------------------------- | ----------------- | " `) }) diff --git a/test/unit/command/scan/output-scan-report-text.test.mts b/test/unit/command/scan/output-scan-report-text.test.mts index b0b604b6d..a0f518285 100644 --- a/test/unit/command/scan/output-scan-report-text.test.mts +++ b/test/unit/command/scan/output-scan-report-text.test.mts @@ -13,10 +13,18 @@ import { describe, expect, it } from 'vitest' import { flattenReportAlerts, + isFullScanReport, toPlainTextReport, } from '../../../../src/command/scan/output-scan-report.mts' -import type { ScanReport } from '../../../../src/command/scan/report-generation.mts' +import type { + EcoMap, + FileMap, + PackageMap, + ReportLeafNode, + ScanReport, + VersionMap, +} from '../../../../src/command/scan/report-generation.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. @@ -24,6 +32,15 @@ import type { ScanReport } from '../../../../src/command/scan/report-generation. // oxlint-disable-next-line no-control-regex -- control chars intended const ANSI_PATTERN = /\u001B\[[0-9;]*m/ +describe('isFullScanReport', () => { + it('distinguishes full reports from short reports', () => { + expect( + isFullScanReport(buildReport({ alerts: new Map(), healthy: true })), + ).toBe(true) + expect(isFullScanReport({ healthy: true })).toBe(false) + }) +}) + function buildReport(config: { alerts: ScanReport['alerts'] healthy: boolean @@ -39,30 +56,33 @@ function buildReport(config: { } 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'] + const leaf: ReportLeafNode = { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: 'https://socket.dev/npm/package/acme-widget', + } + const fileMap: FileMap = new Map([['envVars at 1:2', leaf]]) + const versionMap: VersionMap = new Map([['package.json', fileMap]]) + const packageMap: PackageMap = new Map([['1.0.0', versionMap]]) + const ecosystemMap: EcoMap = new Map([['acme-widget', packageMap]]) + + return new Map([['npm', ecosystemMap]]) } describe('toPlainTextReport', () => { + it('labels the package version as a version', () => { + const report = buildReport({ alerts: buildAlerts(), healthy: false }) + const text = toPlainTextReport(report) + + expect(flattenReportAlerts(report)[0]).toMatchObject({ + packageVersion: '1.0.0', + }) + expect(text).toContain('VERSION') + expect(text).toContain('1.0.0') + expect(text).not.toContain('INTRODUCED BY') + }) + it('emits no ANSI escape codes', () => { const text = toPlainTextReport( buildReport({ alerts: buildAlerts(), healthy: false }), @@ -204,6 +224,29 @@ describe('toPlainTextReport', () => { expect(overlong).toEqual([]) }) + it('renders a large alert report without argument-count failures', () => { + const alertRow: ReportLeafNode = { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: '', + } + 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, healthy: false })) + + expect(text).toContain('200000 alerts with a policy') + expect(text).toContain('package-000000') + expect(text).toContain('package-199999') + }) + it('says so plainly when there are no alerts', () => { const text = toPlainTextReport( buildReport({ alerts: new Map(), healthy: true }), @@ -211,6 +254,123 @@ describe('toPlainTextReport', () => { expect(text).toContain('No alerts') expect(text).not.toContain('ALERT TYPE') + expect(text).not.toContain('VERSION') + }) + + it('retains package versions for version and file folds', () => { + const versionLeaf: ReportLeafNode = { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: '', + } + const versionFoldAlerts: ScanReport['alerts'] = new Map([ + [ + 'npm', + new Map([['demo-transitive', new Map([['2.3.4', versionLeaf]])]]), + ], + ]) + const fileFoldAlerts: ScanReport['alerts'] = new Map([ + [ + 'npm', + new Map([ + [ + 'demo-transitive', + new Map([ + [ + '2.3.4', + new Map([ + ['package.json', versionLeaf], + [ + 'src/index.js', + { ...versionLeaf, manifest: ['package-lock.json'] }, + ], + ]), + ], + ]), + ], + ]), + ], + ]) + const versionFoldReport = { + ...buildReport({ alerts: versionFoldAlerts, healthy: false }), + options: { fold: 'version' as const, reportLevel: 'error' as const }, + } + const fileFoldReport = { + ...buildReport({ alerts: fileFoldAlerts, healthy: false }), + options: { fold: 'file' as const, reportLevel: 'error' as const }, + } + + 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') + }) + + it('keeps package-fold versions unknown and distinguishes package versions', () => { + const leaf: ReportLeafNode = { + manifest: ['package.json'], + policy: 'error', + type: 'envVars', + url: '', + } + 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', + new Map([ + ['package.json', new Map([['envVars at 1:2', leaf]])], + ]), + ], + [ + '2.3.5', + new Map([ + ['package.json', new Map([['envVars at 3:4', leaf]])], + ]), + ], + ]), + ], + ]), + ], + ]) + const packageFoldReport = { + ...buildReport({ alerts: packageFoldAlerts, healthy: false }), + options: { fold: 'pkg' as const, reportLevel: 'error' as const }, + } + const twoVersionReport = { + ...buildReport({ alerts: twoVersionAlerts, healthy: false }), + options: { fold: 'none' as const, reportLevel: 'error' as const }, + } + + expect(flattenReportAlerts(packageFoldReport)[0]?.packageVersion).toBe( + '', + ) + expect(toPlainTextReport(packageFoldReport)).toContain('') + expect( + flattenReportAlerts(twoVersionReport).map(row => row.packageVersion), + ).toEqual(['2.3.4', '2.3.5']) }) }) @@ -223,9 +383,9 @@ describe('flattenReportAlerts', () => { expect(rows).toEqual([ { alertType: 'envVars', - introducedBy: '1.0.0', manifest: 'package.json', packageName: 'acme-widget', + packageVersion: '1.0.0', policy: 'error', url: 'https://socket.dev/npm/package/acme-widget', },