diff --git a/src/api/errors.ts b/src/api/errors.ts index 6fd416af..def7e682 100644 --- a/src/api/errors.ts +++ b/src/api/errors.ts @@ -28,3 +28,47 @@ export class ApiError extends Error { export function isApiErrorCode(code: string): code is ApiErrorCode { return VALID_API_ERROR_CODES.has(code as ApiErrorCode); } + +export const EMPTY_SBOM_ERROR_CODE = 'EMPTY_SBOM'; +export const INVALID_SBOM_JSON_ERROR_CODE = 'INVALID_SBOM_JSON'; +export const SBOM_MISSING_COMPONENTS_ERROR_CODE = 'SBOM_MISSING_COMPONENTS'; +export const SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE = 'SBOM_NO_IDENTIFIABLE_COMPONENTS'; +export const INVALID_PURL_ERROR_CODE = 'INVALID_PURL'; + +/** + * SBOM validation codes returned by eol-api when a submitted SBOM is rejected. + * Kept separate from the auth-oriented API_ERROR_CODES: these are surfaced with + * their own CLI copy and must not feed isApiErrorCode / AUTH_ERROR_MESSAGES. + */ +const SBOM_ERROR_CODES = [ + EMPTY_SBOM_ERROR_CODE, + INVALID_SBOM_JSON_ERROR_CODE, + SBOM_MISSING_COMPONENTS_ERROR_CODE, + SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE, + INVALID_PURL_ERROR_CODE, +] as const; +export type SbomErrorCode = (typeof SBOM_ERROR_CODES)[number]; + +const VALID_SBOM_ERROR_CODES = new Set(SBOM_ERROR_CODES); + +export function isSbomErrorCode(code: string): code is SbomErrorCode { + return VALID_SBOM_ERROR_CODES.has(code as SbomErrorCode); +} + +/** Extra context merged into the GraphQL error `extensions` for some SBOM codes. */ +export interface SbomErrorDetails { + purl?: string; + totalComponents?: number; +} + +export class SbomError extends Error { + readonly code: SbomErrorCode; + readonly details?: SbomErrorDetails; + + constructor(message: string, code: SbomErrorCode, details?: SbomErrorDetails) { + super(message); + this.name = 'SbomError'; + this.code = code; + this.details = details; + } +} diff --git a/src/api/nes.client.ts b/src/api/nes.client.ts index af76c508..133d76b9 100644 --- a/src/api/nes.client.ts +++ b/src/api/nes.client.ts @@ -10,7 +10,7 @@ import { config } from '../config/constants.ts'; import { debugLogger } from '../service/log.svc.ts'; import { stripTypename } from '../utils/strip-typename.ts'; import { createApollo } from './apollo.client.ts'; -import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts'; +import { ApiError, type ApiErrorCode, isApiErrorCode, isSbomErrorCode, SbomError } from './errors.ts'; import { createReportMutation, getEolReportQuery } from './gql-operations.ts'; import { getGraphQLErrors } from './graphql-errors.ts'; @@ -20,6 +20,20 @@ function extractErrorCode(errors: ReadonlyArray): ApiErro return code; } +/** + * Recognize SBOM validation rejections so the thrown error keeps both the code + * and the API-provided message/details for the createReport path. + */ +function extractSbomError(errors: ReadonlyArray): SbomError | undefined { + const extensions = errors[0]?.extensions as { code?: string; purl?: string; totalComponents?: number } | undefined; + const code = extensions?.code; + if (!code || !isSbomErrorCode(code)) return; + return new SbomError(errors[0].message, code, { + purl: extensions.purl, + totalComponents: extensions.totalComponents, + }); +} + export interface ScanProgressHandlers { onReportCreated?: (reportId: string) => void; onPageProgress?: (completedPageCount: number, totalPageCount: number) => void; @@ -40,6 +54,10 @@ export const SbomScanner = (client: ReturnType) => { throw res.error; } if (errors?.length) { + const sbomError = extractSbomError(errors); + if (sbomError) { + throw sbomError; + } const code = extractErrorCode(errors); if (code) { throw new ApiError(errors[0].message, code); diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 8f17bd40..c4c19dcf 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -3,7 +3,16 @@ import { trimCdxBom } from '@herodevs/eol-shared'; import { Command, Flags } from '@oclif/core'; import { Presets, SingleBar } from 'cli-progress'; import ora from 'ora'; -import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } from '../../api/errors.ts'; +import { + ApiError, + EMPTY_SBOM_ERROR_CODE, + INVALID_PURL_ERROR_CODE, + PAYLOAD_TOO_LARGE_ERROR_CODE, + SBOM_MISSING_COMPONENTS_ERROR_CODE, + SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE, + SbomError, + type SbomErrorCode, +} from '../../api/errors.ts'; import { submitScan } from '../../api/nes.client.ts'; import { config, filenamePrefix, SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from '../../config/constants.ts'; import { track } from '../../service/analytics.svc.ts'; @@ -19,6 +28,53 @@ import { import { readSbomFromFile, saveArtifactToFile, validateDirectory } from '../../service/file.svc.ts'; import { getErrorMessage } from '../../service/log.svc.ts'; +/** + * CLI-friendly copy for each SBOM validation code. Kept separate from + * AUTH_ERROR_MESSAGES — these are SBOM problems, not auth problems. + */ +export const SBOM_ERROR_MESSAGES: Record = { + EMPTY_SBOM: "This SBOM doesn't contain any data. Provide a valid CycloneDX or SPDX SBOM and try again.", + INVALID_SBOM_JSON: "This SBOM isn't valid JSON. Provide a well-formed CycloneDX or SPDX SBOM and try again.", + SBOM_MISSING_COMPONENTS: + "This SBOM doesn't list any packages (components), so there's nothing to analyze. Provide an SBOM that includes packages and try again.", + SBOM_NO_IDENTIFIABLE_COMPONENTS: + "None of the packages (components) in this SBOM include package URLs (purls), so we can't identify them for analysis. Regenerate the SBOM with a tool that includes purls, such as cdxgen or Syft, and try again.", + INVALID_PURL: + "One of the packages (components) in this SBOM has a malformed package URL (purl). Regenerate the SBOM with an up-to-date tool such as cdxgen or Syft, and try again. If the issue persists, that purl likely doesn't conform to the purl spec and will need to be manually addressed.", +}; + +/** + * Resolve the user-facing message for an SBOM rejection, tailoring wording for + * user-provided (`--file`) vs CLI-generated SBOMs where it adds clarity, and + * folding in error details (e.g. the offending purl) when the API supplies them. + */ +export function getSbomErrorMessage(error: SbomError, hasUserProvidedSbom: boolean): string { + switch (error.code) { + case EMPTY_SBOM_ERROR_CODE: + return hasUserProvidedSbom + ? "This file doesn't contain any SBOM data. Provide a valid CycloneDX or SPDX SBOM file and try again." + : 'The generated SBOM contained no data. Try scanning a directory that has installed dependencies.'; + case SBOM_MISSING_COMPONENTS_ERROR_CODE: + return hasUserProvidedSbom + ? SBOM_ERROR_MESSAGES[SBOM_MISSING_COMPONENTS_ERROR_CODE] + : 'The generated SBOM has no packages (components). Make sure the scanned project has installed dependencies and try again.'; + case SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE: + return hasUserProvidedSbom + ? "None of the packages (components) in this SBOM include package URLs (purls), so we can't identify them for analysis. Regenerate the SBOM with a tool that includes purls, such as cdxgen or Syft — or run the scan without --file to let the CLI generate one from your project — and try again." + : SBOM_ERROR_MESSAGES[SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE]; + case INVALID_PURL_ERROR_CODE: { + const purl = error.details?.purl; + const problem = `One of the packages (components) in this SBOM has a malformed package URL (purl)${purl ? `: ${purl}` : ''}.`; + const fix = hasUserProvidedSbom + ? 'Regenerate the SBOM with an up-to-date tool such as cdxgen or Syft — or run the scan without --file to let the CLI generate one from your project — and try again.' + : 'Regenerate the SBOM with an up-to-date tool such as cdxgen or Syft, and try again.'; + return `${problem} ${fix} If the issue persists, that purl likely doesn't conform to the purl spec and will need to be manually addressed.`; + } + default: + return SBOM_ERROR_MESSAGES[error.code]; + } +} + export default class ScanEol extends Command { static override description = 'Scan a given SBOM for EOL data'; static enableJsonFlag = true; @@ -253,6 +309,18 @@ export default class ScanEol extends Command { } const scanLoadTime = this.getScanLoadTime(scanStartTime); + if (error instanceof SbomError) { + track('CLI EOL Scan Failed', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_failure_reason: error.code, + scan_load_time: scanLoadTime, + number_of_packages: numberOfPackages, + })); + + this.error(getSbomErrorMessage(error, Boolean(flags.file))); + } + if (error instanceof ApiError) { track('CLI EOL Scan Failed', (context) => ({ command: context.command, diff --git a/test/api/nes.client.test.ts b/test/api/nes.client.test.ts index f93fe9c5..f9d7c19d 100644 --- a/test/api/nes.client.test.ts +++ b/test/api/nes.client.test.ts @@ -3,7 +3,13 @@ import { vi } from 'vitest'; vi.mock('../../src/config/constants.ts', async (importOriginal) => importOriginal()); -import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } from '../../src/api/errors.ts'; +import { + ApiError, + INVALID_PURL_ERROR_CODE, + PAYLOAD_TOO_LARGE_ERROR_CODE, + SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE, + SbomError, +} from '../../src/api/errors.ts'; import { submitScan } from '../../src/api/nes.client.ts'; import { SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from '../../src/config/constants.ts'; import { requireAccessTokenForScan } from '../../src/service/auth.svc.ts'; @@ -127,6 +133,64 @@ describe('nes.client', () => { await expect(submitScan(input)).rejects.toThrow(/Failed to create EOL report/); }); + it('throws SbomError preserving the code and API message for SBOM validation errors', async () => { + fetchMock.addGraphQL({ eol: { createReport: null } }, [ + { + message: 'No identifiable components', + path: ['eol', 'createReport'], + extensions: { code: SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE, totalComponents: 12 }, + }, + ]); + + const input: CreateEolReportInput = { + sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 }, + }; + const error = await submitScan(input).catch((e) => e); + + expect(error).toBeInstanceOf(SbomError); + expect(error.code).toBe(SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE); + expect(error.message).toBe('No identifiable components'); + expect(error.details).toEqual({ purl: undefined, totalComponents: 12 }); + }); + + it('preserves purl details on INVALID_PURL SBOM errors', async () => { + fetchMock.addGraphQL({ eol: { createReport: null } }, [ + { + message: 'Invalid purl', + path: ['eol', 'createReport'], + extensions: { code: INVALID_PURL_ERROR_CODE, purl: 'pkg:npm/@@bad' }, + }, + ]); + + const input: CreateEolReportInput = { + sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 }, + }; + const error = await submitScan(input).catch((e) => e); + + expect(error).toBeInstanceOf(SbomError); + expect(error.code).toBe(INVALID_PURL_ERROR_CODE); + expect(error.details?.purl).toBe('pkg:npm/@@bad'); + }); + + it('falls back to the generic error for unrecognized error codes', async () => { + fetchMock.addGraphQL({ eol: { createReport: null } }, [ + { + message: 'Something unexpected', + path: ['eol', 'createReport'], + extensions: { code: 'SOME_UNKNOWN_CODE' }, + }, + ]); + + const input: CreateEolReportInput = { + sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 }, + }; + const error = await submitScan(input).catch((e) => e); + + expect(error).not.toBeInstanceOf(SbomError); + expect(error).not.toBeInstanceOf(ApiError); + expect(error.message).toMatch(/Failed to create EOL report/); + }); + it('throws ApiError with PAYLOAD_TOO_LARGE code when server returns 413', async () => { fetchMock.push({ headers: { get: () => 'text/plain' }, diff --git a/test/commands/scan/eol.analytics.test.ts b/test/commands/scan/eol.analytics.test.ts index d73bccc7..57d8e9e6 100644 --- a/test/commands/scan/eol.analytics.test.ts +++ b/test/commands/scan/eol.analytics.test.ts @@ -1,7 +1,17 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared'; import type { Config } from '@oclif/core'; -import { ApiError, FORBIDDEN_ERROR_CODE, PAYLOAD_TOO_LARGE_ERROR_CODE } from '../../../src/api/errors.ts'; -import ScanEol from '../../../src/commands/scan/eol.ts'; +import { + ApiError, + EMPTY_SBOM_ERROR_CODE, + FORBIDDEN_ERROR_CODE, + INVALID_PURL_ERROR_CODE, + INVALID_SBOM_JSON_ERROR_CODE, + PAYLOAD_TOO_LARGE_ERROR_CODE, + SBOM_MISSING_COMPONENTS_ERROR_CODE, + SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE, + SbomError, +} from '../../../src/api/errors.ts'; +import ScanEol, { getSbomErrorMessage, SBOM_ERROR_MESSAGES } from '../../../src/commands/scan/eol.ts'; import { CITokenError } from '../../../src/service/auth.svc.ts'; const { @@ -213,6 +223,47 @@ describe('scan:eol analytics timing', () => { ); }); + it('surfaces the SBOM message and tracks the specific code on SbomError failures', async () => { + submitScanMock.mockRejectedValue( + new SbomError('No identifiable components', SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE, { + totalComponents: 12, + }), + ); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { automated: false, saveTrimmedSbom: false, file: '/tmp/sample.sbom.json' }, + }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.scanSbom(sampleSbom)).rejects.toThrow( + "None of the packages (components) in this SBOM include package URLs (purls), so we can't identify them for analysis. Regenerate the SBOM with a tool that includes purls, such as cdxgen or Syft — or run the scan without --file to let the CLI generate one from your project — and try again.", + ); + + const properties = getTrackProperties('CLI EOL Scan Failed'); + expect(properties.scan_failure_reason).toBe(SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE); + expect(properties.scan_load_time).toEqual(expect.any(Number)); + expect(properties.number_of_packages).toBe(1); + }); + + it('includes the offending purl for INVALID_PURL SBOM failures', async () => { + submitScanMock.mockRejectedValue(new SbomError('Invalid purl', INVALID_PURL_ERROR_CODE, { purl: 'pkg:npm/@@bad' })); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { automated: false, saveTrimmedSbom: false, file: '/tmp/sample.sbom.json' }, + }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.scanSbom(sampleSbom)).rejects.toThrow( + 'One of the packages (components) in this SBOM has a malformed package URL (purl): pkg:npm/@@bad. Regenerate the SBOM with an up-to-date tool such as cdxgen or Syft — or run the scan without --file to let the CLI generate one from your project — and try again.', + ); + }); + it('keeps scan_load_time on successful completion events', async () => { const command = createCommand(); @@ -274,3 +325,41 @@ describe('scan:eol analytics timing', () => { ); }); }); + +describe('getSbomErrorMessage', () => { + it('returns the base copy for codes without file/generated tailoring', () => { + const error = new SbomError('x', INVALID_SBOM_JSON_ERROR_CODE); + expect(getSbomErrorMessage(error, true)).toBe(SBOM_ERROR_MESSAGES[INVALID_SBOM_JSON_ERROR_CODE]); + expect(getSbomErrorMessage(error, false)).toBe(SBOM_ERROR_MESSAGES[INVALID_SBOM_JSON_ERROR_CODE]); + }); + + it('tailors EMPTY_SBOM wording for user-provided vs generated SBOMs', () => { + const error = new SbomError('x', EMPTY_SBOM_ERROR_CODE); + expect(getSbomErrorMessage(error, true)).toMatch(/file doesn't contain any SBOM data/); + expect(getSbomErrorMessage(error, false)).toMatch(/generated SBOM contained no data/); + }); + + it('tailors SBOM_MISSING_COMPONENTS wording for user-provided vs generated SBOMs', () => { + const error = new SbomError('x', SBOM_MISSING_COMPONENTS_ERROR_CODE); + expect(getSbomErrorMessage(error, true)).toBe(SBOM_ERROR_MESSAGES[SBOM_MISSING_COMPONENTS_ERROR_CODE]); + expect(getSbomErrorMessage(error, false)).toMatch(/generated SBOM has no packages/); + }); + + it('tailors SBOM_NO_IDENTIFIABLE_COMPONENTS wording for user-provided vs generated SBOMs', () => { + const error = new SbomError('x', SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE); + expect(getSbomErrorMessage(error, true)).toMatch(/run the scan without --file/); + expect(getSbomErrorMessage(error, false)).toBe(SBOM_ERROR_MESSAGES[SBOM_NO_IDENTIFIABLE_COMPONENTS_ERROR_CODE]); + }); + + it('interpolates the purl for INVALID_PURL and hints CLI generation for user-provided SBOMs', () => { + const withPurl = new SbomError('x', INVALID_PURL_ERROR_CODE, { purl: 'pkg:npm/@@bad' }); + expect(getSbomErrorMessage(withPurl, true)).toContain('pkg:npm/@@bad'); + expect(getSbomErrorMessage(withPurl, true)).toMatch(/run the scan without --file/); + + // Generated SBOM (no --file) omits the CLI-generation hint and, without a purl, + // matches the canonical base copy. + const withoutPurl = new SbomError('x', INVALID_PURL_ERROR_CODE); + expect(getSbomErrorMessage(withoutPurl, false)).toBe(SBOM_ERROR_MESSAGES[INVALID_PURL_ERROR_CODE]); + expect(getSbomErrorMessage(withoutPurl, false)).not.toMatch(/run the scan without --file/); + }); +});