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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SbomErrorCode>(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;
}
}
20 changes: 19 additions & 1 deletion src/api/nes.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,6 +20,20 @@ function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): 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<GraphQLFormattedError>): 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;
Expand All @@ -40,6 +54,10 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
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);
Expand Down
70 changes: 69 additions & 1 deletion src/commands/scan/eol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<SbomErrorCode, string> = {
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;
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 65 additions & 1 deletion test/api/nes.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand Down
93 changes: 91 additions & 2 deletions test/commands/scan/eol.analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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/);
});
});
Loading