Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 153 additions & 14 deletions packages/cli/src/commands/scan/output-scan-report.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] || '<unknown>',
manifest: joinAnd(manifest),
packageName: keys[1] || '<unknown>',
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[]
Expand Down Expand Up @@ -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),
)
}
}

Expand Down Expand Up @@ -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] || '<unknown>',
'Introduced by': keys[2] || '<unknown>',
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
Expand Down Expand Up @@ -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')
}
Loading
Loading