diff --git a/e2e/fixtures/npm/simple/package-lock.json b/e2e/fixtures/npm/simple/package-lock.json index 0b9db78f..02c1b3c9 100644 --- a/e2e/fixtures/npm/simple/package-lock.json +++ b/e2e/fixtures/npm/simple/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "bootstrap": "3.1.1" + "bootstrap": "3.1.1", + "vue": "3.5.13" } }, "node_modules/bootstrap": { @@ -17,6 +18,12 @@ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.1.1.tgz", "integrity": "sha512-TAzqS8E/ISpVHP29gYhy+1NSV+FJwApDVQhDfgLYsIuUDJiqJzH2DfORN5tiu+Ie2iBLrzEG34t4Rf465cigqg==", "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "license": "MIT" } } } diff --git a/e2e/fixtures/npm/simple/package.json b/e2e/fixtures/npm/simple/package.json index 0988f4f0..735f9c6e 100644 --- a/e2e/fixtures/npm/simple/package.json +++ b/e2e/fixtures/npm/simple/package.json @@ -6,7 +6,8 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "bootstrap": "3.1.1" + "bootstrap": "3.1.1", + "vue": "3.5.13" }, "packageManager:ignored": "npm", "keywords": [ ], diff --git a/e2e/scan/bom.json b/e2e/fixtures/npm/simple/sbom.json similarity index 100% rename from e2e/scan/bom.json rename to e2e/fixtures/npm/simple/sbom.json diff --git a/e2e/fixtures/npm/up-to-date/package-lock.json b/e2e/fixtures/npm/up-to-date/package-lock.json new file mode 100644 index 00000000..808b61b0 --- /dev/null +++ b/e2e/fixtures/npm/up-to-date/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "up-to-date", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "up-to-date", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "bootstrap": "5.3.5", + "vue": "3.5.13" + } + }, + "node_modules/bootstrap": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz", + "integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==", + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "license": "MIT" + } + } +} diff --git a/e2e/fixtures/npm/up-to-date/package.json b/e2e/fixtures/npm/up-to-date/package.json new file mode 100644 index 00000000..bfbfe707 --- /dev/null +++ b/e2e/fixtures/npm/up-to-date/package.json @@ -0,0 +1,17 @@ +{ + "name": "up-to-date", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "bootstrap": "5.3.5", + "vue": "3.5.13" + }, + "packageManager:ignored": "npm", + "keywords": [ ], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/e2e/fixtures/purls/empty.purls.json b/e2e/fixtures/purls/empty.purls.json new file mode 100644 index 00000000..86ad3eb1 --- /dev/null +++ b/e2e/fixtures/purls/empty.purls.json @@ -0,0 +1,3 @@ +{ + "purls": [] +} \ No newline at end of file diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 899ffcc1..9f42ac1d 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -1,37 +1,27 @@ -import { match, strictEqual } from 'node:assert/strict'; -import fs from 'node:fs/promises'; +import { doesNotThrow } from 'node:assert'; +import { doesNotMatch, match, strictEqual } from 'node:assert/strict'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; import path from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; +import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; import { runCommand } from '@oclif/test'; describe('scan:eol e2e', () => { const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const testDir = path.resolve(__dirname, '../fixtures/npm/simple'); - const reportPath = path.join(testDir, 'nes.eol.json'); + const simpleDir = path.resolve(__dirname, '../fixtures/npm/simple'); + const upToDateDir = path.resolve(__dirname, '../fixtures/npm/up-to-date'); + const reportPath = path.join(simpleDir, 'nes.eol.json'); const extraLargePurlsPath = path.resolve(__dirname, '../fixtures/purls/extra-large.purls.json'); + const emptyPurlsPath = path.resolve(__dirname, '../fixtures/purls/empty.purls.json'); - async function cleanupReport() { - try { - await fs.unlink(reportPath); - } catch { - // Ignore if file doesn't exist - } - } - - beforeEach(async () => { + async function run(cmd: string) { // Set up environment process.env.GRAPHQL_HOST = 'https://api.dev.nes.herodevs.com'; // Ensure test directory exists and is clean - await fs.mkdir(testDir, { recursive: true }); - await cleanupReport(); - }); - - afterEach(cleanupReport); + await mkdir(simpleDir, { recursive: true }); - it('scans a directory for EOL components', async () => { - const cmd = `scan:eol --dir ${testDir}`; const output = await runCommand(cmd); // Log any errors for debugging @@ -43,8 +33,12 @@ describe('scan:eol e2e', () => { // Verify command executed successfully strictEqual(output.error, undefined, 'Command should execute without errors'); - // Verify output contains expected content - const stdout = output.stdout; + return output; + } + + it('scans a directory for EOL components', 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'); @@ -54,51 +48,28 @@ describe('scan:eol e2e', () => { }); it('saves report when --save flag is used', async () => { - const cmd = `scan:eol --dir ${testDir} --save`; - const output = await runCommand(cmd); - - // Log any errors for debugging - if (output.error) { - console.error('Command failed with error:', output.error); - console.error('Error details:', output.stderr); - } - - // Verify command executed successfully - strictEqual(output.error, undefined, 'Command should execute without errors'); + const cmd = `scan:eol --dir ${simpleDir} --save`; + await run(cmd); // Verify report was saved - const reportExists = await fs - .access(reportPath) - .then(() => true) - .catch(() => false); + const reportExists = existsSync(reportPath); strictEqual(reportExists, true, 'Report file should be created'); // Verify report content - const reportContent = await fs.readFile(reportPath, 'utf-8'); - const report = JSON.parse(reportContent); + const report = readFileSync(reportPath, 'utf-8'); // Verify report structure using match - match(JSON.stringify(report), /"components":\s*\[/, 'Report should contain components array'); - match(JSON.stringify(report), /"purl":\s*"pkg:npm\/bootstrap@3\.1\.1"/, 'Report should contain bootstrap package'); - match(JSON.stringify(report), /"isEol":\s*true/, 'Bootstrap should be marked as EOL'); + match(report, /"components":\s*\[/, 'Report should contain components array'); + match(report, /"purl":\s*"pkg:npm\/bootstrap@3\.1\.1"/, 'Report should contain bootstrap package'); + match(report, /"isEol":\s*true/, 'Bootstrap should be marked as EOL'); + + unlinkSync(reportPath); }); it('scans extra-large.purls.json for EOL components', async () => { const cmd = `scan:eol --purls ${extraLargePurlsPath}`; - const output = await runCommand(cmd); - - // Log any errors for debugging - if (output.error) { - console.error('Command failed with error:', output.error); - console.error('Error details:', output.stderr); - } - - // Verify command executed successfully - strictEqual(output.error, undefined, 'Command should execute without errors'); - - // Verify output contains expected content - const stdout = output.stdout; + const { stdout } = await run(cmd); // Match command output patterns match(stdout, /Here are the results of the scan:/, 'Should show results header'); @@ -117,4 +88,62 @@ describe('scan:eol e2e', () => { match(stdout, /⚡= Long Term Support \(LTS\)/, 'Should show legend for LTS status'); match(stdout, /✗ = End of Life \(EOL\)/, 'Should show legend for EOL status'); }); + + it('scans existing SBOM for EOL components', async () => { + 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'); + }); + + it('outputs JSON when using the --json flag', async () => { + const cmd = `scan:eol --dir ${simpleDir} --json`; + const { stdout } = await run(cmd); + + // Match command output patterns + doesNotMatch(stdout, /Here are the results of the scan:/, 'Should not show results header'); + doesNotThrow(() => JSON.parse(stdout)); + }); + + 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 Long Term Support 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'); + }); + }); }); diff --git a/src/api/nes/nes.client.ts b/src/api/nes/nes.client.ts index c185d25c..65076342 100644 --- a/src/api/nes/nes.client.ts +++ b/src/api/nes/nes.client.ts @@ -8,7 +8,13 @@ import type { } from '../../api/types/nes.types.ts'; import { debugLogger } from '../../service/log.svc.ts'; import { SbomScanner, buildScanResult } from '../../service/nes/nes.svc.ts'; -import type { ProcessBatchOptions, ScanInputOptions, ScanResult } from '../types/hd-cli.types.ts'; +import { + DEFAULT_SCAN_BATCH_SIZE, + DEFAULT_SCAN_INPUT_OPTIONS, + type ProcessBatchOptions, + type ScanInputOptions, + type ScanResult, +} from '../types/hd-cli.types.ts'; export interface NesClient { scan: { @@ -49,8 +55,8 @@ function submitScan(purls: string[], options: ScanInputOptions): Promise => { try { const batches = createBatches(purls, batchSize); diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 51bc4426..c771b55e 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Command, Flags, ux } from '@oclif/core'; import { batchSubmitPurls } from '../../api/nes/nes.client.ts'; -import { DEFAULT_SCAN_BATCH_SIZE, DEFAULT_SCAN_INPUT_OPTIONS, type ScanResult } from '../../api/types/hd-cli.types.js'; +import type { ScanResult } from '../../api/types/hd-cli.types.js'; import type { InsightsEolScanComponent } from '../../api/types/nes.types.ts'; import type { Sbom } from '../../service/eol/cdx.svc.ts'; import { getErrorMessage, isErrnoException } from '../../service/error.svc.ts'; @@ -62,38 +62,31 @@ export default class ScanEol extends Command { ux.action.stop('\nScan completed'); - const filteredComponents = this.getFilteredComponents(scan, flags.all); + const components = this.getFilteredComponents(scan, flags.all); if (flags.save) { - await this.saveReport(filteredComponents); + await this.saveReport(components); } - if (this.jsonEnabled()) { - return { components: filteredComponents }; + if (!this.jsonEnabled()) { + await this.displayResults(scan, flags.all); } - await this.displayResults(scan, flags.all); - - return { components: filteredComponents }; + return { components }; } private async getScan(flags: Record, config: Command['config']): Promise { if (flags.purls) { ux.action.start(`Scanning purls from ${flags.purls}`); const purls = this.getPurlsFromFile(flags.purls); - return batchSubmitPurls(purls, DEFAULT_SCAN_INPUT_OPTIONS, DEFAULT_SCAN_BATCH_SIZE); + return batchSubmitPurls(purls); } const sbom = await ScanSbom.loadSbom(flags, config); - const scan = this.scanSbom(sbom, flags); - - return scan; + return this.scanSbom(sbom); } - private getPurlsFromFile(filePath: unknown): string[] { - if (typeof filePath !== 'string') { - this.error(`Failed to parse file path: ${filePath}`); - } + private getPurlsFromFile(filePath: string): string[] { try { const purlsFileString = fs.readFileSync(filePath, 'utf8'); return parsePurlsFile(purlsFileString); @@ -102,7 +95,7 @@ export default class ScanEol extends Command { } } - private async scanSbom(sbom: Sbom, flags: Record): Promise { + private async scanSbom(sbom: Sbom): Promise { let scan: ScanResult; let purls: string[]; @@ -112,7 +105,7 @@ export default class ScanEol extends Command { this.error(`Failed to extract purls from sbom. ${getErrorMessage(error)}`); } try { - scan = await batchSubmitPurls(purls, DEFAULT_SCAN_INPUT_OPTIONS, DEFAULT_SCAN_BATCH_SIZE); + scan = await batchSubmitPurls(purls); } catch (error) { this.error(`Failed to submit scan to NES from sbom. ${getErrorMessage(error)}`); } @@ -125,32 +118,32 @@ export default class ScanEol extends Command { } private getFilteredComponents(scan: ScanResult, all: boolean) { - return Array.from(scan.components.entries()) - .filter(([_, component]) => all || ['EOL', 'LTS'].includes(component.info.status)) - .map(([_, component]) => component); + return Array.from(scan.components.values()).filter( + (component) => all || ['EOL', 'LTS'].includes(component.info.status), + ); } private async saveReport(components: InsightsEolScanComponent[]): Promise { + const { flags } = await this.parse(ScanEol); + const reportPath = path.join(flags.dir || process.cwd(), 'nes.eol.json'); + try { - const { flags } = await this.parse(ScanEol); - const reportPath = path.join(flags.dir || process.cwd(), 'nes.eol.json'); fs.writeFileSync(reportPath, JSON.stringify({ components }, null, 2)); this.log('Report saved to nes.eol.json'); } catch (error) { - if (isErrnoException(error)) { - switch (error.code) { - case 'EACCES': - this.error('Permission denied. Unable to save report to nes.eol.json'); - break; - case 'ENOSPC': - this.error('No space left on device. Unable to save report to nes.eol.json'); - break; - default: - this.error(`Failed to save report: ${getErrorMessage(error)}`); - } - } else { + if (!isErrnoException(error)) { this.error(`Failed to save report: ${getErrorMessage(error)}`); } + switch (error.code) { + case 'EACCES': + this.error('Permission denied. Unable to save report to nes.eol.json'); + break; + case 'ENOSPC': + this.error('No space left on device. Unable to save report to nes.eol.json'); + break; + default: + this.error(`Failed to save report: ${getErrorMessage(error)}`); + } } } @@ -194,9 +187,9 @@ export default class ScanEol extends Command { } 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.LTS}`, `${INDICATORS.LTS}= Long Term Support (LTS)`)); - this.log(ux.colorize(`${STATUS_COLORS.EOL}`, `${INDICATORS.EOL} = End of Life (EOL)`)); + 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.LTS, `${INDICATORS.LTS}= Long Term Support (LTS)`)); + this.log(ux.colorize(STATUS_COLORS.EOL, `${INDICATORS.EOL} = End of Life (EOL)`)); } }