From bc924070e3295564bd3b80c2f56759507ab6c311 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 11:55:55 -0400 Subject: [PATCH 01/10] feat: summarize scan output to favor web report, removing list and table views --- .cursorrules | 7 -- README.md | 1 - bin/main.js | 4 +- e2e/scan/eol.test.ts | 209 ++++------------------------------ package-lock.json | 61 +++++++++- package.json | 2 +- src/api/types/hd-cli.types.ts | 4 +- src/commands/scan/eol.ts | 134 +++++++--------------- src/service/eol/eol.svc.ts | 28 ----- src/ui/date.ui.ts | 14 --- src/ui/eol.ui.ts | 170 --------------------------- src/ui/shared.ui.ts | 4 - test/service/eol.svc.test.ts | 143 ----------------------- test/ui/date.ui.test.ts | 86 -------------- test/ui/eol.ui.test.ts | 188 ------------------------------ 15 files changed, 124 insertions(+), 931 deletions(-) delete mode 100644 src/ui/date.ui.ts delete mode 100644 src/ui/eol.ui.ts delete mode 100644 test/service/eol.svc.test.ts delete mode 100644 test/ui/date.ui.test.ts delete mode 100644 test/ui/eol.ui.test.ts diff --git a/.cursorrules b/.cursorrules index ac548ebc..066a700b 100644 --- a/.cursorrules +++ b/.cursorrules @@ -113,13 +113,6 @@ rule "service-no-inquirer" { message = "UI interactions should be handled in the UI layer, not services" } -rule "service-no-cli-table" { - # Prevent cli-table3 usage in service layer - matches = ["src/service/**/*.svc.ts"] - not_contains = ["cli-table3"] - message = "Table formatting should be handled in the UI layer, not services" -} - rule "service-no-apollo" { # Prevent Apollo Client usage in service layer matches = ["src/service/**/*.svc.ts"] diff --git a/README.md b/README.md index c86e3d11..28ce4b6c 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,6 @@ FLAGS -f, --file= The file path of an existing cyclonedx sbom to scan for EOL -p, --purls= The file path of a list of purls to scan for EOL -s, --save Save the generated report as eol.report.json in the scanned directory - -t, --table Display the results in a table GLOBAL FLAGS --json Format output as json. diff --git a/bin/main.js b/bin/main.js index 7a92fedf..41f2580b 100644 --- a/bin/main.js +++ b/bin/main.js @@ -7,9 +7,9 @@ async function main(isProduction = false) { strict: false, // Don't validate flags }); - // If no arguments at all, default to scan:eol -t + // If no arguments at all, default to scan:eol if (positionals.length === 0) { - process.argv.splice(2, 0, 'scan:eol', '-t'); + process.argv.splice(2, 0, 'scan:eol'); } // If only flags are provided, set scan:eol as the command for those flags else if (positionals.length === 1 && positionals[0].startsWith('-')) { diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index bd995ee6..79ca83e6 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -22,35 +22,12 @@ describe('environment', () => { }); describe('default arguments', () => { - it('defaults to scan:eol -t when no arguments are provided', async () => { + it('defaults to scan:eol when no arguments are provided', async () => { // Run the CLI directly with no arguments const { stdout } = await execAsync('node bin/run.js'); - // Match table header - match(stdout, /┌.*┬.*┬.*┬.*┬.*┐/, 'Should show table top border'); - if (config.showVulnCount) { - match(stdout, /│ NAME\s*│ VERSION\s*│ EOL\s*│ DAYS EOL\s*│ TYPE\s*│ # OF VULNS*|/, 'Should show table headers'); - } else { - match(stdout, /│ NAME\s*│ VERSION\s*│ EOL\s*│ DAYS EOL\s*│ TYPE\s*|/, 'Should show table headers'); - } - match(stdout, /├.*┼.*┼.*┼.*┼.*┤/, 'Should show table header separator'); - - // Match table content - match( - stdout, - /│ bootstrap\s*│ 3\.1\.1\s*│ 2019-07-24\s*│ \d+\s*│ npm\s*│/, - 'Should show bootstrap package in table', - ); - - // Match table footer - match(stdout, /└.*┴.*┴.*┴.*┴.*┘/, 'Should show table bottom border'); - }); - - it('runs scan:eol -a -t when -a -t is passed in', async () => { - const { stdout, stderr } = await execAsync('node bin/run.js -a -t'); - - // Verify command executed successfully - match(stdout, /components scanned/, 'Should show components scanned message'); + // Match EOL count + match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); }); it('runs scan:eol --json when --json is passed in', async () => { @@ -58,7 +35,7 @@ describe('default arguments', () => { const { stdout } = await execAsync('node bin/run.js --json'); // Verify JSON output - doesNotMatch(stdout, /Here are the results of the scan:/, 'Should not show results header'); + doesNotMatch(stdout, /Scan results:/, 'Should not show results header'); doesNotThrow(() => JSON.parse(stdout), 'Output should be valid JSON'); }); @@ -80,17 +57,15 @@ describe('default arguments', () => { match(stdout, /COMMANDS/, 'Should show commands section'); }); }); + describe('scan:eol e2e', () => { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesDir = path.resolve(__dirname, '../fixtures'); const simplePurls = path.resolve(__dirname, '../fixtures/npm/simple.purls.json'); const simpleSbom = path.join(fixturesDir, 'npm/eol.sbom.json'); - const transitiveDependenciesSbom = path.join(fixturesDir, 'npm/transitive-dependencies.sbom.json'); const reportPath = path.resolve(fixturesDir, 'eol.report.json'); const upToDatePurls = path.resolve(__dirname, '../fixtures/npm/up-to-date.purls.json'); - const extraLargePurlsPath = path.resolve(__dirname, '../fixtures/npm/extra-large.purls.json'); const emptyPurlsPath = path.resolve(__dirname, '../fixtures/npm/empty.purls.json'); - const angular17Purls = path.resolve(__dirname, '../fixtures/npm/angular-17.purls.json'); async function run(cmd: string) { // Ensure fixtures directory exists and is clean @@ -114,13 +89,12 @@ describe('scan:eol e2e', () => { const cmd = `scan:eol --file ${simpleSbom}`; const { stdout } = await run(cmd); - // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); - match(stdout, /pkg:npm\/bootstrap@3\.1\.1/, 'Should detect bootstrap package'); - match(stdout, /EOL Date: 2019-07-24/, 'Should show correct EOL date for bootstrap'); + // Match EOL count + match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); }); it('generates purls from SBOM for direct and transitive dependencies', async () => { + const transitiveDependenciesSbom = path.join(fixturesDir, 'npm/transitive-dependencies.sbom.json'); const cmd = `report:purls --file ${transitiveDependenciesSbom} --json`; const { stdout } = await run(cmd); @@ -159,11 +133,12 @@ describe('scan:eol e2e', () => { }); it.skip('scans extra-large.purls.json for EOL components', async () => { + const extraLargePurlsPath = path.resolve(__dirname, '../fixtures/npm/extra-large.purls.json'); const cmd = `scan:eol --purls ${extraLargePurlsPath}`; const { stdout } = await run(cmd); // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); + match(stdout, /Scan results:/, 'Should show results header'); // Match specific EOL packages match(stdout, /pkg:npm\/%40angular\/core@12\.2\.2/, 'Should detect Angular core package'); @@ -186,85 +161,17 @@ describe('scan:eol e2e', () => { const { stdout } = await run(cmd); // Match command output patterns - doesNotMatch(stdout, /Here are the results of the scan:/, 'Should not show results header'); + doesNotMatch(stdout, /Scan results:/, 'Should not show results header'); doesNotThrow(() => JSON.parse(stdout)); }); - it('displays results in table format when using the -t flag', async () => { - const cmd = `scan:eol --purls=${simplePurls} -t`; - const { stdout } = await run(cmd); - - // Match table header - match(stdout, /┌.*┬.*┬.*┬.*┬.*┐/, 'Should show table top border'); - if (config.showVulnCount) { - match(stdout, /│ NAME\s*│ VERSION\s*│ EOL\s*│ DAYS EOL\s*│ TYPE\s*│ # OF VULNS*|/, 'Should show table headers'); - } else { - match(stdout, /│ NAME\s*│ VERSION\s*│ EOL\s*│ DAYS EOL\s*│ TYPE\s*|/, 'Should show table headers'); - } - match(stdout, /├.*┼.*┼.*┼.*┼.*┤/, 'Should show table header separator'); - - // Match table content - match( - stdout, - /│ bootstrap\s*│ 3\.1\.1\s*│ 2019-07-24\s*│ \d+\s*│ npm\s*│/, - 'Should show bootstrap package in table', - ); - - // Match table footer - match(stdout, /└.*┴.*┴.*┴.*┴.*┘/, 'Should show table bottom border'); - }); - - describe('--all flag', () => { - it('excludes OK packages by default', async () => { - const cmd = `scan:eol --purls=${simplePurls}`; - const { stdout } = await run(cmd); - - // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); - doesNotMatch(stdout, /pkg:npm\/vue@3\.5\.13/, 'Should not show vue package'); - }); - - it('shows all packages when --all flag is used', async () => { - const cmd = `scan:eol --purls=${simplePurls} --all`; - const { stdout } = await run(cmd); - - // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); - match(stdout, /pkg:npm\/bootstrap@3\.1\.1/, 'Should detect bootstrap package'); - match(stdout, /pkg:npm\/vue@3\.5\.13/, 'Should show vue package'); - }); - - it('shows "No EOL" message by default if no components are found', async () => { - const cmd = `scan:eol --purls ${upToDatePurls}`; - const { stdout } = await run(cmd); - - // Match command output patterns - doesNotMatch(stdout, /Here are the results of the scan:/, 'Should not show results header'); - match(stdout, /No End-of-Life or Supported components found in scan/, 'Should show "No EOL" message'); - }); - - it('shows "No components found" message if no components are found with --all flag', async () => { - const cmd = `scan:eol --purls ${emptyPurlsPath} --all`; - const { stdout } = await run(cmd); - - // Match command output patterns - doesNotMatch(stdout, /Here are the results of the scan:/, 'Should not show results header'); - match(stdout, /No components found in scan/, 'Should show "No components found" message'); - }); - }); - it('correctly identifies Angular 17 as having a EOL date', async () => { + const angular17Purls = path.resolve(__dirname, '../fixtures/npm/angular-17.purls.json'); const cmd = `scan:eol --purls=${angular17Purls}`; const { stdout } = await run(cmd); - // Check for Angular package presence - match(stdout, /pkg:npm\/%40angular\/core@17\.3\.12/, 'Should detect Angular core package'); - - // Check for EOL date format - match(stdout, /EOL Date: \d{4}-\d{2}-\d{2}/, 'Should show EOL date'); - - // Check for the arrow format - match(stdout, /⮑ {2}EOL Date: \d{4}-\d{2}-\d{2}/, 'Should show EOL date with arrow'); + // Match EOL count + match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); }); describe('web report URL', () => { @@ -273,23 +180,7 @@ describe('scan:eol e2e', () => { const { stdout } = await run(cmd); // Match the key text and scan ID pattern - match(stdout, /View your free EOL report at.*[a-zA-Z0-9-]+/, 'Should show web report text and scan ID'); - }); - - it('displays web report URL in table format when using -t flag', async () => { - const cmd = `scan:eol --purls=${simplePurls} -t`; - const { stdout } = await run(cmd); - - // Match the key text and scan ID pattern - match(stdout, /View your free EOL report at.*[a-zA-Z0-9-]+/, 'Should show web report text and scan ID'); - }); - - it('does not display web report URL when using --json flag', async () => { - const cmd = `scan:eol --purls=${simplePurls} --json`; - const { stdout } = await run(cmd); - - // Verify URL text is not in output - doesNotMatch(stdout, /View your free EOL report/, 'Should not show web report text in JSON output'); + match(stdout, /View your full EOL report at.*[a-zA-Z0-9-]+/, 'Should show web report text and scan ID'); }); }); }); @@ -326,11 +217,8 @@ describe('scan:eol e2e directory', () => { const cmd = `scan:eol --dir ${simpleDir}`; const { stdout } = await run(cmd); - // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); - match(stdout, /pkg:npm\/bootstrap@3\.1\.1/, 'Should detect bootstrap package'); - match(stdout, /End of Life \(EOL\)/, 'Should show EOL status'); - match(stdout, /EOL Date:/, 'Should show EOL date information'); + // Match EOL count + match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); }); it('displays web report URL when scanning directory', async () => { @@ -338,7 +226,7 @@ describe('scan:eol e2e directory', () => { const { stdout } = await run(cmd); // Match the key text and scan ID pattern - match(stdout, /View your free EOL report at.*[a-zA-Z0-9-]+/, 'Should show web report text and scan ID'); + match(stdout, /View your full EOL report at.*[a-zA-Z0-9-]+/, 'Should show web report text and scan ID'); }); it('saves report when --save flag is used', async () => { @@ -370,10 +258,7 @@ describe('scan:eol e2e directory', () => { const cmd = `scan:eol --file ${simpleDir}/sbom.json`; const { stdout } = await run(cmd); - // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); - match(stdout, /pkg:npm\/bootstrap@3\.1\.1/, 'Should detect bootstrap package'); - match(stdout, /EOL Date: 2019-07-24/, 'Should show correct EOL date for bootstrap'); + match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); }); it('outputs JSON when using the --json flag', async () => { @@ -381,61 +266,7 @@ describe('scan:eol e2e directory', () => { const { stdout } = await run(cmd); // Match command output patterns - doesNotMatch(stdout, /Here are the results of the scan:/, 'Should not show results header'); + doesNotMatch(stdout, /Scan results:/, 'Should not show results header'); doesNotThrow(() => JSON.parse(stdout)); }); - - it('displays results in table format when using the -t flag', async () => { - const cmd = `scan:eol --dir ${simpleDir} -t`; - const { stdout } = await run(cmd); - - // Match table header - match(stdout, /┌.*┬.*┬.*┬.*┬.*┐/, 'Should show table top border'); - match( - stdout, - /│ NAME\s*│ VERSION\s*│ EOL\s*│ DAYS EOL\s*│ TYPE\s*│/, // TODO: add vulns to monorepo api - 'Should show table headers', - ); - match(stdout, /├.*┼.*┼.*┼.*┼.*┤/, 'Should show table header separator'); - - // Match table content - match( - stdout, - /│ bootstrap\s*│ 3\.1\.1\s*│ 2019-07-24\s*│ \d+\s*│ npm\s*│/, - 'Should show bootstrap package in table', - ); - - // Match table footer - match(stdout, /└.*┴.*┴.*┴.*┴.*┘/, 'Should show table bottom border'); - }); - - describe('--all flag', () => { - it('excludes OK packages by default', async () => { - const cmd = `scan:eol --dir ${simpleDir}`; - const { stdout } = await run(cmd); - - // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); - doesNotMatch(stdout, /pkg:npm\/vue@3\.5\.13/, 'Should not show vue package'); - }); - - it('shows all packages when --all flag is used', async () => { - const cmd = `scan:eol --dir ${simpleDir} --all`; - const { stdout } = await run(cmd); - - // Match command output patterns - match(stdout, /Here are the results of the scan:/, 'Should show results header'); - match(stdout, /pkg:npm\/bootstrap@3\.1\.1/, 'Should detect bootstrap package'); - match(stdout, /pkg:npm\/vue@3\.5\.13/, 'Should show vue package'); - }); - - it('shows "No EOL" message by default if no components are found', async () => { - const cmd = `scan:eol --dir ${upToDateDir}`; - const { stdout } = await run(cmd); - - // Match command output patterns - doesNotMatch(stdout, /Here are the results of the scan:/, 'Should not show results header'); - match(stdout, /No End-of-Life or Supported components found in scan/, 'Should show "No EOL" message'); - }); - }); }); diff --git a/package-lock.json b/package-lock.json index 18129beb..3bbf13a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ "@oclif/core": "^4.3.1", "@oclif/plugin-help": "^6.2.28", "@oclif/plugin-update": "^4.6.42", - "@oclif/table": "^0.4.8", "graphql": "^16.11.0", "packageurl-js": "^2.0.1", + "terminal-link": "^4.0.0", "update-notifier": "^7.3.1" }, "bin": { @@ -12630,6 +12630,34 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -12789,6 +12817,37 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-jsonc": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tiny-jsonc/-/tiny-jsonc-1.0.2.tgz", diff --git a/package.json b/package.json index 0fb6a260..0c2fc5fe 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,9 @@ "@oclif/core": "^4.3.1", "@oclif/plugin-help": "^6.2.28", "@oclif/plugin-update": "^4.6.42", - "@oclif/table": "^0.4.8", "graphql": "^16.11.0", "packageurl-js": "^2.0.1", + "terminal-link": "^4.0.0", "update-notifier": "^7.3.1" }, "devDependencies": { diff --git a/src/api/types/hd-cli.types.ts b/src/api/types/hd-cli.types.ts index d6346ef7..9ebf19d8 100644 --- a/src/api/types/hd-cli.types.ts +++ b/src/api/types/hd-cli.types.ts @@ -19,14 +19,12 @@ export const DEFAULT_SCAN_INPUT_OPTIONS: ScanInputOptions = { totalPages: 1, } satisfies ScanInputOptions; -export type ScanResultComponentsMap = Map; - export type ScanInput = { components: string[]; options: ScanInputOptions; }; export interface ScanResult { - components: ScanResultComponentsMap; + components: Map; createdOn?: string; diagnostics?: Record; message: string; diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index de5b5785..dc318113 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -8,9 +8,9 @@ import { config } from '../../config/constants.ts'; import type { Sbom } from '../../service/eol/cdx.svc.ts'; import { getErrorMessage, isErrnoException } from '../../service/error.svc.ts'; import { extractPurls, parsePurlsFile } from '../../service/purls.svc.ts'; -import { createStatusDisplay, createTableForStatus, groupComponentsByStatus } from '../../ui/eol.ui.ts'; import { INDICATORS, SCAN_ID_KEY, STATUS_COLORS } from '../../ui/shared.ui.ts'; import ScanSbom from './sbom.ts'; +import terminalLink from 'terminal-link'; export default class ScanEol extends Command { static override description = 'Scan a given sbom for EOL data'; @@ -39,42 +39,30 @@ export default class ScanEol extends Command { default: false, description: 'Save the generated report as eol.report.json in the scanned directory', }), - all: Flags.boolean({ - char: 'a', - description: 'Show all components (default is EOL and SUPPORTED only)', - default: false, - }), - table: Flags.boolean({ - char: 't', - description: 'Display the results in a table', - default: false, - }), }; public async run(): Promise<{ components: InsightsEolScanComponent[]; createdOn: string }> { const { flags } = await this.parse(ScanEol); const scan = await this.getScan(flags, this.config); + const components = Array.from(scan.components.values()); - ux.action.stop('\nScan completed'); - - const components = this.getFilteredComponents(scan, flags.all); + ux.action.stop(); if (flags.save) { await this.saveReport(components, scan.createdOn); } if (!this.jsonEnabled()) { - if (flags.table) { - this.log(`${scan.components.size} components scanned`); - this.displayResultsInTable(scan, flags.all); - } else { - this.displayResults(scan, flags.all); - } + this.displayResults(components); if (scan.scanId) { this.printWebReportUrl(scan.scanId); } + + this.log('* Use --json to output the report payload'); + this.log('* Use --save to save the report to eol.report.json'); + this.log('* Use --help for more commands or options'); } return { components, createdOn: scan.createdOn ?? '' }; @@ -101,11 +89,11 @@ export default class ScanEol extends Command { } private printWebReportUrl(scanId: string): void { - this.logLine(); + this.log(ux.colorize('bold', '-'.repeat(40))); const id = scanId.split(SCAN_ID_KEY)[1]; const reportCardUrl = config.eolReportUrl; - const url = ux.colorize('blue', `${reportCardUrl}/${id}`); - this.log(`🌐 View your free EOL report at: ${ux.colorize('blue', url)}`); + const url = ux.colorize('blue', terminalLink(new URL(reportCardUrl).hostname, `${reportCardUrl}/${id}`)); + this.log(`🌐 View your full EOL report at: ${url}\n`); } private async scanSbom(sbom: Sbom): Promise { @@ -123,19 +111,9 @@ export default class ScanEol extends Command { this.error(`Failed to submit scan to NES from sbom. ${getErrorMessage(error)}`); } - if (scan.components.size === 0) { - this.warn('No components found in scan'); - } - return scan; } - private getFilteredComponents(scan: ScanResult, all: boolean) { - return Array.from(scan.components.values()).filter( - (component) => all || ['EOL', 'SUPPORTED'].includes(component.info.status), - ); - } - private async saveReport(components: InsightsEolScanComponent[], createdOn?: string): Promise { const { flags } = await this.parse(ScanEol); const reportPath = path.join(flags.dir || process.cwd(), 'eol.report.json'); @@ -157,74 +135,42 @@ export default class ScanEol extends Command { } } - private displayResults(scan: ScanResult, all: boolean) { - const { UNKNOWN, OK, SUPPORTED, EOL } = createStatusDisplay(scan.components, all); + private displayResults(components: InsightsEolScanComponent[]) { + const { UNKNOWN, OK, SUPPORTED, EOL } = countComponentsByStatus(components); - if (!UNKNOWN.length && !OK.length && !SUPPORTED.length && !EOL.length) { - this.displayNoComponentsMessage(all); + if (!UNKNOWN && !OK && !SUPPORTED && !EOL) { + this.log(ux.colorize('yellow', 'No components found in scan.')); return; } - this.log(ux.colorize('bold', 'Here are the results of the scan:')); - this.logLine(); - - // Display sections in order of increasing severity - for (const components of [UNKNOWN, OK, SUPPORTED, EOL]) { - this.displayStatusSection(components); - } - - this.logLegend(); - } - - private displayResultsInTable(scan: ScanResult, all: boolean) { - const grouped = groupComponentsByStatus(scan.components); - const statuses: ComponentStatus[] = ['SUPPORTED', 'EOL']; - - if (all) { - statuses.unshift('UNKNOWN', 'OK'); - } - - for (const status of statuses) { - const components = grouped[status]; - if (components.length > 0) { - const table = createTableForStatus(grouped, status); - this.displayTable(table, components.length, status); - } - } - this.logLegend(); - } - - private displayTable(table: string, count: number, status: ComponentStatus): void { - this.log(ux.colorize(STATUS_COLORS[status], `${INDICATORS[status]} ${count} ${status} Component(s):`)); - this.log(ux.colorize(STATUS_COLORS[status], table)); - } - - private displayNoComponentsMessage(all: boolean): void { - if (!all) { - this.log(ux.colorize('yellow', 'No End-of-Life or Supported components found in scan.')); - this.log(ux.colorize('yellow', 'Use --all flag to view all components.')); - } else { - this.log(ux.colorize('yellow', 'No components found in scan.')); - } + this.log(ux.colorize('bold', 'Scan results:')); + this.log(ux.colorize('bold', '-'.repeat(40))); + this.log(ux.colorize('bold', `${components.length.toLocaleString()} total packages scanned`)); + this.log(ux.colorize(STATUS_COLORS.EOL, `${INDICATORS.EOL} ${EOL.toLocaleString().padEnd(5)} End-of-Life (EOL)`)); + this.log( + ux.colorize( + STATUS_COLORS.SUPPORTED, + `${INDICATORS.SUPPORTED}${SUPPORTED.toLocaleString().padEnd(5)} Scheduled End-of-Life (EOL)`, + ), + ); + this.log(ux.colorize(STATUS_COLORS.OK, `${INDICATORS.OK} ${OK.toLocaleString().padEnd(5)} OK`)); + this.log( + ux.colorize(STATUS_COLORS.UNKNOWN, `${INDICATORS.UNKNOWN} ${UNKNOWN.toLocaleString().padEnd(5)} Unknown Status`), + ); } +} - private logLine(): void { - this.log(ux.colorize('bold', '-'.repeat(50))); - } +export function countComponentsByStatus(components: InsightsEolScanComponent[]): Record { + const grouped: Record = { + UNKNOWN: 0, + OK: 0, + SUPPORTED: 0, + EOL: 0, + }; - private displayStatusSection(components: string[]): void { - if (components.length > 0) { - this.log(components.join('\n')); - this.logLine(); - } + for (const component of components) { + grouped[component.info.status]++; } - private logLegend(): void { - this.log(ux.colorize(STATUS_COLORS.UNKNOWN, `${INDICATORS.UNKNOWN} = No Known Issues`)); - this.log(ux.colorize(STATUS_COLORS.OK, `${INDICATORS.OK} = OK`)); - this.log( - ux.colorize(STATUS_COLORS.SUPPORTED, `${INDICATORS.SUPPORTED}= Supported: End-of-Life (EOL) is scheduled`), - ); - this.log(ux.colorize(STATUS_COLORS.EOL, `${INDICATORS.EOL} = End of Life (EOL)`)); - } + return grouped; } diff --git a/src/service/eol/eol.svc.ts b/src/service/eol/eol.svc.ts index b14e1507..65398af6 100644 --- a/src/service/eol/eol.svc.ts +++ b/src/service/eol/eol.svc.ts @@ -1,4 +1,3 @@ -import type { PackageURL } from 'packageurl-js'; import { debugLogger } from '../../service/log.svc.ts'; import { type Sbom, createBomFromDir } from './cdx.svc.ts'; @@ -38,30 +37,3 @@ export function validateIsCycloneDxSbom(sbom: unknown): asserts sbom is Sbom { throw new Error('Invalid SBOM: missing or invalid components array'); } } - -const purlPackageNameRules = { - npm: (p: PackageURL) => (p.namespace ? `${p.namespace}/${p.name}` : p.name), - maven: (p: PackageURL) => (p.namespace ? `${p.namespace}:${p.name}` : p.name), - pypi: (p: PackageURL) => p.name.toLowerCase(), - nuget: (p: PackageURL) => p.name, - gem: (p: PackageURL) => p.name, - composer: (p: PackageURL) => (p.namespace ? `${p.namespace}/${p.name}` : p.name), - golang: (p: PackageURL) => (p.namespace ? `${p.namespace}/${p.name}` : p.name), - cargo: (p: PackageURL) => p.name, - conan: (p: PackageURL) => (p.namespace ? `${p.namespace}/${p.name}` : p.name), - github: (p: PackageURL) => (p.namespace ? `${p.namespace}/${p.name}` : p.name), - bitbucket: (p: PackageURL) => (p.namespace ? `${p.namespace}/${p.name}` : p.name), - docker: (p: PackageURL) => (p.namespace ? `${p.namespace}/${p.name}` : p.name), -} as const; - -function isKnownEcosystemType(type: string): type is keyof typeof purlPackageNameRules { - return type in purlPackageNameRules; -} - -export function resolvePurlPackageName(purl: PackageURL): string { - if (!isKnownEcosystemType(purl.type)) { - debugLogger(`Unsupported package type: ${purl.type}, falling back to name only`); - return purl.name; - } - return purlPackageNameRules[purl.type](purl); -} diff --git a/src/ui/date.ui.ts b/src/ui/date.ui.ts deleted file mode 100644 index 5a92476a..00000000 --- a/src/ui/date.ui.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function parseMomentToSimpleDate(momentDate: string | Date | number | null): string { - // Only return empty string for null - if (momentDate === null) return ''; - - try { - const dateObj = new Date(momentDate); - if (Number.isNaN(dateObj.getTime())) { - throw new Error('Invalid date'); - } - return dateObj.toISOString().split('T')[0]; - } catch { - throw new Error('Invalid date'); - } -} diff --git a/src/ui/eol.ui.ts b/src/ui/eol.ui.ts deleted file mode 100644 index b4b7a10c..00000000 --- a/src/ui/eol.ui.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { ux } from '@oclif/core'; -import { makeTable } from '@oclif/table'; -import { PackageURL } from 'packageurl-js'; -import type { ScanResultComponentsMap } from '../api/types/hd-cli.types.ts'; -import type { - ComponentStatus, - InsightsEolScanComponent, - InsightsEolScanComponentInfo, -} from '../api/types/nes.types.ts'; -import { config } from '../config/constants.ts'; -import { resolvePurlPackageName } from '../service/eol/eol.svc.ts'; -import { parseMomentToSimpleDate } from './date.ui.ts'; -import { INDICATORS, MAX_PURL_LENGTH, MAX_TABLE_COLUMN_WIDTH, STATUS_COLORS } from './shared.ui.ts'; - -export function truncateString(purl: string, maxLength: number): string { - const ellipses = '...'; - return purl.length > maxLength ? `${purl.slice(0, maxLength - ellipses.length)}${ellipses}` : purl; -} - -export function colorizeStatus(status: ComponentStatus): string { - return ux.colorize(STATUS_COLORS[status], status); -} - -function formatSimpleComponent(purl: string, status: ComponentStatus): string { - const color = STATUS_COLORS[status]; - return ` ${INDICATORS[status]} ${ux.colorize(color, truncateString(purl, MAX_PURL_LENGTH))}`; -} - -function getDaysEolString(daysEol: number | null): string { - if (daysEol === null) { - return ''; - } - if (daysEol <= 0) { - return `${Math.abs(daysEol) + 1} days from now`; - } - if (daysEol > 0) { - return 'today'; - } - return `${daysEol} days ago`; -} - -function formatDetailedComponent(purl: string, info: InsightsEolScanComponentInfo): string { - const { status, eolAt, daysEol, vulnCount } = info; - const simpleComponent = formatSimpleComponent(purl, status); - const eolAtString = parseMomentToSimpleDate(eolAt); - const daysEolString = getDaysEolString(daysEol); - - const eolString = [`${simpleComponent}`, ` ⮑ EOL Date: ${eolAtString} (${daysEolString})`]; - - if (config.showVulnCount) { - eolString.push(` ⮑ # of Vulns: ${vulnCount ?? ''}`); - } - - const output = eolString.filter(Boolean).join('\n'); - - return output; -} - -export function createStatusDisplay( - components: ScanResultComponentsMap, - all: boolean, -): Record { - const statusOutput: Record = { - UNKNOWN: [], - OK: [], - SUPPORTED: [], - EOL: [], - }; - - // Single loop to separate and format components - for (const [purl, component] of components.entries()) { - const { status } = component.info; - - if (all) { - if (status === 'UNKNOWN' || status === 'OK') { - statusOutput[status].push(formatSimpleComponent(purl, status)); - } - } - if (status === 'SUPPORTED' || status === 'EOL') { - statusOutput[status].push(formatDetailedComponent(purl, component.info)); - } - } - - return statusOutput; -} - -export function createTableForStatus( - grouped: Record, - status: ComponentStatus, -) { - const data = grouped[status].map((component) => convertComponentToTableRow(component)); - - if (status === 'EOL' || status === 'SUPPORTED') { - if (config.showVulnCount) { - return makeTable({ - data, - columns: [ - { key: 'name', name: 'NAME', width: MAX_TABLE_COLUMN_WIDTH }, - { key: 'version', name: 'VERSION', width: 10 }, - { key: 'eol', name: 'EOL', width: 12 }, - { key: 'daysEol', name: 'DAYS EOL', width: 10 }, - { key: 'type', name: 'TYPE', width: 12 }, - { key: 'vulnCount', name: '# OF VULNS', width: 12 }, - ], - }); - } - return makeTable({ - data, - columns: [ - { key: 'name', name: 'NAME', width: MAX_TABLE_COLUMN_WIDTH }, - { key: 'version', name: 'VERSION', width: 10 }, - { key: 'eol', name: 'EOL', width: 12 }, - { key: 'daysEol', name: 'DAYS EOL', width: 10 }, - { key: 'type', name: 'TYPE', width: 12 }, - ], - }); - } - - if (config.showVulnCount) { - return makeTable({ - data, - columns: [ - { key: 'name', name: 'NAME', width: MAX_TABLE_COLUMN_WIDTH }, - { key: 'version', name: 'VERSION', width: 10 }, - { key: 'type', name: 'TYPE', width: 12 }, - { key: 'vulnCount', name: '# OF VULNS', width: 12 }, - ], - }); - } - - return makeTable({ - data, - columns: [ - { key: 'name', name: 'NAME', width: MAX_TABLE_COLUMN_WIDTH }, - { key: 'version', name: 'VERSION', width: 10 }, - { key: 'type', name: 'TYPE', width: 12 }, - ], - }); -} - -export function convertComponentToTableRow(component: InsightsEolScanComponent) { - const purlParts = PackageURL.fromString(component.purl); - const { eolAt, daysEol, vulnCount } = component.info; - - return { - name: resolvePurlPackageName(purlParts), - version: purlParts.version ?? '', - eol: parseMomentToSimpleDate(eolAt), - daysEol: daysEol, - type: purlParts.type, - vulnCount: vulnCount, - }; -} - -export function groupComponentsByStatus( - components: ScanResultComponentsMap, -): Record { - const grouped: Record = { - UNKNOWN: [], - OK: [], - SUPPORTED: [], - EOL: [], - }; - - for (const component of components.values()) { - grouped[component.info.status].push(component); - } - - return grouped; -} diff --git a/src/ui/shared.ui.ts b/src/ui/shared.ui.ts index a1d8cddd..0b1abc46 100644 --- a/src/ui/shared.ui.ts +++ b/src/ui/shared.ui.ts @@ -15,8 +15,4 @@ export const INDICATORS: Record = { SUPPORTED: ux.colorize(STATUS_COLORS.SUPPORTED, '⚡'), }; -export const MAX_PURL_LENGTH = 60; - -export const MAX_TABLE_COLUMN_WIDTH = 30; - export const SCAN_ID_KEY = 'eol-scan-v1-'; diff --git a/test/service/eol.svc.test.ts b/test/service/eol.svc.test.ts deleted file mode 100644 index 1d0fb77f..00000000 --- a/test/service/eol.svc.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { PackageURL } from 'packageurl-js'; -import { resolvePurlPackageName } from '../../src/service/eol/eol.svc.ts'; - -describe('eol.svc', () => { - describe('resolvePurlPackageName', () => { - for (const { purl, expected, description } of [ - // npm tests - { - purl: 'pkg:npm/%40angular/core@17.3.0', - expected: '@angular/core', - description: 'npm with namespace', - }, - { - purl: 'pkg:npm/lodash@4.17.21', - expected: 'lodash', - description: 'npm without namespace', - }, - { - purl: 'pkg:npm/express@4.18.2', - expected: 'express', - description: 'simple npm package', - }, - // maven tests - { - purl: 'pkg:maven/org.springframework/spring-core@5.3.0', - expected: 'org.springframework:spring-core', - description: 'maven with group and artifact ids', - }, - { - purl: 'pkg:maven/org.junit/junit@4.13.2', - expected: 'org.junit:junit', - description: 'maven with different group and artifact ids', - }, - // pypi tests - { - purl: 'pkg:pypi/Django@4.2.0', - expected: 'django', - description: 'pypi package (converts to lowercase)', - }, - { - purl: 'pkg:pypi/requests@2.28.0', - expected: 'requests', - description: 'pypi package (already lowercase)', - }, - { - purl: 'pkg:pypi/numpy@1.24.3', - expected: 'numpy', - description: 'simple pypi package', - }, - // golang tests - { - purl: 'pkg:golang/github.com/gorilla/mux@v1.8.0', - expected: 'github.com/gorilla/mux', - description: 'golang with full path', - }, - { - purl: 'pkg:golang/go.uber.org/zap@v1.24.0', - expected: 'go.uber.org/zap', - description: 'golang with organization path', - }, - // docker tests - { - purl: 'pkg:docker/library/nginx@1.19', - expected: 'library/nginx', - description: 'docker with namespace', - }, - { - purl: 'pkg:docker/nginx@1.19', - expected: 'nginx', - description: 'docker without namespace', - }, - { - purl: 'pkg:docker/redis@7.0', - expected: 'redis', - description: 'simple docker image', - }, - // nuget tests - { - purl: 'pkg:nuget/Newtonsoft.Json@13.0.1', - expected: 'Newtonsoft.Json', - description: 'nuget package with dot', - }, - { - purl: 'pkg:nuget/Serilog@3.0.1', - expected: 'Serilog', - description: 'simple nuget package', - }, - // composer tests - { - purl: 'pkg:composer/laravel/framework@8.0.0', - expected: 'laravel/framework', - description: 'composer with vendor', - }, - { - purl: 'pkg:composer/monolog/monolog@3.3.1', - expected: 'monolog/monolog', - description: 'simple composer package', - }, - // gem tests - { - purl: 'pkg:gem/rails@7.0.4', - expected: 'rails', - description: 'simple ruby gem', - }, - { - purl: 'pkg:gem/nokogiri@1.14.3', - expected: 'nokogiri', - description: 'another simple ruby gem', - }, - // cargo tests - { - purl: 'pkg:cargo/serde@1.0.160', - expected: 'serde', - description: 'simple rust crate', - }, - { - purl: 'pkg:cargo/tokio@1.28.0', - expected: 'tokio', - description: 'another simple rust crate', - }, - // Unicode test - { - purl: 'pkg:npm/パッケージ@1.0.0', - expected: 'パッケージ', - description: 'package with non-ASCII characters', - }, - // unknown ecosystem test - { - purl: 'pkg:unknown/some-package@1.0.0', - expected: 'some-package', - description: 'unknown ecosystem falls back to name', - }, - ]) { - it(`resolves ${description}`, () => { - const packageUrl = PackageURL.fromString(purl); - const result = resolvePurlPackageName(packageUrl); - assert.equal(result, expected); - }); - } - }); -}); diff --git a/test/ui/date.ui.test.ts b/test/ui/date.ui.test.ts deleted file mode 100644 index 1285980e..00000000 --- a/test/ui/date.ui.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { parseMomentToSimpleDate } from '../../src/ui/date.ui.ts'; - -describe('date.ui', () => { - describe('parseMomentToSimpleDate', () => { - it('returns empty string for null input', () => { - assert.strictEqual(parseMomentToSimpleDate(null), ''); - }); - - it('converts moment string to YYYY-MM-DD format', () => { - // Arrange - const momentDate = '2024-03-20T12:00:00Z'; - - // Act - const result = parseMomentToSimpleDate(momentDate); - - // Assert - assert.strictEqual(result, '2024-03-20'); - }); - - it('converts number timestamp to YYYY-MM-DD format', () => { - // Arrange - const timestamp = new Date('2024-03-20').getTime(); - - // Act - const result = parseMomentToSimpleDate(timestamp); - - // Assert - assert.strictEqual(result, '2024-03-20'); - }); - - it('converts Date object to YYYY-MM-DD format', () => { - // Arrange - const date = new Date('2024-03-20'); - - // Act - const result = parseMomentToSimpleDate(date); - - // Assert - assert.strictEqual(result, '2024-03-20'); - }); - - it('throws error for empty string input', () => { - // Arrange - const input = ''; - - // Assert - assert.throws( - // Act - () => parseMomentToSimpleDate(input), - { - message: 'Invalid date', - }, - ); - }); - - it('throws error for non-date string input', () => { - // Arrange - const input = 'not-a-date'; - - // Assert - assert.throws( - // Act - () => parseMomentToSimpleDate(input), - { - message: 'Invalid date', - }, - ); - }); - - it('throws error for invalid date values', () => { - // Arrange - const input = '2024-13-45'; - - // Assert - assert.throws( - // Act - () => parseMomentToSimpleDate(input), - { - message: 'Invalid date', - }, - ); - }); - }); -}); diff --git a/test/ui/eol.ui.test.ts b/test/ui/eol.ui.test.ts deleted file mode 100644 index 93618190..00000000 --- a/test/ui/eol.ui.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { config } from '../../src/config/constants.ts'; -import { - convertComponentToTableRow, - createTableForStatus, - groupComponentsByStatus, - truncateString, -} from '../../src/ui/eol.ui.ts'; -import { createMockComponent, createMockScan } from '../utils/mocks/scan-result-component.mock.ts'; - -describe('EOL UI', () => { - describe('truncateString', () => { - it('returns original PURL if length is sixty characters or less', () => { - // Arrange - const purl = 'pkg:npm/test@1.0.0'; - - // Act - const result = truncateString(purl, 60); - - // Assert - assert.strictEqual(result, purl); - }); - - it('truncates PURL if length is greater than sixty characters', () => { - // Arrange - const longPurl = 'pkg:npm/very-long-package-name-that-exceeds-sixty-characters-significantly@1.0.0'; - const expected = `${longPurl.slice(0, 57)}...`; - - // Act - const result = truncateString(longPurl, 60); - - // Assert - assert.strictEqual(result, expected); - }); - }); - - describe('convertComponentToTableRow', () => { - it('converts a component to a table row', () => { - // Arrange - const component = createMockComponent( - 'pkg:npm/very-long-package-name-that-exceeds-thirty-characters@1.0.0', - 'EOL', - new Date('2023-01-01'), - 365, - ); - - // Act - const result = convertComponentToTableRow(component); - - // Assert - assert.strictEqual(result.name, 'very-long-package-name-that-exceeds-thirty-characters'); - assert.strictEqual(result.version, '1.0.0'); - assert.strictEqual(result.eol, '2023-01-01'); - assert.strictEqual(result.daysEol, 365); - assert.strictEqual(result.type, 'npm'); - assert.strictEqual(result.vulnCount, 0); // Default vulnCount - }); - - it('handles null values for eolAt and daysEol', () => { - // Arrange - const component = createMockComponent('pkg:npm/test@1.0.0', 'OK'); - - // Act - const result = convertComponentToTableRow(component); - - // Assert - assert.strictEqual(result.name, 'test'); - assert.strictEqual(result.version, '1.0.0'); - assert.strictEqual(result.eol, ''); - assert.strictEqual(result.daysEol, null); - assert.strictEqual(result.type, 'npm'); - assert.strictEqual(result.vulnCount, 0); // Default vulnCount - }); - }); - - describe('createTableForStatus', () => { - it('creates a table with components of matching status', () => { - // Arrange - const components = createMockScan([ - createMockComponent('pkg:npm/test1@1.0.0', 'EOL', new Date('2023-01-01'), 365), - createMockComponent('pkg:npm/test2@2.0.0', 'OK'), - createMockComponent('pkg:npm/test3@3.0.0', 'EOL', new Date('2023-02-01'), 400), - ]).components; - const grouped = groupComponentsByStatus(components); - - // Act - const table = createTableForStatus(grouped, 'EOL'); - - // Assert - assert.strictEqual(typeof table, 'string'); - // Check that the table contains the expected data, ignoring exact formatting - if (config.showVulnCount) { - assert.match(table, /test1.*1.0.0.*2023-01-01.*365.*npm.*0/); - } else { - assert.match(table, /test1.*1.0.0.*2023-01-01.*365.*npm/); - } - assert.match(table, /test3.*3.0.0.*2023-02-01.*400.*npm/); - }); - - it('returns empty table when no components match status', () => { - // Arrange - const components = createMockScan([createMockComponent('pkg:npm/test1@1.0.0', 'OK')]).components; - const grouped = groupComponentsByStatus(components); - - // Act - const table = createTableForStatus(grouped, 'EOL'); - - // Assert - assert.strictEqual(typeof table, 'string'); - // The table should be empty except for headers - assert.doesNotMatch(table, /test1/); - }); - it('creates a table with components of matching status without EOL columns', () => { - // Arrange - const components = createMockScan([ - createMockComponent('pkg:npm/test1@1.0.0', 'OK'), - createMockComponent('pkg:npm/test2@2.0.0', 'EOL', new Date('2023-01-01'), 365), - createMockComponent('pkg:npm/test3@3.0.0', 'OK'), - ]).components; - const grouped = groupComponentsByStatus(components); - - // Act - const table = createTableForStatus(grouped, 'OK'); - - // Assert - assert.strictEqual(typeof table, 'string'); - // Check that the table contains the expected columns in order - const lines = table.split('\n'); - const headerLine = lines[1]; // Second line contains headers - if (config.showVulnCount) { - assert.match(headerLine, /NAME.*VERSION.*TYPE.*# OF VULNS/); - assert.match(table, /test1.*1.0.0.*npm.*0/); - } else { - assert.match(headerLine, /NAME.*VERSION.*TYPE/); - assert.match(table, /test1.*1.0.0.*npm/); - } - // Verify EOL and DAYS EOL columns are not present - assert.doesNotMatch(headerLine, /EOL/); - assert.doesNotMatch(headerLine, /DAYS EOL/); - }); - - it('creates a table for UNKNOWN status without EOL columns', () => { - // Arrange - const components = createMockScan([ - createMockComponent('pkg:npm/test1@1.0.0', 'UNKNOWN'), - createMockComponent('pkg:npm/test2@2.0.0', 'OK'), - createMockComponent('pkg:npm/test3@3.0.0', 'UNKNOWN'), - ]).components; - const grouped = groupComponentsByStatus(components); - - // Act - const table = createTableForStatus(grouped, 'UNKNOWN'); - - // Assert - assert.strictEqual(typeof table, 'string'); - // Check that the table contains the expected columns in order - const lines = table.split('\n'); - const headerLine = lines[1]; // Second line contains headers - if (config.showVulnCount) { - assert.match(headerLine, /NAME.*VERSION.*TYPE.*# OF VULNS/); - assert.match(table, /test1.*1.0.0.*npm.*0/); - } else { - assert.match(headerLine, /NAME.*VERSION.*TYPE/); - assert.match(table, /test1.*1.0.0.*npm/); - } - // Verify EOL and DAYS EOL columns are not present - assert.doesNotMatch(headerLine, /EOL/); - assert.doesNotMatch(headerLine, /DAYS EOL/); - }); - - it('returns empty table when no components match status', () => { - // Arrange - const components = createMockScan([ - createMockComponent('pkg:npm/test1@1.0.0', 'EOL', new Date('2023-01-01'), 365), - ]).components; - const grouped = groupComponentsByStatus(components); - - // Act - const table = createTableForStatus(grouped, 'OK'); - - // Assert - assert.strictEqual(typeof table, 'string'); - // The table should be empty except for headers - assert.doesNotMatch(table, /test1/); - }); - }); -}); From 92f9e24f66bc6fda09a312770783aa37fd8dbeb7 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 12:34:00 -0400 Subject: [PATCH 02/10] chore: fix .envrc.example URL --- .envrc.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.envrc.example b/.envrc.example index b3e446af..b7d563ea 100644 --- a/.envrc.example +++ b/.envrc.example @@ -1,4 +1,4 @@ #!/usr/bin/env bash export GRAPHQL_HOST='https://api.nes.herodevs.com'; -export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports'; +export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.io/reports'; From 525e19f66bbffc8feb2d0c105482d6238cd6063d Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 12:34:17 -0400 Subject: [PATCH 03/10] feat: pull remediation data and add to the summary --- e2e/scan/eol.test.ts | 3 ++- src/api/queries/nes/sbom.ts | 3 +++ src/api/types/nes.types.ts | 3 ++- src/commands/scan/eol.ts | 15 +++++++++++++-- src/ui/shared.ui.ts | 2 ++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 79ca83e6..5f7b022c 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -165,13 +165,14 @@ describe('scan:eol e2e', () => { doesNotThrow(() => JSON.parse(stdout)); }); - it('correctly identifies Angular 17 as having a EOL date', async () => { + it('correctly identifies Angular 17 as having a EOL date and remediations available', async () => { const angular17Purls = path.resolve(__dirname, '../fixtures/npm/angular-17.purls.json'); const cmd = `scan:eol --purls=${angular17Purls}`; const { stdout } = await run(cmd); // Match EOL count match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); + match(stdout, /1( .*)EOL Packages with HeroDevs NES Remediations Available/, 'Should show remediation count'); }); describe('web report URL', () => { diff --git a/src/api/queries/nes/sbom.ts b/src/api/queries/nes/sbom.ts index 67dbbaba..bc4209f8 100644 --- a/src/api/queries/nes/sbom.ts +++ b/src/api/queries/nes/sbom.ts @@ -16,6 +16,9 @@ export const M_SCAN = { status vulnCount } + remediation { + id + } } diagnostics message diff --git a/src/api/types/nes.types.ts b/src/api/types/nes.types.ts index 561afd63..a5f751a3 100644 --- a/src/api/types/nes.types.ts +++ b/src/api/types/nes.types.ts @@ -48,6 +48,7 @@ export interface InsightsEolScanComponentInfo { export interface InsightsEolScanComponent { info: InsightsEolScanComponentInfo; purl: string; + remediation?: { id: string }; } export interface ScanWarning { @@ -59,4 +60,4 @@ export interface ScanWarning { } export type ComponentStatus = (typeof VALID_STATUSES)[number]; -export const VALID_STATUSES = ['UNKNOWN', 'OK', 'EOL', 'SUPPORTED'] as const; +export const VALID_STATUSES = ['UNKNOWN', 'OK', 'EOL', 'SUPPORTED', 'NES_AVAILABLE'] as const; diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index dc318113..5a08ac82 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -136,7 +136,7 @@ export default class ScanEol extends Command { } private displayResults(components: InsightsEolScanComponent[]) { - const { UNKNOWN, OK, SUPPORTED, EOL } = countComponentsByStatus(components); + const { UNKNOWN, OK, SUPPORTED, EOL, NES_AVAILABLE } = countComponentsByStatus(components); if (!UNKNOWN && !OK && !SUPPORTED && !EOL) { this.log(ux.colorize('yellow', 'No components found in scan.')); @@ -150,13 +150,19 @@ export default class ScanEol extends Command { this.log( ux.colorize( STATUS_COLORS.SUPPORTED, - `${INDICATORS.SUPPORTED}${SUPPORTED.toLocaleString().padEnd(5)} Scheduled End-of-Life (EOL)`, + `${INDICATORS.SUPPORTED}${SUPPORTED.toLocaleString().padEnd(5)} Scheduled EOL`, ), ); this.log(ux.colorize(STATUS_COLORS.OK, `${INDICATORS.OK} ${OK.toLocaleString().padEnd(5)} OK`)); this.log( ux.colorize(STATUS_COLORS.UNKNOWN, `${INDICATORS.UNKNOWN} ${UNKNOWN.toLocaleString().padEnd(5)} Unknown Status`), ); + this.log( + ux.colorize( + STATUS_COLORS.NES_AVAILABLE, + `${INDICATORS.NES_AVAILABLE} ${NES_AVAILABLE.toLocaleString().padEnd(5)} EOL Packages with HeroDevs NES Remediations Available`, + ), + ); } } @@ -166,10 +172,15 @@ export function countComponentsByStatus(components: InsightsEolScanComponent[]): OK: 0, SUPPORTED: 0, EOL: 0, + NES_AVAILABLE: 0, }; for (const component of components) { grouped[component.info.status]++; + + if (component.remediation) { + grouped.NES_AVAILABLE++; + } } return grouped; diff --git a/src/ui/shared.ui.ts b/src/ui/shared.ui.ts index 0b1abc46..1be84ae4 100644 --- a/src/ui/shared.ui.ts +++ b/src/ui/shared.ui.ts @@ -6,6 +6,7 @@ export const STATUS_COLORS: Record = { UNKNOWN: 'default', OK: 'green', SUPPORTED: 'yellow', + NES_AVAILABLE: 'default', }; export const INDICATORS: Record = { @@ -13,6 +14,7 @@ export const INDICATORS: Record = { UNKNOWN: ux.colorize(STATUS_COLORS.UNKNOWN, '•'), OK: ux.colorize(STATUS_COLORS.OK, '✔'), SUPPORTED: ux.colorize(STATUS_COLORS.SUPPORTED, '⚡'), + NES_AVAILABLE: ux.colorize(STATUS_COLORS.NES_AVAILABLE, '!'), }; export const SCAN_ID_KEY = 'eol-scan-v1-'; From a1e77913ace3e19ce89afb3de2228688b58f81a0 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 13:07:21 -0400 Subject: [PATCH 04/10] feat: add nesAvailable property to JSON result --- src/api/types/nes.types.ts | 3 ++- src/service/nes/nes.svc.ts | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/api/types/nes.types.ts b/src/api/types/nes.types.ts index a5f751a3..50bdbe8e 100644 --- a/src/api/types/nes.types.ts +++ b/src/api/types/nes.types.ts @@ -43,12 +43,13 @@ export interface InsightsEolScanComponentInfo { status: ComponentStatus; daysEol: number | null; vulnCount: number | null; + nesAvailable?: boolean; } export interface InsightsEolScanComponent { info: InsightsEolScanComponentInfo; purl: string; - remediation?: { id: string }; + remediation?: { id: string } | null; } export interface ScanWarning { diff --git a/src/service/nes/nes.svc.ts b/src/service/nes/nes.svc.ts index 6005a6bb..e3537206 100644 --- a/src/service/nes/nes.svc.ts +++ b/src/service/nes/nes.svc.ts @@ -12,7 +12,13 @@ import { debugLogger } from '../log.svc.ts'; export const buildScanResult = (scan: InsightsEolScanResult): ScanResult => { const components = new Map(); for (const c of scan.components) { - components.set(c.purl, c); + components.set(c.purl, { + info: { + ...c.info, + nesAvailable: c.remediation !== null, + }, + purl: c.purl, + }); } return { From 465a4283f3ffaee36b78a4ce1be2da246e856794 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 13:09:31 -0400 Subject: [PATCH 05/10] chore: minor remediations line reword --- src/commands/scan/eol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 5a08ac82..59aedf44 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -160,7 +160,7 @@ export default class ScanEol extends Command { this.log( ux.colorize( STATUS_COLORS.NES_AVAILABLE, - `${INDICATORS.NES_AVAILABLE} ${NES_AVAILABLE.toLocaleString().padEnd(5)} EOL Packages with HeroDevs NES Remediations Available`, + `${INDICATORS.NES_AVAILABLE} ${NES_AVAILABLE.toLocaleString().padEnd(5)} HeroDevs NES Remediations Available`, ), ); } From f940c597f0dbc95c78bc3cbf87a90f250f9e27e4 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 13:13:59 -0400 Subject: [PATCH 06/10] chore: fix import ordering --- src/commands/scan/eol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 59aedf44..21987742 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Command, Flags, ux } from '@oclif/core'; +import terminalLink from 'terminal-link'; import { batchSubmitPurls } from '../../api/nes/nes.client.ts'; import type { ScanResult } from '../../api/types/hd-cli.types.js'; import type { ComponentStatus, InsightsEolScanComponent } from '../../api/types/nes.types.ts'; @@ -10,7 +11,6 @@ import { getErrorMessage, isErrnoException } from '../../service/error.svc.ts'; import { extractPurls, parsePurlsFile } from '../../service/purls.svc.ts'; import { INDICATORS, SCAN_ID_KEY, STATUS_COLORS } from '../../ui/shared.ui.ts'; import ScanSbom from './sbom.ts'; -import terminalLink from 'terminal-link'; export default class ScanEol extends Command { static override description = 'Scan a given sbom for EOL data'; From 36b7dc9bb2bea0f35d8a426b01dd1f0cbe626b37 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 13:47:12 -0400 Subject: [PATCH 07/10] fix: address misses from last two commits (test failure and property change) --- e2e/scan/eol.test.ts | 2 +- src/commands/scan/eol.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 5f7b022c..93921107 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -172,7 +172,7 @@ describe('scan:eol e2e', () => { // Match EOL count match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); - match(stdout, /1( .*)EOL Packages with HeroDevs NES Remediations Available/, 'Should show remediation count'); + match(stdout, /1( .*)HeroDevs NES Remediations Available/, 'Should show remediation count'); }); describe('web report URL', () => { diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 21987742..f6005140 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -178,7 +178,7 @@ export function countComponentsByStatus(components: InsightsEolScanComponent[]): for (const component of components) { grouped[component.info.status]++; - if (component.remediation) { + if (component.info.nesAvailable) { grouped.NES_AVAILABLE++; } } From 6234de37517dbc5564443264cc2a2998c503b838 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Thu, 12 Jun 2025 15:58:32 -0400 Subject: [PATCH 08/10] chore: address some PR comments --- .envrc.example | 2 +- e2e/scan/eol.test.ts | 4 ++-- src/api/types/nes.types.ts | 2 +- src/commands/scan/eol.ts | 10 ++++---- src/ui/shared.ui.ts | 2 -- test/service/eol.svc.test.ts | 46 ++++++++++++++++++++++++++++++++++++ 6 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 test/service/eol.svc.test.ts diff --git a/.envrc.example b/.envrc.example index b7d563ea..b3e446af 100644 --- a/.envrc.example +++ b/.envrc.example @@ -1,4 +1,4 @@ #!/usr/bin/env bash export GRAPHQL_HOST='https://api.nes.herodevs.com'; -export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.io/reports'; +export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports'; diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 93921107..4aa9fd27 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -15,9 +15,9 @@ const execAsync = promisify(exec); describe('environment', () => { it('should not be configured to run against the production environment', () => { notStrictEqual(process.env.GRAPHQL_HOST, 'https://api.nes.herodevs.com'); - notStrictEqual(process.env.EOL_REPORT_URL, 'https://eol-report-card.apps.herodevs.io/reports'); + notStrictEqual(process.env.EOL_REPORT_URL, 'https://eol-report-card.apps.herodevs.com/reports'); notStrictEqual(config.graphqlHost, 'https://api.nes.herodevs.com'); - notStrictEqual(config.eolReportUrl, 'https://eol-report-card.apps.herodevs.io/reports'); + notStrictEqual(config.eolReportUrl, 'https://eol-report-card.apps.herodevs.com/reports'); }); }); diff --git a/src/api/types/nes.types.ts b/src/api/types/nes.types.ts index 50bdbe8e..7558705a 100644 --- a/src/api/types/nes.types.ts +++ b/src/api/types/nes.types.ts @@ -61,4 +61,4 @@ export interface ScanWarning { } export type ComponentStatus = (typeof VALID_STATUSES)[number]; -export const VALID_STATUSES = ['UNKNOWN', 'OK', 'EOL', 'SUPPORTED', 'NES_AVAILABLE'] as const; +export const VALID_STATUSES = ['UNKNOWN', 'OK', 'EOL', 'SUPPORTED'] as const; diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index f6005140..84627d82 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -159,15 +159,17 @@ export default class ScanEol extends Command { ); this.log( ux.colorize( - STATUS_COLORS.NES_AVAILABLE, - `${INDICATORS.NES_AVAILABLE} ${NES_AVAILABLE.toLocaleString().padEnd(5)} HeroDevs NES Remediations Available`, + STATUS_COLORS.UNKNOWN, + `${INDICATORS.UNKNOWN} ${NES_AVAILABLE.toLocaleString().padEnd(5)} HeroDevs NES Remediation${NES_AVAILABLE !== 1 ? 's' : ''} Available`, ), ); } } -export function countComponentsByStatus(components: InsightsEolScanComponent[]): Record { - const grouped: Record = { +export function countComponentsByStatus( + components: InsightsEolScanComponent[], +): Record { + const grouped: Record = { UNKNOWN: 0, OK: 0, SUPPORTED: 0, diff --git a/src/ui/shared.ui.ts b/src/ui/shared.ui.ts index 1be84ae4..0b1abc46 100644 --- a/src/ui/shared.ui.ts +++ b/src/ui/shared.ui.ts @@ -6,7 +6,6 @@ export const STATUS_COLORS: Record = { UNKNOWN: 'default', OK: 'green', SUPPORTED: 'yellow', - NES_AVAILABLE: 'default', }; export const INDICATORS: Record = { @@ -14,7 +13,6 @@ export const INDICATORS: Record = { UNKNOWN: ux.colorize(STATUS_COLORS.UNKNOWN, '•'), OK: ux.colorize(STATUS_COLORS.OK, '✔'), SUPPORTED: ux.colorize(STATUS_COLORS.SUPPORTED, '⚡'), - NES_AVAILABLE: ux.colorize(STATUS_COLORS.NES_AVAILABLE, '!'), }; export const SCAN_ID_KEY = 'eol-scan-v1-'; diff --git a/test/service/eol.svc.test.ts b/test/service/eol.svc.test.ts new file mode 100644 index 00000000..228fe7b9 --- /dev/null +++ b/test/service/eol.svc.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import { createSbom, validateIsCycloneDxSbom } from '../../src/service/eol/eol.svc.ts'; +import assert from 'node:assert'; + +describe('eol.svc', () => { + describe('validateIsCycloneDxSbom', () => { + it('should throw an error if the SBOM is not an object', () => { + assert.throws(() => validateIsCycloneDxSbom('hello')); + }); + + it('should throw an error if the SBOM is not in CycloneDX format', () => { + assert.throws(() => + validateIsCycloneDxSbom({ + bomFormat: 'SPDX', + }), + ); + }); + + it('should throw an error if the SBOM is missing a specVersion', () => { + assert.throws(() => + validateIsCycloneDxSbom({ + bomFormat: 'CycloneDX', + }), + ); + }); + + it('should throw an error if the SBOM has no component array', () => { + assert.throws(() => + validateIsCycloneDxSbom({ + bomFormat: 'CycloneDX', + specVersion: 1, + }), + ); + }); + + it('should not throw an error if all criteria pass', () => { + assert.throws(() => + validateIsCycloneDxSbom({ + bomFormat: 'CycloneDX', + components: [], + specVersion: 1, + }), + ); + }); + }); +}); From cbda17ae238950b18123dc7f1d9aefa6afa31b43 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Fri, 13 Jun 2025 09:48:09 -0400 Subject: [PATCH 09/10] chore: add some coverage back --- e2e/scan/eol.test.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 4aa9fd27..8fc9f3c1 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -165,14 +165,25 @@ describe('scan:eol e2e', () => { doesNotThrow(() => JSON.parse(stdout)); }); - it('correctly identifies Angular 17 as having a EOL date and remediations available', async () => { + it('correctly identifies Bootstrap as having EOL status and remediation available when using the --json flag', async () => { + const cmd = `scan:eol --purls=${simplePurls} --json`; + const { stdout } = await run(cmd); + + const json = JSON.parse(stdout); + const bootstrap = json.components.find((component) => component.purl.startsWith('pkg:npm/bootstrap@')); + strictEqual(bootstrap?.info.status, 'EOL', 'Should match EOL count'); + strictEqual(bootstrap?.info.nesAvailable, true, 'Should match remediation count'); + }); + + it('correctly identifies Angular 17 as having a EOL date when using --json flag', async () => { const angular17Purls = path.resolve(__dirname, '../fixtures/npm/angular-17.purls.json'); - const cmd = `scan:eol --purls=${angular17Purls}`; + const cmd = `scan:eol --purls=${angular17Purls} --json`; const { stdout } = await run(cmd); + const json = JSON.parse(stdout); + const angular17 = json.components.find((component) => component.purl.startsWith('pkg:npm/%40angular/core@')); // Match EOL count - match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count'); - match(stdout, /1( .*)HeroDevs NES Remediations Available/, 'Should show remediation count'); + strictEqual(angular17?.info.status, 'EOL', 'Should match EOL status'); }); describe('web report URL', () => { @@ -183,6 +194,14 @@ describe('scan:eol e2e', () => { // Match the key text and scan ID pattern match(stdout, /View your full EOL report at.*[a-zA-Z0-9-]+/, 'Should show web report text and scan ID'); }); + + it('does not display web report URL when using --json flag', async () => { + const cmd = `scan:eol --purls=${simplePurls} --json`; + const { stdout } = await run(cmd); + + // Verify URL text is not in output + doesNotMatch(stdout, /View your free EOL report/, 'Should not show web report text in JSON output'); + }); }); }); @@ -190,7 +209,7 @@ describe('scan:eol e2e', () => { * Directory scan tests * Please see CONTRIBUTING.md before adding new tests to this section. */ -describe('scan:eol e2e directory', () => { +describe('with directory flag', () => { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const simpleDir = path.resolve(__dirname, '../fixtures/npm/simple'); const upToDateDir = path.resolve(__dirname, '../fixtures/npm/up-to-date'); From ef7e2a153650f2f857dd62d4e26897bb4faa8c32 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Fri, 13 Jun 2025 09:49:55 -0400 Subject: [PATCH 10/10] chore: fix lint --- test/service/eol.svc.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/service/eol.svc.test.ts b/test/service/eol.svc.test.ts index 228fe7b9..0caccfb9 100644 --- a/test/service/eol.svc.test.ts +++ b/test/service/eol.svc.test.ts @@ -1,6 +1,6 @@ -import { describe, it } from 'node:test'; -import { createSbom, validateIsCycloneDxSbom } from '../../src/service/eol/eol.svc.ts'; import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { validateIsCycloneDxSbom } from '../../src/service/eol/eol.svc.ts'; describe('eol.svc', () => { describe('validateIsCycloneDxSbom', () => {