diff --git a/src/cli/args.ts b/src/cli/args.ts index 49816d776e..5fc6c4290a 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -219,6 +219,7 @@ export function args(rawArgv: string[]): Args { 'include-provenance', 'fingerprint-algorithm', 'detection-depth', + 'exclude-relative', 'init-script', 'integration-name', 'integration-version', diff --git a/src/cli/main.ts b/src/cli/main.ts index b36292e677..546fccf277 100755 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -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 { @@ -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) { diff --git a/src/lib/errors/exclude-relative-flag-invalid-input.ts b/src/lib/errors/exclude-relative-flag-invalid-input.ts new file mode 100644 index 0000000000..db32e5138f --- /dev/null +++ b/src/lib/errors/exclude-relative-flag-invalid-input.ts @@ -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(''); + } +} diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index 205f3bb38a..cbb09b067c 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -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'; diff --git a/src/lib/find-files.ts b/src/lib/find-files.ts index 3ed7d6d12a..70977e5e71 100644 --- a/src/lib/find-files.ts +++ b/src/lib/find-files.ts @@ -54,6 +54,7 @@ const ignoreFolders = ['node_modules', '.build']; interface FindFilesConfig { path: string; ignore?: string[]; + excludePaths?: string[]; filter?: string[]; levelsDeep?: number; featureFlags?: Set; @@ -62,6 +63,7 @@ interface FindFilesConfig { type DefaultFindConfig = { path: string; ignore: string[]; + excludePaths: string[]; filter: string[]; levelsDeep: number; featureFlags: Set; @@ -70,6 +72,7 @@ type DefaultFindConfig = { const defaultFindConfig: DefaultFindConfig = { path: '', ignore: [], + excludePaths: [], filter: [], levelsDeep: 4, featureFlags: new Set(), @@ -137,6 +140,17 @@ export async function find(findConfig: FindFilesConfig): Promise { } } +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); @@ -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); diff --git a/src/lib/plugins/get-deps-from-plugin.ts b/src/lib/plugins/get-deps-from-plugin.ts index 4873571d24..881024339b 100644 --- a/src/lib/plugins/get-deps-from-plugin.ts +++ b/src/lib/plugins/get-deps-from-plugin.ts @@ -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, @@ -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, @@ -77,6 +95,7 @@ export async function getDepsFromPlugin( ), levelsDeep, ignore, + excludePaths, }; analytics.add(scanType, analyticData); debug( diff --git a/src/lib/types.ts b/src/lib/types.ts index d87db84e70..606a4f5078 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; @@ -288,6 +289,7 @@ export type SupportedUserReachableFacingCliArgs = | 'maven-skip-wrapper' | 'include-provenance' | 'fingerprint-algorithm' + | 'exclude-relative' | 'gradle-normalize-deps'; export enum SupportedCliCommands { diff --git a/test/jest/acceptance/cli-args.spec.ts b/test/jest/acceptance/cli-args.spec.ts index 08420f33b6..c46fde0dfc 100644 --- a/test/jest/acceptance/cli-args.spec.ts +++ b/test/jest/acceptance/cli-args.spec.ts @@ -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( diff --git a/test/jest/acceptance/snyk-test/all-projects.spec.ts b/test/jest/acceptance/snyk-test/all-projects.spec.ts index 1bbbf8266d..159d09333f 100644 --- a/test/jest/acceptance/snyk-test/all-projects.spec.ts +++ b/test/jest/acceptance/snyk-test/all-projects.spec.ts @@ -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); diff --git a/test/tap/find-files.test.ts b/test/tap/find-files.test.ts index fa1c6305fc..538d2f2057 100644 --- a/test/tap/find-files.test.ts +++ b/test/tap/find-files.test.ts @@ -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', + ); +});