From 42fc06984b4b48971aae7e918f52dae4da26ac4e Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Wed, 22 Apr 2026 16:56:55 +0100 Subject: [PATCH 1/5] feat: add --exclude-relative flag for path-based file exclusion The existing --exclude flag only accepts basenames, which causes collateral exclusion in workspace-style projects where multiple files share the same name (e.g. package.json). This adds --exclude-relative to allow comma-separated relative paths for precise per-file exclusion in --all-projects and --yarn-workspaces scans. Made-with: Cursor --- src/cli/args.ts | 1 + src/cli/main.ts | 20 +++++ .../exclude-relative-flag-invalid-input.ts | 15 ++++ src/lib/errors/index.ts | 1 + src/lib/find-files.ts | 7 ++ src/lib/plugins/get-deps-from-plugin.ts | 7 ++ src/lib/types.ts | 2 + test/jest/acceptance/cli-args.spec.ts | 39 ++++++++++ .../acceptance/snyk-test/all-projects.spec.ts | 73 +++++++++++++++++++ test/tap/find-files.test.ts | 51 +++++++++++++ 10 files changed, 216 insertions(+) create mode 100644 src/lib/errors/exclude-relative-flag-invalid-input.ts 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..ba768d1db6 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(','); + 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..6cb5ffdedc 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(), @@ -156,6 +159,10 @@ async function findInDirectory( const files = await readDirectory(config.path); const toFind = files .filter((file) => !config.ignore.includes(file)) + .filter((file) => { + const resolvedPath = pathLib.resolve(config.path, file); + return !config.excludePaths.includes(resolvedPath); + }) .map((file) => { const resolvedPath = pathLib.resolve(config.path, file); if (!fs.existsSync(resolvedPath)) { diff --git a/src/lib/plugins/get-deps-from-plugin.ts b/src/lib/plugins/get-deps-from-plugin.ts index 4873571d24..c558957a2a 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)) + : []; const { files: targetFiles, allFilesFound } = await find({ path: root, ignore, + excludePaths, filter: multiProjectProcessors[scanType].files, featureFlags, levelsDeep, @@ -77,6 +83,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..34d608c15a 100644 --- a/test/tap/find-files.test.ts +++ b/test/tap/find-files.test.ts @@ -273,3 +273,54 @@ 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', + ); +}); From 53c169a26b8efd16ff4cedd703142ad720c81966 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Thu, 23 Apr 2026 11:46:16 +0100 Subject: [PATCH 2/5] fix: resolve Prettier formatting issues in modified files --- src/lib/plugins/get-deps-from-plugin.ts | 4 +--- test/tap/find-files.test.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib/plugins/get-deps-from-plugin.ts b/src/lib/plugins/get-deps-from-plugin.ts index c558957a2a..19381f0df6 100644 --- a/src/lib/plugins/get-deps-from-plugin.ts +++ b/src/lib/plugins/get-deps-from-plugin.ts @@ -46,9 +46,7 @@ export async function getDepsFromPlugin( const levelsDeep = options.detectionDepth; const ignore = options.exclude ? options.exclude.split(',') : []; const excludePaths = options.excludeRelative - ? options.excludeRelative - .split(',') - .map((p) => pathLib.resolve(root, p)) + ? options.excludeRelative.split(',').map((p) => pathLib.resolve(root, p)) : []; const { files: targetFiles, allFilesFound } = await find({ diff --git a/test/tap/find-files.test.ts b/test/tap/find-files.test.ts index 34d608c15a..538d2f2057 100644 --- a/test/tap/find-files.test.ts +++ b/test/tap/find-files.test.ts @@ -286,7 +286,11 @@ test('find excludes specific files by absolute path via excludePaths', async (t) 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'); + 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) => { @@ -302,13 +306,12 @@ test('find excludePaths does not affect files with the same basename at differen 'should still include npm/package.json', ); t.ok( - result.includes(path.join(testFixture, 'npm-with-lockfile', 'package.json')), + 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', - ); + t.notOk(result.includes(yarnPackageJson), 'should exclude yarn/package.json'); }); test('find excludePaths can exclude directories', async (t) => { From 8fcb1f669a7682efbc3426f0602603498fc29a30 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Thu, 23 Apr 2026 13:22:00 +0100 Subject: [PATCH 3/5] fix: address review feedback on exclude-relative - Resolve path once in findInDirectory instead of twice - Add case-insensitive path comparison for Windows - Trim whitespace from comma-separated exclude-relative paths - Fix Prettier formatting --- src/cli/main.ts | 2 +- src/lib/find-files.ts | 30 +++++++++++++++---------- src/lib/plugins/get-deps-from-plugin.ts | 4 +++- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index ba768d1db6..546fccf277 100755 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -458,7 +458,7 @@ function validateUnsupportedOptionCombinations( if (typeof options.excludeRelative !== 'string') { throw new ExcludeRelativeFlagInvalidInputError(); } - const paths = options.excludeRelative.split(','); + 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('..')) { diff --git a/src/lib/find-files.ts b/src/lib/find-files.ts index 6cb5ffdedc..70977e5e71 100644 --- a/src/lib/find-files.ts +++ b/src/lib/find-files.ts @@ -140,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); @@ -159,21 +170,16 @@ async function findInDirectory( const files = await readDirectory(config.path); const toFind = files .filter((file) => !config.ignore.includes(file)) - .filter((file) => { - const resolvedPath = pathLib.resolve(config.path, file); - return !config.excludePaths.includes(resolvedPath); - }) - .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 19381f0df6..94545d998d 100644 --- a/src/lib/plugins/get-deps-from-plugin.ts +++ b/src/lib/plugins/get-deps-from-plugin.ts @@ -46,7 +46,9 @@ export async function getDepsFromPlugin( const levelsDeep = options.detectionDepth; const ignore = options.exclude ? options.exclude.split(',') : []; const excludePaths = options.excludeRelative - ? options.excludeRelative.split(',').map((p) => pathLib.resolve(root, p)) + ? options.excludeRelative + .split(',') + .map((p) => pathLib.resolve(root, p.trim())) : []; const { files: targetFiles, allFilesFound } = await find({ From 8bfb1e0022e3889ff2cbaffc092a2e5c220ea0d4 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Thu, 23 Apr 2026 14:01:34 +0100 Subject: [PATCH 4/5] fix: filter excluded projects discovered by workspace parsers Workspace processors (e.g. processPnpmWorkspaces) read pnpm-workspace.yaml directly and discover all workspace members, bypassing the --exclude-relative file-level exclusion applied during find(). Apply excludePaths as a post-filter on scannedProjects so projects matched by --exclude-relative are consistently removed. Made-with: Cursor --- src/lib/plugins/get-deps-from-plugin.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/lib/plugins/get-deps-from-plugin.ts b/src/lib/plugins/get-deps-from-plugin.ts index 94545d998d..a7e5557027 100644 --- a/src/lib/plugins/get-deps-from-plugin.ts +++ b/src/lib/plugins/get-deps-from-plugin.ts @@ -74,6 +74,19 @@ 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, From 6789d22a2b576a921a9d633dc7c82ae9b9624465 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Thu, 23 Apr 2026 15:15:38 +0100 Subject: [PATCH 5/5] fix: resolve Prettier formatting in get-deps-from-plugin Made-with: Cursor --- src/lib/plugins/get-deps-from-plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/plugins/get-deps-from-plugin.ts b/src/lib/plugins/get-deps-from-plugin.ts index a7e5557027..881024339b 100644 --- a/src/lib/plugins/get-deps-from-plugin.ts +++ b/src/lib/plugins/get-deps-from-plugin.ts @@ -78,8 +78,7 @@ export async function getDepsFromPlugin( if (excludePaths.length > 0) { inspectRes.scannedProjects = inspectRes.scannedProjects.filter( (project) => { - const targetFile = - project.meta?.targetFile || project.targetFile; + const targetFile = project.meta?.targetFile || project.targetFile; if (!targetFile) return true; const resolved = pathLib.resolve(root, targetFile); return !excludePaths.includes(resolved);