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
1 change: 1 addition & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export function args(rawArgv: string[]): Args {
'include-provenance',
'fingerprint-algorithm',
'detection-depth',
'exclude-relative',
'init-script',
'integration-name',
'integration-version',
Expand Down
20 changes: 20 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { IaCErrorCodes } from './commands/test/iac/local-execution/types';
import stripAnsi = require('strip-ansi');
import { ExcludeFlagInvalidInputError } from '../lib/errors/exclude-flag-invalid-input';
import { ExcludeRelativeFlagInvalidInputError } from '../lib/errors/exclude-relative-flag-invalid-input';
import { modeValidation } from './modes';
import { JsonFileOutputBadInputError } from '../lib/errors/json-file-output-bad-input-error';
import {
Expand Down Expand Up @@ -446,6 +447,25 @@ function validateUnsupportedOptionCombinations(
throw new ExcludeFlagInvalidInputError();
}
}

if (options.excludeRelative) {
if (!(options.allProjects || options.yarnWorkspaces)) {
throw new MissingOptionError('--exclude-relative', [
'--yarn-workspaces',
'--all-projects',
]);
}
if (typeof options.excludeRelative !== 'string') {
throw new ExcludeRelativeFlagInvalidInputError();
}
const paths = options.excludeRelative.split(',').map((p) => p.trim());
for (const p of paths) {
const normalized = pathLib.normalize(p);
if (pathLib.isAbsolute(normalized) || normalized.startsWith('..')) {
throw new ExcludeRelativeFlagInvalidInputError();
}
}
}
}

