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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/api/apollo.client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core';
import { requireAccessTokenForScan } from '../service/auth.svc.ts';
import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } from './errors.ts';

export type TokenProvider = (forceRefresh?: boolean) => Promise<string>;

Expand Down Expand Up @@ -42,7 +43,15 @@ const createAuthorizedFetch =
const refreshed = await tokenProvider(true);
const retryHeaders = new Headers(init?.headers);
retryHeaders.set('Authorization', `Bearer ${refreshed}`);
return fetch(input, { ...init, headers: retryHeaders });
const retryResponse = await fetch(input, { ...init, headers: retryHeaders });
if (retryResponse.status === 413) {
throw new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE);
}
return retryResponse;
}

if (response.status === 413) {
throw new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE);
}

return response;
Expand Down
14 changes: 13 additions & 1 deletion src/api/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
const API_ERROR_CODES = ['SESSION_EXPIRED', 'INVALID_TOKEN', 'UNAUTHENTICATED', 'FORBIDDEN'] as const;
export const SESSION_EXPIRED_ERROR_CODE = 'SESSION_EXPIRED';
export const INVALID_TOKEN_ERROR_CODE = 'INVALID_TOKEN';
export const UNAUTHENTICATED_ERROR_CODE = 'UNAUTHENTICATED';
export const FORBIDDEN_ERROR_CODE = 'FORBIDDEN';
export const PAYLOAD_TOO_LARGE_ERROR_CODE = 'PAYLOAD_TOO_LARGE';

const API_ERROR_CODES = [
SESSION_EXPIRED_ERROR_CODE,
INVALID_TOKEN_ERROR_CODE,
UNAUTHENTICATED_ERROR_CODE,
FORBIDDEN_ERROR_CODE,
PAYLOAD_TOO_LARGE_ERROR_CODE,
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];

