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
47 changes: 46 additions & 1 deletion src/lib/snyk-test/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,51 @@ export async function printEffectiveDepGraph(
});
}

/**
* printEffectiveDepGraphError writes an error output for failed dependency graph resolution
* to the destination stream in a format consistent with printEffectiveDepGraph.
* This is used when --print-effective-graph-with-errors is set but dependency resolution failed.
*/
export async function printEffectiveDepGraphError(
errorTitle: string,
errorDetail: string,
normalisedTargetFile: string | undefined,
destination: Writable,
): Promise<void> {
return new Promise((res, rej) => {
const effectiveGraphErrorOutput = {
error: {
id: 'SNYK-CLI-0000',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is there a reason why we do always use this generic error code? Why don't we try to use additional information from the original error instance we received from the plugins. Choosing this hardcoded value here will make it more and more difficult to correctly report on errors in the future. It would even be better if the string would be empty by default, so that the decision of mapping it to generic can be done somewhere else.

I hope this makes sense. If you want we can chat a bit more. Maybe look at the CustomError on how we made it possible to transparently handle error catalog errors if available.

title: errorTitle,
detail: errorDetail,
},
normalisedTargetFile,
};

new ConcatStream(
new JsonStreamStringify(effectiveGraphErrorOutput),
Readable.from('\n'),
)
.on('end', res)
.on('error', rej)
.pipe(destination);
});
}

/**
* Checks if either --print-effective-graph or --print-effective-graph-with-errors is set.
*/
export function shouldPrintEffectiveDepGraph(opts: Options): boolean {
return !!opts['print-effective-graph'];
return (
!!opts['print-effective-graph'] ||
shouldPrintEffectiveDepGraphWithErrors(opts)
Comment on lines +170 to +171
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shouldPrintEffectiveDepGraph() check is used in multiple places and should remain true for both --print-effective-graph and --print-effective-graph-with-errors

);
}

/**
* shouldPrintEffectiveDepGraphWithErrors checks if the --print-effective-graph-with-errors flag is set.
* This is used to determine if the effective dep-graph with errors should be printed.
*/
export function shouldPrintEffectiveDepGraphWithErrors(opts: Options): boolean {
return !!opts['print-effective-graph-with-errors'];
}
22 changes: 22 additions & 0 deletions src/lib/snyk-test/run-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ import {
RETRY_DELAY,
printDepGraph,
printEffectiveDepGraph,
printEffectiveDepGraphError,
assembleQueryString,
shouldPrintDepGraph,
shouldPrintEffectiveDepGraph,
shouldPrintEffectiveDepGraphWithErrors,
} from './common';
import config from '../config';
import * as analytics from '../analytics';
Expand Down Expand Up @@ -667,6 +669,26 @@ async function assembleLocalPayloads(
'getDepsFromPlugin returned failed results, cannot run test/monitor',
failedResults,
);

if (shouldPrintEffectiveDepGraphWithErrors(options)) {
for (const failed of failedResults) {
// Normalize the target file path to be relative to root, consistent with printEffectiveDepGraph
const normalisedTargetFile = failed.targetFile
? path.relative(root, failed.targetFile)
: failed.targetFile;
const errorTitle = normalisedTargetFile
? `Failed to resolve dependencies for project ${normalisedTargetFile}`
: 'Failed to resolve dependencies for project';

await printEffectiveDepGraphError(
errorTitle,
failed.errMessage,
normalisedTargetFile,
process.stdout,
);
}
}

