diff --git a/.gitignore b/.gitignore index d324ab695..f66a8c8fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store ._.DS_Store +.Trash/ Thumbs.db /.env /.env.local diff --git a/src/commands/ci/handle-ci.mts b/src/commands/ci/handle-ci.mts index 34f72609e..d959e7988 100644 --- a/src/commands/ci/handle-ci.mts +++ b/src/commands/ci/handle-ci.mts @@ -51,6 +51,7 @@ export async function handleCi(autoManifest: boolean): Promise { pendingHead: true, pullRequest: 0, reach: { + excludePaths: [], reachAnalysisMemoryLimit: 0, reachAnalysisTimeout: 0, reachConcurrency: 1, diff --git a/src/commands/install/socket-completion.bash b/src/commands/install/socket-completion.bash index 4619cc7d8..5a486e6ef 100755 --- a/src/commands/install/socket-completion.bash +++ b/src/commands/install/socket-completion.bash @@ -125,12 +125,12 @@ FLAGS=( [repos update]="--default-branch --homepage --interactive --org --repo-description --repo-name --visibility" [repos view]="--interactive --org --repo-name" [scan]="" - [scan create]="--auto-manifest --branch --commit-hash --commit-message --committers --cwd --default-branch --interactive --json --markdown --org --pull-request --reach --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths --read-only --repo --report --set-as-alerts-page --tmp" + [scan create]="--auto-manifest --branch --commit-hash --commit-message --committers --cwd --default-branch --exclude-paths --interactive --json --markdown --org --pull-request --reach --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths --read-only --repo --report --set-as-alerts-page --tmp" [scan del]="--interactive --org" [scan diff]="--depth --file --interactive --org" [scan list]="--branch --direction --from-time --interactive --json --markdown --org --page --per-page --repo --sort --until-time" [scan metadata]="--interactive --org" - [scan reach]="--reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths" + [scan reach]="--exclude-paths --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths" [scan report]="--fold --interactive --license --org --report-level --short" [scan view]="--interactive --org --stream" [threat-feed]="--direction --eco --filter --interactive --json --markdown --org --page --per-page" diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 24c1c98ea..502559346 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -3,9 +3,10 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { assertValidExcludePaths } from './exclude-paths.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' -import { reachabilityFlags } from './reachability-flags.mts' +import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' import { suggestOrgSlug } from './suggest-org-slug.mts' import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' @@ -171,6 +172,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => ` @@ -181,7 +183,7 @@ async function run( ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} Options - ${getFlagListOutput(generalFlags)} + ${getFlagListOutput({ ...generalFlags, ...excludePathsFlag })} Reachability Options (when --reach is used) ${getFlagListOutput(reachabilityFlags)} @@ -463,6 +465,9 @@ async function run( logger.error('') } + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) // Validation helpers for better readability. @@ -608,6 +613,7 @@ async function run( pendingHead: Boolean(pendingHead), pullRequest: Number(pullRequest), reach: { + excludePaths, reachAnalysisMemoryLimit: Number(reachAnalysisMemoryLimit), reachAnalysisTimeout: Number(reachAnalysisTimeout), reachConcurrency: Number(reachConcurrency), diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 2f0a8c774..ca8a7f25e 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -40,6 +40,7 @@ describe('socket scan create', async () => { --committers Committers --cwd working directory, defaults to process.cwd() --default-branch Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch. + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. --json Output as JSON --markdown Output as Markdown @@ -185,6 +186,62 @@ describe('socket scan create', async () => { }, ) + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--exclude-paths', + 'tests', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should succeed when --exclude-paths is used without --reach', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect( + code, + 'should exit with code 0 when --exclude-paths is used standalone', + ).toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--exclude-paths', + '!tests/keep', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should reject --exclude-paths negation patterns', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", + ) + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + cmdit( [ 'scan', @@ -437,6 +494,32 @@ describe('socket scan create', async () => { }, ) + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'test/fixtures/commands/scan/simple-npm', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--exclude-paths', + 'tests', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should succeed when --exclude-paths is used with --reach', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0 when all flags are valid').toBe(0) + }, + ) + cmdit( [ 'scan', diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 65666d7f8..9c366259f 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -3,8 +3,9 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { assertValidExcludePaths } from './exclude-paths.mts' import { handleScanReach } from './handle-scan-reach.mts' -import { reachabilityFlags } from './reachability-flags.mts' +import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' import constants from '../../constants.mts' @@ -74,6 +75,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => @@ -88,7 +90,7 @@ async function run( ${getFlagListOutput(generalFlags)} Reachability Options - ${getFlagListOutput(reachabilityFlags)} + ${getFlagListOutput({ ...excludePathsFlag, ...reachabilityFlags })} Runs the Socket reachability analysis without creating a scan in Socket. The output is written to .socket.facts.json in the current working directory @@ -167,8 +169,10 @@ async function run( const dryRun = !!cli.flags['dryRun'] // Process comma-separated values for isMultiple flags. + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) + assertValidExcludePaths(excludePaths) // Validate ecosystem values. const reachEcosystems: PURL_Type[] = [] @@ -272,6 +276,7 @@ async function run( outputKind, outputPath: outputPath || '', reachabilityOptions: { + excludePaths, reachAnalysisMemoryLimit: Number(reachAnalysisMemoryLimit), reachAnalysisTimeout: Number(reachAnalysisTimeout), reachConcurrency: Number(reachConcurrency), diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index f3f67e1d5..47dff750d 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,6 +37,7 @@ describe('socket scan reach', async () => { --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory. Reachability Options + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --reach-analysis-memory-limit The maximum memory in MB to use for the reachability analysis. The default is 8192MB. --reach-analysis-timeout Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly. --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level that ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. NPM reachability analysis does not support concurrent execution, so the concurrency level is ignored for NPM. @@ -295,6 +296,50 @@ describe('socket scan reach', async () => { 'scan', 'reach', FLAG_DRY_RUN, + '--exclude-paths', + 'node_modules,dist', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --exclude-paths with comma-separated values', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--exclude-paths', + 'node_modules', + '--exclude-paths', + 'dist', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept multiple --exclude-paths flags', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--exclude-paths', + 'build', '--reach-exclude-paths', 'node_modules,dist', '--org', @@ -310,6 +355,29 @@ describe('socket scan reach', async () => { }, ) + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--exclude-paths', + '!tests/keep', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should reject --exclude-paths negation patterns', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", + ) + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + cmdit( [ 'scan', diff --git a/src/commands/scan/create-scan-from-github.mts b/src/commands/scan/create-scan-from-github.mts index 14ce4f707..759a9e6a2 100644 --- a/src/commands/scan/create-scan-from-github.mts +++ b/src/commands/scan/create-scan-from-github.mts @@ -250,6 +250,7 @@ async function scanOneRepo( pendingHead: true, pullRequest: 0, reach: { + excludePaths: [], reachAnalysisMemoryLimit: 0, reachAnalysisTimeout: 0, reachConcurrency: 1, diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts new file mode 100644 index 000000000..bed3b4ca2 --- /dev/null +++ b/src/commands/scan/exclude-paths.mts @@ -0,0 +1,186 @@ +import path from 'node:path' + +import { InputError } from '../../utils/errors.mts' +import { stripTrailingSlash } from '../../utils/glob.mts' + +import type { ReachabilityOptions } from './perform-reachability-analysis.mts' + +type ApplyFullExcludePathsOptions = { + cwd: string + reachabilityOptions: ReachabilityOptions + target: string +} + +type ApplyFullExcludePathsResult = { + additionalScaIgnores: string[] + mergedReachabilityOptions: ReachabilityOptions +} + +function normalizeProjectIgnorePath(path: string): string { + return stripTrailingSlash( + toPosixPath(path.startsWith('/') ? path.slice(1) : path), + ) +} + +/** + * Converts a Socket-scan-root anchored --exclude-paths pattern into the shape + * Coana expects for the current analysis target. Coana resolves --exclude-dirs + * relative to the path passed to `coana run`, not relative to this command's + * cwd. For a root target the pattern can pass through unchanged; for a nested + * target we strip the target prefix; documented match-anywhere globstar + * patterns remain meaningful relative to the nested target; and paths outside + * the target return undefined because Coana cannot exclude directories it is + * not analyzing. + */ +function pathRelativeToTarget( + path: string, + target: string, +): string | undefined { + const normalized = normalizeProjectIgnorePath(path) + if (target === '.' || target === '') { + // Root target: the project root and Coana analysis root are the same directory. + return normalized + } + if (normalized === target) { + // Whole target excluded: manifest discovery should stop before Coana runs. + return undefined + } + if (normalized.startsWith('**/')) { + // Match-anywhere glob: keep matching at any depth under the Coana target. + return normalized + } + const targetPrefix = `${target}/` + if (normalized.startsWith(targetPrefix)) { + // Nested target: strip the target prefix to make the pattern target-relative. + return normalized.slice(targetPrefix.length) + } + // Outside the target: there is nothing for this Coana run to exclude. + return undefined +} + +function toPosixPath(path: string): string { + return path.replaceAll('\\', '/') +} + +/** + * Derives the two scan-time forms of --exclude-paths: anchored minimatch + * patterns for SCA manifest discovery, and target-relative paths for Coana's + * reachability analysis. + */ +export function applyFullExcludePaths({ + cwd, + reachabilityOptions, + target, +}: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { + const { excludePaths } = reachabilityOptions + const additionalScaIgnores = excludePaths.flatMap(excludePathToScanIgnores) + const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths(excludePaths, { + cwd, + target, + }) + const mergedReachabilityOptions = excludePaths.length + ? { + ...reachabilityOptions, + reachExcludePaths: [ + ...reachabilityOptions.reachExcludePaths, + ...coanaExcludeGlobs, + ], + } + : reachabilityOptions + + return { + additionalScaIgnores, + mergedReachabilityOptions, + } +} + +// Patterns that resolve to "exclude the entire scan" or "exclude nothing +// useful" are almost certainly typos. Rejecting them up front beats +// silently producing an empty scan or a no-op exclusion. +const DEGENERATE_EXCLUDE_PATHS = new Set([ + '', + '.', + './', + './**', + '/', + '**', + '/**', +]) + +/** + * Validates --exclude-paths entries before they reach either exclusion sink. + * Rejects gitignore-style negations (coana's --exclude-dirs has no negation + * form), absolute paths (the flag is scan-root relative), patterns escaping + * the scan root via `..`, and degenerate match-everything sentinels like `.`, + * `**`, `/`. + */ +export function assertValidExcludePaths(paths: readonly string[]): void { + for (const p of paths) { + if (p.startsWith('!')) { + throw new InputError( + `--exclude-paths does not support negation patterns. Got: '${p}'.`, + ) + } + const posix = toPosixPath(p).trim() + if (DEGENERATE_EXCLUDE_PATHS.has(stripTrailingSlash(posix))) { + throw new InputError( + `--exclude-paths does not accept match-everything patterns. Got: '${p}'.`, + ) + } + if (posix.startsWith('/')) { + throw new InputError( + `--exclude-paths must be relative to the scan root. Got absolute path: '${p}'.`, + ) + } + if (posix === '..' || posix.startsWith('../') || posix.includes('/../')) { + throw new InputError( + `--exclude-paths cannot escape the scan root with '..'. Got: '${p}'.`, + ) + } + } +} + + +/** + * Expands an anchored-micromatch --exclude-paths entry into the minimatch + * patterns fast-glob needs to skip both the matched entry itself (file-shaped + * matches like `packages/stray.json` against `packages/*`) and any subtree + * underneath it (`packages/a/foo.json`). Returned patterns are ready for + * fast-glob's `ignore` list — no gitignore translation involved. + */ +export function excludePathToScanIgnores(input: string): string[] { + const stripped = stripTrailingSlash(toPosixPath(input)) + // User already opted into "match everything under this dir" — one pattern + // is enough. + if (stripped.endsWith('/**')) { + return [stripped] + } + // Emit the entry itself (catches file-shaped hits) plus its subtree + // (catches descendants when the entry resolves to a directory). + return [stripped, `${stripped}/**`] +} + +/** + * Re-anchors --exclude-paths patterns onto the reachability analysis target. + * Coana matches --exclude-dirs relative to whichever directory it was invoked + * on, so when the analysis target is a nested subdirectory, scan-root + * patterns need their target prefix stripped. Patterns that fall outside the + * target are dropped — coana cannot exclude what it isn't analyzing. Bails + * out entirely when any input contains a negation, since coana's --exclude-dirs + * has no negation form. + */ +export function projectIgnorePathsToReachExcludePaths( + paths: readonly string[] | undefined, + options: { cwd: string; target: string }, +): string[] { + if (!Array.isArray(paths) || paths.some(p => p.startsWith('!'))) { + return [] + } + const targetPattern = normalizeProjectIgnorePath( + path.relative(options.cwd, path.resolve(options.cwd, options.target)), + ) + return paths.flatMap(p => { + const reachPath = pathRelativeToTarget(p, targetPattern) + return reachPath === undefined ? [] : [reachPath] + }) +} diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts new file mode 100644 index 000000000..bac07a2fa --- /dev/null +++ b/src/commands/scan/exclude-paths.test.mts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' + +import { + applyFullExcludePaths, + assertValidExcludePaths, + excludePathToScanIgnores, + projectIgnorePathsToReachExcludePaths, +} from './exclude-paths.mts' +import { InputError } from '../../utils/errors.mts' + +import type { ReachabilityOptions } from './perform-reachability-analysis.mts' + +function makeReachOptions( + overrides: Partial = {}, +): ReachabilityOptions { + return { + excludePaths: [], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: [], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + ...overrides, + } +} + +describe('exclude-paths', () => { + describe('assertValidExcludePaths', () => { + it('allows positive patterns', () => { + expect(() => + assertValidExcludePaths(['tests', 'packages/*', 'a/b/c']), + ).not.toThrow() + }) + + it('rejects negation patterns', () => { + expect(() => assertValidExcludePaths(['!tests/keep'])).toThrow( + InputError, + ) + expect(() => assertValidExcludePaths(['!tests/keep'])).toThrow( + "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", + ) + }) + + it.each(['', '.', './', './**', '/', '**', '/**'])( + 'rejects match-everything sentinel %j', + input => { + expect(() => assertValidExcludePaths([input])).toThrow( + /match-everything|negation|absolute/, + ) + }, + ) + + it.each(['/repo/tests', '/etc/passwd'])( + 'rejects absolute path %j', + input => { + expect(() => assertValidExcludePaths([input])).toThrow( + /absolute path/, + ) + }, + ) + + it.each(['..', '../tests', 'apps/../tests'])( + 'rejects path %j that escapes scan root via ..', + input => { + expect(() => assertValidExcludePaths([input])).toThrow( + /cannot escape the scan root/, + ) + }, + ) + }) + + describe('excludePathToScanIgnores', () => { + it.each<[string, string[]]>([ + ['packages/*', ['packages/*', 'packages/*/**']], + ['tests', ['tests', 'tests/**']], + ['tests/', ['tests', 'tests/**']], + ['tests/**', ['tests/**']], + ])('expands %s to %j for the fast-glob ignore set', (input, expected) => { + expect(excludePathToScanIgnores(input)).toEqual(expected) + }) + }) + + describe('projectIgnorePathsToReachExcludePaths', () => { + it('passes patterns through verbatim when target equals project root', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['tests', 'dist/', 'fixtures/**'], + { + cwd: '/repo', + target: '/repo', + }, + ), + ).toEqual(['tests', 'dist', 'fixtures/**']) + }) + + it('treats a literal "." target the same as project root', () => { + expect( + projectIgnorePathsToReachExcludePaths(['tests', 'fixtures/**'], { + cwd: '/repo', + target: '.', + }), + ).toEqual(['tests', 'fixtures/**']) + }) + + it('normalizes leading dot-slash targets before re-anchoring', () => { + expect( + projectIgnorePathsToReachExcludePaths(['apps/api/tests'], { + cwd: '/repo', + target: './apps/api', + }), + ).toEqual(['tests']) + }) + + it('does not send a Coana exclude when the exclude names the whole target', () => { + expect( + projectIgnorePathsToReachExcludePaths(['apps/api'], { + cwd: '/repo', + target: '/repo/apps/api', + }), + ).toEqual([]) + }) + + it('strips the target prefix and drops out-of-target patterns for nested targets', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['tests/**', 'apps/api/tests/**', 'apps/api/packages/*/**'], + { + cwd: '/repo', + target: '/repo/apps/api', + }, + ), + ).toEqual(['tests/**', 'packages/*/**']) + }) + + it('preserves match-anywhere globs for nested targets', () => { + expect( + projectIgnorePathsToReachExcludePaths(['**/dist'], { + cwd: '/repo', + target: '/repo/apps/api', + }), + ).toEqual(['**/dist']) + }) + + it('strips trailing slashes when re-anchoring under a nested target', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['apps/api/tests/', 'apps/api/build/'], + { + cwd: '/repo', + target: '/repo/apps/api', + }, + ), + ).toEqual(['tests', 'build']) + }) + + it('returns no paths when project ignore paths use negation', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['fixtures/**', '!fixtures/keep'], + { + cwd: '/repo', + target: '/repo', + }, + ), + ).toEqual([]) + }) + }) + + describe('applyFullExcludePaths', () => { + it('expands exclude-paths into SCA ignores and re-anchored Coana excludes', () => { + const result = applyFullExcludePaths({ + cwd: '/repo', + reachabilityOptions: makeReachOptions({ + excludePaths: ['tests'], + }), + target: '/repo', + }) + + expect(result.additionalScaIgnores).toEqual(['tests', 'tests/**']) + expect(result.mergedReachabilityOptions.reachExcludePaths).toEqual([ + 'tests', + ]) + }) + }) +}) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 800d37323..aa660f58f 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -6,6 +6,7 @@ import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import { pluralize } from '@socketsecurity/registry/lib/words' +import { applyFullExcludePaths } from './exclude-paths.mts' import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { finalizeTier1Scan } from './finalize-tier1-scan.mts' @@ -172,7 +173,15 @@ export async function handleCreateNewScan({ ? socketYmlResult.data?.parsed : undefined + const { additionalScaIgnores, mergedReachabilityOptions } = + applyFullExcludePaths({ + cwd, + reachabilityOptions: reach, + target: targets[0]!, + }) + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { + additionalIgnores: additionalScaIgnores, config: socketConfig, cwd, }) @@ -213,7 +222,7 @@ export async function handleCreateNewScan({ logger.error('') logger.info('Starting reachability analysis...') debugFn('notice', 'Reachability analysis enabled') - debugDir('inspect', { reachabilityOptions: reach }) + debugDir('inspect', { reachabilityOptions: mergedReachabilityOptions }) spinner.start() @@ -222,7 +231,7 @@ export async function handleCreateNewScan({ cwd, orgSlug, packagePaths, - reachabilityOptions: reach, + reachabilityOptions: mergedReachabilityOptions, repoName, spinner, target: targets[0]!, diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts new file mode 100644 index 000000000..29f5f9974 --- /dev/null +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -0,0 +1,392 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { handleCreateNewScan } from './handle-create-new-scan.mts' + +const { + mockFetchCreateOrgFullScan, + mockFetchSupportedScanFileNames, + mockFindSocketYmlSync, + mockGetPackageFilesForScan, + mockPerformReachabilityAnalysis, + mockReadOrDefaultSocketJson, +} = vi.hoisted(() => ({ + mockFetchCreateOrgFullScan: vi.fn(), + mockFetchSupportedScanFileNames: vi.fn(), + mockFindSocketYmlSync: vi.fn(), + mockGetPackageFilesForScan: vi.fn(), + mockPerformReachabilityAnalysis: vi.fn(), + mockReadOrDefaultSocketJson: vi.fn(), +})) + +vi.mock('./fetch-create-org-full-scan.mts', () => ({ + fetchCreateOrgFullScan: mockFetchCreateOrgFullScan, +})) + +vi.mock('./fetch-supported-scan-file-names.mts', () => ({ + fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, +})) + +vi.mock('./finalize-tier1-scan.mts', () => ({ + finalizeTier1Scan: vi.fn(), +})) + +vi.mock('./handle-scan-report.mts', () => ({ + handleScanReport: vi.fn(), +})) + +vi.mock('./output-create-new-scan.mts', () => ({ + outputCreateNewScan: vi.fn(), +})) + +vi.mock('./perform-reachability-analysis.mts', () => ({ + performReachabilityAnalysis: mockPerformReachabilityAnalysis, +})) + +vi.mock('../../utils/config.mts', () => ({ + findSocketYmlSync: mockFindSocketYmlSync, +})) + +vi.mock('../../utils/path-resolve.mts', () => ({ + getPackageFilesForScan: mockGetPackageFilesForScan, +})) + +vi.mock('../../utils/socket-json.mts', () => ({ + readOrDefaultSocketJson: mockReadOrDefaultSocketJson, +})) + +vi.mock('../manifest/detect-manifest-actions.mts', () => ({ + detectManifestActions: vi.fn(() => Promise.resolve({ count: 0 })), +})) + +vi.mock('../manifest/generate_auto_manifest.mts', () => ({ + generateAutoManifest: vi.fn(), +})) + +describe('handleCreateNewScan excludePaths', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockFetchCreateOrgFullScan.mockResolvedValue({ + data: { id: 'scan-id' }, + ok: true, + }) + mockFetchSupportedScanFileNames.mockResolvedValue({ + data: { size: 1 }, + ok: true, + }) + mockFindSocketYmlSync.mockReturnValue({ + data: { parsed: { projectIgnorePaths: ['fixtures/**'] } }, + ok: true, + }) + mockGetPackageFilesForScan.mockResolvedValue(['package.json']) + mockPerformReachabilityAnalysis.mockResolvedValue({ + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: 'tier1-id', + }, + ok: true, + }) + mockReadOrDefaultSocketJson.mockReturnValue({}) + }) + + it('adds excludePaths to manifest discovery and reachability excludes', async () => { + await handleCreateNewScan({ + autoManifest: false, + branchName: 'main', + commitHash: '', + commitMessage: '', + committers: '', + cwd: '/repo', + defaultBranch: false, + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + pendingHead: false, + pullRequest: 0, + reach: { + excludePaths: ['tests', 'packages/*'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: ['dist'], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + runReachabilityAnalysis: true, + }, + readOnly: false, + repoName: 'repo', + report: false, + reportLevel: 'error', + targets: ['/repo'], + tmp: false, + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo'], + { size: 1 }, + { + additionalIgnores: ['tests', 'tests/**', 'packages/*', 'packages/*/**'], + config: { projectIgnorePaths: ['fixtures/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['dist', 'tests', 'packages/*'], + }), + }), + ) + }) + + it('translates excludePaths from the scan root for nested reachability targets', async () => { + await handleCreateNewScan({ + autoManifest: false, + branchName: 'main', + commitHash: '', + commitMessage: '', + committers: '', + cwd: '/repo', + defaultBranch: false, + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + pendingHead: false, + pullRequest: 0, + reach: { + excludePaths: ['apps/api/tests', '**/dist'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: ['node_modules'], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + runReachabilityAnalysis: true, + }, + readOnly: false, + repoName: 'repo', + report: false, + reportLevel: 'error', + targets: ['/repo/apps/api'], + tmp: false, + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo/apps/api'], + { size: 1 }, + { + additionalIgnores: [ + 'apps/api/tests', + 'apps/api/tests/**', + '**/dist', + '**/dist/**', + ], + config: { projectIgnorePaths: ['fixtures/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + target: '/repo/apps/api', + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['node_modules', 'tests', '**/dist'], + }), + }), + ) + }) + + it('applies excludePaths to SCA discovery even when reachability is disabled', async () => { + await handleCreateNewScan({ + autoManifest: false, + branchName: 'main', + commitHash: '', + commitMessage: '', + committers: '', + cwd: '/repo', + defaultBranch: false, + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + pendingHead: false, + pullRequest: 0, + reach: { + excludePaths: ['tests'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: [], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + runReachabilityAnalysis: false, + }, + readOnly: false, + repoName: 'repo', + report: false, + reportLevel: 'error', + targets: ['/repo'], + tmp: false, + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo'], + { size: 1 }, + { + additionalIgnores: ['tests', 'tests/**'], + config: { projectIgnorePaths: ['fixtures/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() + }) + + it('does not invoke Coana when excludePaths remove the whole target from manifest discovery', async () => { + mockGetPackageFilesForScan.mockResolvedValueOnce([]) + + await handleCreateNewScan({ + autoManifest: false, + branchName: 'main', + commitHash: '', + commitMessage: '', + committers: '', + cwd: '/repo', + defaultBranch: false, + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + pendingHead: false, + pullRequest: 0, + reach: { + excludePaths: ['apps/api'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: ['node_modules'], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + runReachabilityAnalysis: true, + }, + readOnly: false, + repoName: 'repo', + report: false, + reportLevel: 'error', + targets: ['/repo/apps/api'], + tmp: false, + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo/apps/api'], + { size: 1 }, + { + additionalIgnores: ['apps/api', 'apps/api/**'], + config: { projectIgnorePaths: ['fixtures/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() + }) + + it('passes config: undefined when socket.yml is absent', async () => { + mockFindSocketYmlSync.mockReturnValueOnce({ ok: false }) + + await handleCreateNewScan({ + autoManifest: false, + branchName: 'main', + commitHash: '', + commitMessage: '', + committers: '', + cwd: '/repo', + defaultBranch: false, + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + pendingHead: false, + pullRequest: 0, + reach: { + excludePaths: ['tests'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: [], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + runReachabilityAnalysis: false, + }, + readOnly: false, + repoName: 'repo', + report: false, + reportLevel: 'error', + targets: ['/repo'], + tmp: false, + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo'], + { size: 1 }, + { + additionalIgnores: ['tests', 'tests/**'], + config: undefined, + cwd: '/repo', + }, + ) + }) +}) diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 7363d0e45..9df5c2a17 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -1,6 +1,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { pluralize } from '@socketsecurity/registry/lib/words' +import { applyFullExcludePaths } from './exclude-paths.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { outputScanReach } from './output-scan-reach.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' @@ -33,7 +34,7 @@ export async function handleScanReach({ }: HandleScanReachConfig) { const { spinner } = constants - // Get supported file names + // Get supported file names. const supportedFilesCResult = await fetchSupportedScanFileNames({ spinner }) if (!supportedFilesCResult.ok) { await outputScanReach(supportedFilesCResult, { @@ -55,7 +56,15 @@ export async function handleScanReach({ ? socketYmlResult.data?.parsed : undefined + const { additionalScaIgnores, mergedReachabilityOptions } = + applyFullExcludePaths({ + cwd, + reachabilityOptions, + target: targets[0]!, + }) + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { + additionalIgnores: additionalScaIgnores, config: socketConfig, cwd, }) @@ -86,7 +95,7 @@ export async function handleScanReach({ orgSlug, outputPath, packagePaths, - reachabilityOptions, + reachabilityOptions: mergedReachabilityOptions, spinner, target: targets[0]!, uploadManifests: true, diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts new file mode 100644 index 000000000..6d94d9a21 --- /dev/null +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -0,0 +1,295 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { handleScanReach } from './handle-scan-reach.mts' + +const { + mockCheckCommandInput, + mockFetchSupportedScanFileNames, + mockFindSocketYmlSync, + mockGetPackageFilesForScan, + mockOutputScanReach, + mockPerformReachabilityAnalysis, + mockSentryInternalsSymbol, +} = vi.hoisted(() => ({ + mockCheckCommandInput: vi.fn(), + mockFetchSupportedScanFileNames: vi.fn(), + mockFindSocketYmlSync: vi.fn(), + mockGetPackageFilesForScan: vi.fn(), + mockOutputScanReach: vi.fn(), + mockPerformReachabilityAnalysis: vi.fn(), + mockSentryInternalsSymbol: Symbol('kInternalsSymbol'), +})) + +vi.mock('./fetch-supported-scan-file-names.mts', () => ({ + fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, +})) + +vi.mock('./output-scan-reach.mts', () => ({ + outputScanReach: mockOutputScanReach, +})) + +vi.mock('./perform-reachability-analysis.mts', () => ({ + performReachabilityAnalysis: mockPerformReachabilityAnalysis, +})) + +vi.mock('../../constants.mts', () => ({ + default: { + kInternalsSymbol: mockSentryInternalsSymbol, + [mockSentryInternalsSymbol]: { + getSentry: vi.fn(() => undefined), + }, + spinner: { + start: vi.fn(), + stop: vi.fn(), + successAndStop: vi.fn(), + }, + }, + // glob.mts pulls NODE_MODULES through the import chain; re-export it + // here so the streaming-iterables loader inside fast-glob is happy. + NODE_MODULES: 'node_modules', + UNKNOWN_ERROR: 'unknown', +})) + +vi.mock('../../utils/check-input.mts', () => ({ + checkCommandInput: mockCheckCommandInput, +})) + +vi.mock('../../utils/config.mts', () => ({ + findSocketYmlSync: mockFindSocketYmlSync, +})) + +vi.mock('../../utils/path-resolve.mts', () => ({ + getPackageFilesForScan: mockGetPackageFilesForScan, +})) + +vi.mock('@socketsecurity/registry/lib/logger', () => ({ + logger: { + success: vi.fn(), + }, +})) + +describe('handleScanReach', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckCommandInput.mockReturnValue(true) + mockFetchSupportedScanFileNames.mockResolvedValue({ + ok: true, + data: { npm: { packageJson: { pattern: 'package.json' } } }, + }) + mockFindSocketYmlSync.mockReturnValue({ + ok: true, + data: { parsed: { projectIgnorePaths: ['vendor/**'] } }, + }) + mockGetPackageFilesForScan.mockResolvedValue(['package.json']) + mockPerformReachabilityAnalysis.mockResolvedValue({ + ok: true, + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: undefined, + }, + }) + }) + + it('applies excludePaths to manifest discovery and reachability analysis', async () => { + const reachabilityOptions = { + excludePaths: ['tests', 'packages/*'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: ['node_modules'], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + } + + await handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions, + targets: ['.'], + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['.'], + { npm: { packageJson: { pattern: 'package.json' } } }, + { + additionalIgnores: ['tests', 'tests/**', 'packages/*', 'packages/*/**'], + config: { projectIgnorePaths: ['vendor/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['node_modules', 'tests', 'packages/*'], + }), + }), + ) + }) + + it('translates excludePaths from the scan root for nested targets', async () => { + const reachabilityOptions = { + excludePaths: ['apps/api/tests', '**/dist'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: ['node_modules'], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + } + + await handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions, + targets: ['/repo/apps/api'], + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo/apps/api'], + { npm: { packageJson: { pattern: 'package.json' } } }, + { + additionalIgnores: [ + 'apps/api/tests', + 'apps/api/tests/**', + '**/dist', + '**/dist/**', + ], + config: { projectIgnorePaths: ['vendor/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['node_modules', 'tests', '**/dist'], + }), + }), + ) + }) + + it('does not invoke Coana when excludePaths remove the whole target from manifest discovery', async () => { + mockGetPackageFilesForScan.mockResolvedValueOnce([]) + mockCheckCommandInput.mockImplementation( + (_outputKind: unknown, ...checks: Array<{ test: boolean }>) => + checks.every(check => check.test), + ) + const reachabilityOptions = { + excludePaths: ['apps/api'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: ['node_modules'], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + } + + await handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions, + targets: ['/repo/apps/api'], + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo/apps/api'], + { npm: { packageJson: { pattern: 'package.json' } } }, + { + additionalIgnores: ['apps/api', 'apps/api/**'], + config: { projectIgnorePaths: ['vendor/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() + }) + + it('passes config: undefined when socket.yml is absent', async () => { + mockFindSocketYmlSync.mockReturnValueOnce({ ok: false }) + + const reachabilityOptions = { + excludePaths: ['tests'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: [], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + } + + await handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions, + targets: ['.'], + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['.'], + { npm: { packageJson: { pattern: 'package.json' } } }, + { + additionalIgnores: ['tests', 'tests/**'], + config: undefined, + cwd: '/repo', + }, + ) + }) +}) diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index 1ededeea7..bb77e0a58 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -16,6 +16,7 @@ import type { PURL_Type } from '../../utils/ecosystem.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' export type ReachabilityOptions = { + excludePaths: string[] reachAnalysisMemoryLimit: number reachAnalysisTimeout: number reachConcurrency: number diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index c00b9f9d0..102a32195 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -116,3 +116,12 @@ export const reachabilityFlags: MeowFlags = { 'When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project.', }, } + +export const excludePathsFlag: MeowFlags = { + excludePaths: { + type: 'string', + isMultiple: true, + description: + 'List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (`--cwd` if set), not the reachability target: `tests` matches only `/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + }, +} diff --git a/src/utils/glob.mts b/src/utils/glob.mts index 06f57c7de..dd89f37ef 100644 --- a/src/utils/glob.mts +++ b/src/utils/glob.mts @@ -141,7 +141,7 @@ function ignorePatternToMinimatch(pattern: string): string { // here as `**/dist/` after `ignorePatternToMinimatch`, which fast-glob // then drops — defeating the entire ignore. Strip the trailing slash // so fast-glob actually honors the pattern. -function stripTrailingSlash(pattern: string): string { +export function stripTrailingSlash(pattern: string): string { if ( pattern.length > 1 && pattern.charCodeAt(pattern.length - 1) === 47 /*'/'*/ @@ -204,6 +204,13 @@ export function getSupportedFilePatterns( } type GlobWithGitIgnoreOptions = GlobOptions & { + // Already-anchored minimatch patterns merged into fast-glob's `ignore` + // option in every code path. These bypass the gitignore translator and + // the `ignore` package matcher entirely; use this channel for CLI flags + // whose contract is anchored micromatch from `cwd` (e.g. --exclude-paths). + // Patterns in `socketConfig.projectIgnorePaths` and discovered `.gitignore` + // files take the other channel: they're gitignore-translated first. + additionalIgnores?: readonly string[] | undefined // Optional filter function to apply during streaming. // When provided, only files passing this filter are accumulated. // This is critical for memory efficiency when scanning large monorepos. @@ -216,6 +223,7 @@ export async function globWithGitIgnore( options: GlobWithGitIgnoreOptions, ): Promise { const { + additionalIgnores, cwd = process.cwd(), filter, socketConfig, @@ -265,14 +273,21 @@ export async function globWithGitIgnore( } } + // CLI-supplied `additionalIgnores` are already anchored minimatch — they + // must not pass through the `ignore` package (whose gitignore "match + // anywhere" semantics would re-interpret a bare `tests` to match + // `subdir/tests/foo.json`). Keep them in fast-glob's ignore list across + // both paths; only gitignore-translated entries go into the `ig` matcher. + const cliMinimatchIgnores = additionalIgnores ?? [] + const globOptions = { __proto__: null, absolute: true, cwd, dot: true, ignore: hasNegatedPattern - ? defaultIgnore - : [...ignores].map(stripTrailingSlash), + ? [...defaultIgnore, ...cliMinimatchIgnores] + : [...ignores, ...cliMinimatchIgnores].map(stripTrailingSlash), ...additionalOptions, } as GlobOptions diff --git a/src/utils/glob.test.mts b/src/utils/glob.test.mts index 7bc132a65..fdec8a636 100644 --- a/src/utils/glob.test.mts +++ b/src/utils/glob.test.mts @@ -201,6 +201,54 @@ describe('glob utilities', () => { ]) }) + it('keeps additionalIgnores anchored even when a gitignore negation forces the streaming path', async () => { + // A bare `tests` pattern means "the entry `tests` at the scan root". + // The streaming path uses the `ignore` package for gitignore-translated + // entries, which treats bare names as match-anywhere. CLI patterns + // must bypass that matcher so anchored semantics survive. + mockTestFs({ + // `!nested/keep.json` forces hasNegatedPattern = true → streaming. + [`${mockFixturePath}/.gitignore`]: 'banned/**\n!nested/keep.json', + [`${mockFixturePath}/tests/foo.json`]: '{}', + [`${mockFixturePath}/subdir/tests/foo.json`]: '{}', + [`${mockFixturePath}/nested/keep.json`]: '{}', + }) + + const results = await globWithGitIgnore(['**/*.json'], { + additionalIgnores: ['tests', 'tests/**'], + cwd: mockFixturePath, + }) + + expect(results.map(normalizePath).sort()).toEqual([ + `${mockFixturePath}/nested/keep.json`, + `${mockFixturePath}/subdir/tests/foo.json`, + ]) + }) + + it('excludes direct-child files when user writes `--exclude-paths packages/*`', async () => { + // Anchored micromatch semantics: `packages/*` matches every direct + // child of packages/ — both files like packages/stray.json and dirs + // like packages/a. The user-facing help text promises anchored + // micromatch, so all four manifest files below should be excluded + // from the scan, leaving only the top-level package.json. + mockTestFs({ + [`${mockFixturePath}/package.json`]: '{}', + [`${mockFixturePath}/packages/stray.json`]: '{}', + [`${mockFixturePath}/packages/package.json`]: '{}', + [`${mockFixturePath}/packages/a/package.json`]: '{}', + [`${mockFixturePath}/packages/b/package.json`]: '{}', + }) + + const results = await globWithGitIgnore(['**/*.json'], { + additionalIgnores: ['packages/*', 'packages/*/**'], + cwd: mockFixturePath, + }) + + expect(results.map(normalizePath).sort()).toEqual([ + `${mockFixturePath}/package.json`, + ]) + }) + it('should combine filter with negated gitignore patterns', async () => { mockTestFs({ [`${mockFixturePath}/.gitignore`]: 'build/**\n!build/manifest.json', diff --git a/src/utils/path-resolve.mts b/src/utils/path-resolve.mts index 4da0347c3..247d81ede 100644 --- a/src/utils/path-resolve.mts +++ b/src/utils/path-resolve.mts @@ -12,6 +12,7 @@ import { createSupportedFilesFilter, globWithGitIgnore, pathsToGlobPatterns, + stripTrailingSlash, } from './glob.mts' import type { SocketYml } from '@socketsecurity/config' @@ -100,16 +101,45 @@ export function findNpmDirPathSync(npmBinPath: string): string | undefined { } export type PackageFilesForScanOptions = { + // Already-anchored minimatch patterns to skip, forwarded straight to + // fast-glob. Bypasses the gitignore translator — use this for CLI-supplied + // exclusions whose contract is anchored micromatch from `cwd`. Mix with + // `config.projectIgnorePaths` for gitignore-style patterns. + additionalIgnores?: readonly string[] | undefined cwd?: string | undefined config?: SocketYml | undefined } +/** + * Converts absolute scan targets inside cwd back to cwd-relative paths before + * glob expansion. SCA excludes passed through `additionalIgnores` are anchored + * to cwd, so package discovery needs target globs in the same coordinate + * system for fast-glob to apply those ignores consistently. + */ +function normalizeScanInputPath(pathToNormalize: string, cwd: string): string { + if (!path.isAbsolute(pathToNormalize)) { + return pathToNormalize + } + const relativePath = path.relative(cwd, pathToNormalize) + const isInsideCwd = + relativePath === '' || + (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + if (!isInsideCwd) { + return pathToNormalize + } + return stripTrailingSlash(relativePath.replaceAll('\\', '/')) || '.' +} + export async function getPackageFilesForScan( inputPaths: string[], supportedFiles: SocketSdkSuccessResult<'getReportSupportedFiles'>['data'], options?: PackageFilesForScanOptions | undefined, ): Promise { - const { config: socketConfig, cwd = process.cwd() } = { + const { + additionalIgnores, + config: socketConfig, + cwd = process.cwd(), + } = { __proto__: null, ...options, } as PackageFilesForScanOptions @@ -119,9 +149,14 @@ export async function getPackageFilesForScan( // where accumulating all paths before filtering causes OOM errors. const filter = createSupportedFilesFilter(supportedFiles) + const normalizedInputPaths = inputPaths.map(p => + normalizeScanInputPath(p, cwd), + ) + return await globWithGitIgnore( - pathsToGlobPatterns(inputPaths, options?.cwd), + pathsToGlobPatterns(normalizedInputPaths, cwd), { + additionalIgnores, cwd, filter, socketConfig, diff --git a/src/utils/path-resolve.test.mts b/src/utils/path-resolve.test.mts index 242c696ad..2bbbb6d9a 100644 --- a/src/utils/path-resolve.test.mts +++ b/src/utils/path-resolve.test.mts @@ -173,6 +173,29 @@ describe('Path Resolve', () => { ]) }) + it('should keep scan-root ignores effective when the input path is absolute', async () => { + const appDirPath = normalizePath(path.join(mockFixturePath, 'apps/api')) + mockTestFs({ + [`${appDirPath}/package.json`]: '{}', + [`${appDirPath}/src/package.json`]: '{}', + [`${appDirPath}/tests/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + [appDirPath], + globPatterns, + { + additionalIgnores: ['apps/api/tests', 'apps/api/tests/**'], + cwd: mockFixturePath, + }, + ) + + expect(actual.map(normalizePath)).toEqual([ + `${appDirPath}/package.json`, + `${appDirPath}/src/package.json`, + ]) + }) + it('should respect ignores from socket config', async () => { mockTestFs({ [`${mockFixturePath}/bar/package-lock.json`]: '{}',