const VALID_API_ERROR_CODES = new Set<ApiErrorCode>(API_ERROR_CODES);
Expand Down
6 changes: 6 additions & 0 deletions src/api/nes.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
const errors = getGraphQLErrors(res);
if (res?.error || errors?.length) {
debugLogger('Error returned from createReport mutation: %o', res.error || errors);
if (res?.error instanceof ApiError) {
throw res.error;
}
if (errors?.length) {
const code = extractErrorCode(errors);
if (code) {
Expand Down Expand Up @@ -76,6 +79,9 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
const queryErrors = getGraphQLErrors(response);
if (response?.error || queryErrors?.length || !response.data?.eol) {
debugLogger('Error in getReport query response: %o', response?.error ?? queryErrors ?? response);
if (response?.error instanceof ApiError) {
throw response.error;
}
if (queryErrors?.length) {
const code = extractErrorCode(queryErrors);
if (code) {
Expand Down
15 changes: 14 additions & 1 deletion src/commands/scan/eol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared';
import { trimCdxBom } from '@herodevs/eol-shared';
import { Command, Flags } from '@oclif/core';
import ora from 'ora';
import { ApiError } from '../../api/errors.ts';
import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } 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 Down Expand Up @@ -233,6 +233,10 @@ export default class ScanEol extends Command {
number_of_packages: numberOfPackages,
}));

if (error.code === PAYLOAD_TOO_LARGE_ERROR_CODE) {
this.error(this.getPayloadTooLargeMessage(Boolean(flags.file)));
}

const message = AUTH_ERROR_MESSAGES[error.code] ?? error.message?.trim();
this.error(message);
}
Expand All @@ -253,6 +257,15 @@ export default class ScanEol extends Command {
return (performance.now() - scanStartTime) / 1000;
}

private getPayloadTooLargeMessage(hasUserProvidedSbom: boolean): string {
const USER_PROVIDED_SBOM_TOO_LARGE_MESSAGE =
'File exceeds the 10MB limit. Try providing a smaller or partial SBOM.';
const GENERATED_SBOM_TOO_LARGE_MESSAGE =
'Generated SBOM exceeds the 10MB upload limit. Try scanning a smaller scope (e.g. a single project or subdirectory).';

return hasUserProvidedSbom ? USER_PROVIDED_SBOM_TOO_LARGE_MESSAGE : GENERATED_SBOM_TOO_LARGE_MESSAGE;
}

private saveReport(report: EolReport, dir: string, outputPath?: string): string {
try {
return saveArtifactToFile(dir, { kind: 'report', payload: report, outputPath });
Expand Down
8 changes: 6 additions & 2 deletions src/service/cdx.svc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,22 @@ type CreateSbomDependencies = {
postProcess: typeof postProcess;
};

type BomGenerationResult = {
bomJson: CdxBom;
};

export function createSbomFactory({
createBom: createBomDependency = createBom,
postProcess: postProcessDependency = postProcess,
}: Partial<CreateSbomDependencies> = {}) {
return async function createSbom(directory: string): Promise<CdxBom> {
const sbom: any = await createBomDependency(directory, SBOM_DEFAULT__OPTIONS);
const sbom = (await createBomDependency(directory, SBOM_DEFAULT__OPTIONS)) as BomGenerationResult | undefined;

if (!sbom) {
throw new Error('SBOM not generated');
}

const postProcessedSbom: any = postProcessDependency(sbom, SBOM_DEFAULT__OPTIONS);
const postProcessedSbom = postProcessDependency(sbom, SBOM_DEFAULT__OPTIONS) as BomGenerationResult | undefined;

if (!postProcessedSbom) {
throw new Error('SBOM not generated');
Expand Down
50 changes: 50 additions & 0 deletions test/api/nes.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ 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 { 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';
import { FetchMock } from '../utils/mocks/fetch.mock.ts';

vi.mock('../../src/service/auth.svc.ts', () => ({
Expand Down Expand Up @@ -125,6 +127,54 @@ describe('nes.client', () => {
await expect(submitScan(input)).rejects.toThrow(/Failed to create EOL report/);
});

it('throws ApiError with PAYLOAD_TOO_LARGE code when server returns 413', async () => {
fetchMock.push({
headers: { get: () => 'text/plain' },
status: 413,
async text() {
return '';
},
} as unknown as Response);

const input: CreateEolReportInput = {
sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 },
};
const error = await submitScan(input).catch((e) => e);
expect(error).toBeInstanceOf(ApiError);
expect(error.code).toBe(PAYLOAD_TOO_LARGE_ERROR_CODE);
});

it('throws ApiError with PAYLOAD_TOO_LARGE when retry after 401 returns 413', async () => {
vi.mocked(requireAccessTokenForScan).mockReset();
vi.mocked(requireAccessTokenForScan)
.mockResolvedValueOnce('expired-token')
.mockResolvedValueOnce('refreshed-token');

fetchMock.push({
headers: { get: () => 'text/plain' },
status: 401,
async text() {
return '';
},
} as unknown as Response);
fetchMock.push({
headers: { get: () => 'text/plain' },
status: 413,
async text() {
return '';
},
} as unknown as Response);

const input: CreateEolReportInput = {
sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 },
};
const error = await submitScan(input).catch((e) => e);

expect(error).toBeInstanceOf(ApiError);
expect(error.code).toBe(PAYLOAD_TOO_LARGE_ERROR_CODE);
expect(vi.mocked(requireAccessTokenForScan).mock.calls).toEqual([[], [true]]);
});

describe('scanOrigin', () => {
it('passes scanOrigin to createReport mutation when provided', async () => {
const components = [{ purl: 'pkg:npm/test@1.0.0', metadata: { isEol: false } }];
Expand Down
49 changes: 45 additions & 4 deletions test/commands/scan/eol.analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CdxBom, EolReport } from '@herodevs/eol-shared';
import { ApiError } from '../../../src/api/errors.ts';
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';

const {
Expand Down Expand Up @@ -86,7 +87,7 @@ type ScanCommandInternals = {
};

function createCommand(): ScanCommandInternals {
return new ScanEol([], {} as Record<string, unknown>) as unknown as ScanCommandInternals;
return new ScanEol([], {} as Config) as unknown as ScanCommandInternals;
}

function getTrackProperties(eventName: string): Record<string, unknown> {
Expand Down Expand Up @@ -152,7 +153,7 @@ describe('scan:eol analytics timing', () => {
});

it('tracks scan_load_time on ApiError scan failures', async () => {
submitScanMock.mockRejectedValue(new ApiError('forbidden', 'FORBIDDEN'));
submitScanMock.mockRejectedValue(new ApiError('forbidden', FORBIDDEN_ERROR_CODE));

const command = createCommand();
vi.spyOn(command, 'parse').mockResolvedValue({
Expand All @@ -165,12 +166,52 @@ describe('scan:eol analytics timing', () => {
await expect(command.scanSbom(sampleSbom)).rejects.toThrow('You do not have permission to perform this action.');

const properties = getTrackProperties('CLI EOL Scan Failed');
expect(properties.scan_failure_reason).toBe('FORBIDDEN');
expect(properties.scan_failure_reason).toBe(FORBIDDEN_ERROR_CODE);
expect(properties.scan_load_time).toEqual(expect.any(Number));
expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0);
expect(properties.number_of_packages).toBe(1);
});

it('shows the user-supplied SBOM message on PAYLOAD_TOO_LARGE failures', async () => {
submitScanMock.mockRejectedValue(new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE));

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(
'File exceeds the 10MB limit. Try providing a smaller or partial SBOM.',
);
});

it('shows the generated SBOM message on PAYLOAD_TOO_LARGE failures', async () => {
submitScanMock.mockRejectedValue(new ApiError('Payload too large', PAYLOAD_TOO_LARGE_ERROR_CODE));

const command = createCommand();
vi.spyOn(command, 'parse').mockResolvedValue({
flags: {
automated: false,
saveTrimmedSbom: false,
dir: process.cwd(),
},
});
vi.spyOn(command, 'error').mockImplementation((message: string) => {
throw new Error(message);
});

await expect(command.scanSbom(sampleSbom)).rejects.toThrow(
'Generated SBOM exceeds the 10MB upload limit. Try scanning a smaller scope (e.g. a single project or subdirectory).',
);
});

it('keeps scan_load_time on successful completion events', async () => {
const command = createCommand();

Expand Down