if (options['fail-fast']) {
// should include failure message if applicable
const message = errorMessages.length
Expand Down
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface Options {
'print-tree'?: boolean;
'print-dep-paths'?: boolean;
'print-effective-graph'?: boolean;
'print-effective-graph-with-errors'?: boolean;
'remote-repo-url'?: string;
criticality?: string;
scanAllUnmanaged?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
invalid-json-syntax
"name": "invalid-project",
"version": "1.0.0"
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "valid-project",
"version": "1.0.0",
"description": "A valid npm project for testing",
"main": "index.js"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "with-vulnerable-lodash-dep",
"version": "1.2.3",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"license": "ISC",
"dependencies": {
"lodash": "4.17.15"
}
}

Large diffs are not rendered by default.

227 changes: 227 additions & 0 deletions test/jest/acceptance/print-effective-dep-graph-with-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { fakeServer } from '../../acceptance/fake-server';
import { createProjectFromFixture } from '../util/createProject';
import { runSnykCLI } from '../util/runSnykCLI';
import { getServerPort } from '../util/getServerPort';

jest.setTimeout(1000 * 30);

describe('`test` command with `--print-effective-graph-with-errors` option', () => {
let server;
let env: Record<string, string>;

beforeAll((done) => {
const port = getServerPort(process);
const baseApi = '/api/v1';
env = {
...process.env,
SNYK_API: 'http://localhost:' + port + baseApi,
SNYK_HOST: 'http://localhost:' + port,
SNYK_TOKEN: '123456789',
SNYK_INTEGRATION_NAME: 'JENKINS',
SNYK_INTEGRATION_VERSION: '1.2.3',
};
server = fakeServer(baseApi, env.SNYK_TOKEN);
server.listen(port, () => {
done();
});
});

afterEach(() => {
server.restore();
});

afterAll((done) => {
server.close(() => {
done();
});
});

it('works for project with no deps', async () => {
const project = await createProjectFromFixture('print-graph-no-deps');
const { code, stdout } = await runSnykCLI(
'test --print-effective-graph-with-errors',
{
cwd: project.path(),
env,
},
);

expect(code).toEqual(0);

const jsonOutput = JSON.parse(stdout);

expect(jsonOutput.normalisedTargetFile).toBe('package.json');
expect(jsonOutput.depGraph).toMatchObject({
pkgManager: {
name: 'npm',
},
pkgs: [
{
id: '[email protected]',
info: {
name: 'print-graph-no-deps',
version: '1.0.0',
},
},
],
graph: {
rootNodeId: 'root-node',
nodes: [
{
nodeId: 'root-node',
pkgId: '[email protected]',
deps: [],
},
],
},
});
});

it('successfully outputs dep graph for single project success', async () => {
const project = await createProjectFromFixture(
'npm/with-vulnerable-lodash-dep',
);
server.setCustomResponse(
await project.readJSON('test-dep-graph-result.json'),
);
const { code, stdout } = await runSnykCLI(
'test --print-effective-graph-with-errors',
{
cwd: project.path(),
env,
},
);

expect(code).toEqual(0);

const jsonOutput = JSON.parse(stdout);

expect(jsonOutput.normalisedTargetFile).toBe('package-lock.json');

expect(jsonOutput.depGraph).toMatchObject({
pkgManager: {
name: 'npm',
},
pkgs: [
{
id: '[email protected]',
info: {
name: 'with-vulnerable-lodash-dep',
version: '1.2.3',
},
},
{
id: '[email protected]',
info: {
name: 'lodash',
version: '4.17.15',
},
},
],
graph: {
rootNodeId: 'root-node',
nodes: [
{
nodeId: 'root-node',
pkgId: '[email protected]',
deps: [
{
nodeId: '[email protected]',
},
],
},
{
nodeId: '[email protected]',
pkgId: '[email protected]',
deps: [],
info: {
labels: {
scope: 'prod',
},
},
},
],
},
});
});

it('outputs both error JSON and dep graphs for mixed success/failure with --all-projects', async () => {
const project = await createProjectFromFixture(
'print-graph-mixed-success-failure',
);
server.setCustomResponse(
await project.readJSON('valid-project/test-dep-graph-result.json'),
);
const { code, stdout, stderr } = await runSnykCLI(
'test --all-projects --print-effective-graph-with-errors',
{
cwd: project.path(),
env,
},
);

// Should succeed (exit code 0) because at least one project succeeded
// Or exit code 2 if the error propagates
expect([0, 2]).toContain(code);

// Parse JSONL output
const lines = stdout
.trim()
.split('\n')
.filter((line) => line.trim());

const jsonObjects: any[] = [];
for (const line of lines) {
try {
jsonObjects.push(JSON.parse(line));
} catch {
// Skip non-JSON lines
}
}

// Should have at least one output (either success or error)
expect(jsonObjects.length).toBeGreaterThan(0);

// Find error outputs from printEffectiveDepGraphError (has error.id field)
const errorOutputs = jsonObjects.filter(
(obj) =>
obj.error !== undefined &&
obj.error.id === 'SNYK-CLI-0000' &&
obj.normalisedTargetFile !== undefined,
);
// Find success outputs (dep graphs)
const successOutputs = jsonObjects.filter(
(obj) => obj.depGraph !== undefined,
);

// Should have at least one error output for the invalid project
expect(errorOutputs.length).toBeGreaterThanOrEqual(1);

// Validate error output structure
for (const errorOutput of errorOutputs) {
expect(errorOutput.error).toHaveProperty('id', 'SNYK-CLI-0000');
expect(errorOutput.error).toHaveProperty('title');
expect(errorOutput.error).toHaveProperty('detail');
expect(errorOutput).toHaveProperty('normalisedTargetFile');
expect(errorOutput.error.title).toMatch(
/^Failed to resolve dependencies for project/,
);
expect(errorOutput.error.title).toContain(
errorOutput.normalisedTargetFile,
);
}

// Should have at least one success output for the valid project
expect(successOutputs.length).toBeGreaterThanOrEqual(1);

// Validate success output structure
for (const successOutput of successOutputs) {
expect(successOutput).toHaveProperty('depGraph');
expect(successOutput).toHaveProperty('normalisedTargetFile');
expect(successOutput.depGraph).toHaveProperty('pkgManager');
}

// stderr should contain the failure warning
expect(stderr).toMatch(/failed to get dependencies/i);
});
});