function validateUnsupportedSarifCombinations(args) {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/errors/exclude-relative-flag-invalid-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { CLI } from '@snyk/error-catalog-nodejs-public';
import { CustomError } from './custom-error';

export class ExcludeRelativeFlagInvalidInputError extends CustomError {
private static ERROR_CODE = 422;
private static ERROR_MESSAGE =
'The --exclude-relative argument must be a comma separated list of relative file or directory paths. Absolute paths and parent directory references (..) are not allowed.';

constructor() {
super(ExcludeRelativeFlagInvalidInputError.ERROR_MESSAGE);
this.code = ExcludeRelativeFlagInvalidInputError.ERROR_CODE;
this.userMessage = ExcludeRelativeFlagInvalidInputError.ERROR_MESSAGE;
this.errorCatalog = new CLI.InvalidFlagOptionError('');
}
}
1 change: 1 addition & 0 deletions src/lib/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { FeatureNotSupportedForOrgError } from './unsupported-feature-for-org-er
export { MissingOptionError } from './missing-option-error';
export { MissingArgError } from './missing-arg-error';
export { ExcludeFlagBadInputError } from './exclude-flag-bad-input';
export { ExcludeRelativeFlagInvalidInputError } from './exclude-relative-flag-invalid-input';
export { UnsupportedOptionCombinationError } from './unsupported-option-combination-error';
export { FeatureNotSupportedByPackageManagerError } from './feature-not-supported-by-package-manager-error';
export { DockerImageNotFoundError } from './docker-image-not-found-error';
Expand Down
29 changes: 21 additions & 8 deletions src/lib/find-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const ignoreFolders = ['node_modules', '.build'];
interface FindFilesConfig {
path: string;
ignore?: string[];
excludePaths?: string[];
filter?: string[];
levelsDeep?: number;
featureFlags?: Set<string>;
Expand All @@ -62,6 +63,7 @@ interface FindFilesConfig {
type DefaultFindConfig = {
path: string;
ignore: string[];
excludePaths: string[];
filter: string[];
levelsDeep: number;
featureFlags: Set<string>;
Expand All @@ -70,6 +72,7 @@ type DefaultFindConfig = {
const defaultFindConfig: DefaultFindConfig = {
path: '',
ignore: [],
excludePaths: [],
filter: [],
levelsDeep: 4,
featureFlags: new Set<string>(),
Expand Down Expand Up @@ -137,6 +140,17 @@ export async function find(findConfig: FindFilesConfig): Promise<FindFilesRes> {
}
}

function isExcludedPath(resolvedPath: string, excludePaths: string[]): boolean {
if (excludePaths.length === 0) {
return false;
}
if (process.platform === 'win32') {
const lowerPath = resolvedPath.toLowerCase();
return excludePaths.some((ep) => ep.toLowerCase() === lowerPath);
}
return excludePaths.includes(resolvedPath);
}

function findFile(path: string, filter: string[] = []): string | null {
if (filter.length > 0) {
const filename = pathLib.basename(path);
Expand All @@ -156,17 +170,16 @@ async function findInDirectory(
const files = await readDirectory(config.path);
const toFind = files
.filter((file) => !config.ignore.includes(file))
.map((file) => {
const resolvedPath = pathLib.resolve(config.path, file);
.map((file) => pathLib.resolve(config.path, file))
.filter(
(resolvedPath) => !isExcludedPath(resolvedPath, config.excludePaths),
)
.map((resolvedPath) => {
if (!fs.existsSync(resolvedPath)) {
debug('File does not seem to exist, skipping: ', file);
debug('File does not seem to exist, skipping: ', resolvedPath);
return { files: [], allFilesFound: [] };
}
const findconfig = {
...config,
path: resolvedPath,
};
return find(findconfig);
return find({ ...config, path: resolvedPath });
});

const found = await Promise.all(toFind);
Expand Down
19 changes: 19 additions & 0 deletions src/lib/plugins/get-deps-from-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,16 @@ export async function getDepsFromPlugin(
const scanType = options.yarnWorkspaces ? 'yarnWorkspaces' : 'allProjects';
const levelsDeep = options.detectionDepth;
const ignore = options.exclude ? options.exclude.split(',') : [];
const excludePaths = options.excludeRelative
? options.excludeRelative
.split(',')
.map((p) => pathLib.resolve(root, p.trim()))
: [];

const { files: targetFiles, allFilesFound } = await find({
path: root,
ignore,
excludePaths,
filter: multiProjectProcessors[scanType].files,
featureFlags,
levelsDeep,
Expand All @@ -68,6 +74,18 @@ export async function getDepsFromPlugin(
targetFiles,
featureFlags,
);

if (excludePaths.length > 0) {
inspectRes.scannedProjects = inspectRes.scannedProjects.filter(
(project) => {
const targetFile = project.meta?.targetFile || project.targetFile;
if (!targetFile) return true;
const resolved = pathLib.resolve(root, targetFile);
return !excludePaths.includes(resolved);
},
);
}

const scannedProjects = inspectRes.scannedProjects;
const analyticData = {
scannedProjects: scannedProjects.length,
Expand All @@ -77,6 +95,7 @@ export async function getDepsFromPlugin(
),
levelsDeep,
ignore,
excludePaths,
};
analytics.add(scanType, analyticData);
debug(
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface Options {
allProjects?: boolean;
detectionDepth?: number;
exclude?: string;
excludeRelative?: string;
strictOutOfSync?: boolean;
// Used only with the IaC mode & Docker plugin. Allows requesting some experimental/unofficial features.
experimental?: boolean;
Expand Down Expand Up @@ -288,6 +289,7 @@ export type SupportedUserReachableFacingCliArgs =
| 'maven-skip-wrapper'
| 'include-provenance'
| 'fingerprint-algorithm'
| 'exclude-relative'
| 'gradle-normalize-deps';

export enum SupportedCliCommands {
Expand Down
39 changes: 39 additions & 0 deletions test/jest/acceptance/cli-args.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,45 @@ describe.each(userJourneyWorkflows)(
expect(code).toEqual(2);
});

test('snyk test --exclude-relative without --all-projects displays error message', async () => {
const { code, stdout } = await runSnykCLI(
`test --exclude-relative=packages/api/package.json`,
{
env,
},
);
expect(stdout).toContainText(
'The --exclude-relative option can only be used in combination with --all-projects or --yarn-workspaces.',
);
expect(code).toEqual(2);
});

test('snyk test --exclude-relative with absolute path displays error message', async () => {
const { code, stdout } = await runSnykCLI(
`test --all-projects --exclude-relative=/absolute/path/file.json`,
{
env,
},
);
expect(stdout).toContainText(
'The --exclude-relative argument must be a comma separated list of relative file or directory paths.',
);
expect(code).toEqual(2);
});

test('snyk test --exclude-relative with parent traversal displays error message', async () => {
const { code, stdout } = await runSnykCLI(
`test --all-projects --exclude-relative=../escape/package.json`,
{
env,
},
);
expect(stdout).toContainText(
'The --exclude-relative argument must be a comma separated list of relative file or directory paths.',
);
expect(code).toEqual(2);
});

test('snyk iac test --exclude=path/to/dir displays error message', async () => {
const exclude = path.normalize('path/to/dir');
const { code, stdout } = await runSnykCLI(
Expand Down
73 changes: 73 additions & 0 deletions test/jest/acceptance/snyk-test/all-projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,79 @@ describe('snyk test --all-projects (mocked server only)', () => {
expect(code).toEqual(0);
});

test('`test pnpm-workspace --all-projects --exclude-relative=shared/package.json` excludes only the specified file', async () => {
server.setFeatureFlag('enablePnpmCli', true);

const project = await createProjectFromFixture(
'pnpm-workspace-with-exclude-issue/workspace',
);

const { code, stdout } = await runSnykCLI(
'test --all-projects --exclude-relative=shared/package.json',
{
cwd: project.path(),
env,
},
);

const backendRequests = server.getRequests().filter((req: any) => {
return req.url.includes('/api/v1/test');
});

expect(backendRequests.length).toBe(3);
expect(stdout).not.toMatch(join('shared', 'package.json'));
expect(stdout).toMatch(join('app1', 'package.json'));
expect(stdout).toMatch(join('app2', 'package.json'));
expect(code).toEqual(0);
});

test('`test pnpm-workspace --all-projects --exclude-relative` with multiple paths excludes all specified files', async () => {
server.setFeatureFlag('enablePnpmCli', true);

const project = await createProjectFromFixture(
'pnpm-workspace-with-exclude-issue/workspace',
);

const { code, stdout } = await runSnykCLI(
'test --all-projects --exclude-relative=shared/package.json,app2/package.json',
{
cwd: project.path(),
env,
},
);

const backendRequests = server.getRequests().filter((req: any) => {
return req.url.includes('/api/v1/test');
});

expect(backendRequests.length).toBe(2);
expect(stdout).not.toMatch(join('shared', 'package.json'));
expect(stdout).not.toMatch(join('app2', 'package.json'));
expect(stdout).toMatch(join('app1', 'package.json'));
expect(code).toEqual(0);
});

test('`test pnpm-workspace --all-projects --exclude-relative=shared/package.json` does not affect other package.json files', async () => {
server.setFeatureFlag('enablePnpmCli', true);

const project = await createProjectFromFixture(
'pnpm-workspace-with-exclude-issue/workspace',
);

const { code, stdout } = await runSnykCLI(
'test --all-projects --exclude-relative=shared/package.json',
{
cwd: project.path(),
env,
},
);

expect(stdout).toMatch('package.json');
expect(stdout).toMatch(join('app1', 'package.json'));
expect(stdout).toMatch(join('app2', 'package.json'));
expect(code).toEqual(0);
});

test('`test pnpm-workspace --all-projects --exclude=shared --detection-depth=2` excludes specified directory', async () => {
server.setFeatureFlag('enablePnpmCli', true);

Expand Down
54 changes: 54 additions & 0 deletions test/tap/find-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,57 @@ test('find returns a single valid manifest after filtering', async (t) => {
const expected = [path.join(mavenPath, 'pom.xml')];
t.same(result, expected, 'should return the single manifest');
});

test('find excludes specific files by absolute path via excludePaths', async (t) => {
const npmPackageJson = path.join(testFixture, 'npm', 'package.json');
const { files: result } = await find({
path: testFixture,
filter: ['package.json'],
excludePaths: [npmPackageJson],
levelsDeep: 6,
});
const expected = [
path.join(testFixture, 'npm-with-lockfile', 'package.json'),
path.join(testFixture, 'yarn', 'package.json'),
];
t.same(
result.sort(),
expected.sort(),
'should exclude only the specified file',
);
});

test('find excludePaths does not affect files with the same basename at different paths', async (t) => {
const yarnPackageJson = path.join(testFixture, 'yarn', 'package.json');
const { files: result } = await find({
path: testFixture,
filter: ['package.json'],
excludePaths: [yarnPackageJson],
levelsDeep: 6,
});
t.ok(
result.includes(path.join(testFixture, 'npm', 'package.json')),
'should still include npm/package.json',
);
t.ok(
result.includes(
path.join(testFixture, 'npm-with-lockfile', 'package.json'),
),
'should still include npm-with-lockfile/package.json',
);
t.notOk(result.includes(yarnPackageJson), 'should exclude yarn/package.json');
});

test('find excludePaths can exclude directories', async (t) => {
const npmDir = path.join(testFixture, 'npm');
const { files: result } = await find({
path: testFixture,
filter: ['package.json'],
excludePaths: [npmDir],
levelsDeep: 6,
});
t.notOk(
result.includes(path.join(testFixture, 'npm', 'package.json')),
'should not include files from excluded directory',
);
});
Loading