From 5e2fd10a1d64f5476d9d16bc9551e2613f581527 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 17:49:07 +0000 Subject: [PATCH 01/20] feat(scan): add tier1 exclude paths flag Co-authored-by: Simon --- src/commands/ci/handle-ci.mts | 1 + src/commands/scan/cmd-scan-create.mts | 9 + src/commands/scan/cmd-scan-create.test.mts | 59 +++++ src/commands/scan/cmd-scan-reach.mts | 4 + src/commands/scan/cmd-scan-reach.test.mts | 68 ++++++ src/commands/scan/create-scan-from-github.mts | 1 + src/commands/scan/exclude-paths.mts | 25 +++ src/commands/scan/exclude-paths.test.mts | 49 ++++ src/commands/scan/handle-create-new-scan.mts | 29 ++- .../scan/handle-create-new-scan.test.mts | 209 ++++++++++++++++++ src/commands/scan/handle-scan-reach.mts | 34 ++- src/commands/scan/handle-scan-reach.test.mts | 134 +++++++++++ .../scan/perform-reachability-analysis.mts | 1 + src/commands/scan/reachability-flags.mts | 36 +-- 14 files changed, 638 insertions(+), 21 deletions(-) create mode 100644 src/commands/scan/exclude-paths.mts create mode 100644 src/commands/scan/exclude-paths.test.mts create mode 100644 src/commands/scan/handle-create-new-scan.test.mts create mode 100644 src/commands/scan/handle-scan-reach.test.mts 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/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 24c1c98ea..8a83dfa4a 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { assertNoNegationPatterns } 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' @@ -279,6 +280,7 @@ async function run( setAsAlertsPage: boolean tmp: boolean // Reachability flags. + excludePaths: string[] | undefined reach: boolean reachAnalysisMemoryLimit: number reachAnalysisTimeout: number @@ -463,9 +465,14 @@ async function run( logger.error('') } + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertNoNegationPatterns(excludePaths) + const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) // Validation helpers for better readability. + const hasExcludePaths = excludePaths.length > 0 + const hasReachEcosystems = reachEcosystems.length > 0 const hasReachExcludePaths = reachExcludePaths.length > 0 @@ -488,6 +495,7 @@ async function run( reachVersion !== reachabilityFlags['reachVersion']?.default const isUsingAnyReachabilityFlags = + hasExcludePaths || hasReachEcosystems || hasReachExcludePaths || isUsingNonDefaultAnalytics || @@ -608,6 +616,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..d5fb9e1f3 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -55,6 +55,7 @@ describe('socket scan create', async () => { --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) + --exclude-paths List of glob patterns to exclude from the entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. 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. @@ -185,6 +186,38 @@ 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 fail when --exclude-paths is used without --reach', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'Reachability analysis flags require --reach to be enabled', + ) + expect(output).toContain('add --reach flag to use --reach-* options') + expect( + code, + 'should exit with non-zero code when validation fails', + ).not.toBe(0) + }, + ) + cmdit( [ 'scan', @@ -437,6 +470,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..6a20d78aa 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { assertNoNegationPatterns } from './exclude-paths.mts' import { handleScanReach } from './handle-scan-reach.mts' import { reachabilityFlags } from './reachability-flags.mts' import { suggestTarget } from './suggest_target.mts' @@ -167,8 +168,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']) + assertNoNegationPatterns(excludePaths) // Validate ecosystem values. const reachEcosystems: PURL_Type[] = [] @@ -272,6 +275,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..dab3c4198 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 entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. 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..127c9fbe5 --- /dev/null +++ b/src/commands/scan/exclude-paths.mts @@ -0,0 +1,25 @@ +import { InputError } from '../../utils/errors.mts' + +export function excludePathToProjectIgnorePath(path: string): string { + const stripped = stripTrailingSlash(path) + return stripped.endsWith('/**') ? stripped : `${stripped}/**` +} + +export function assertNoNegationPatterns(paths: readonly string[]): void { + for (const path of paths) { + if (path.startsWith('!')) { + throw new InputError( + `--exclude-paths does not support negation patterns. Got: '${path}'.`, + ) + } + } +} + +export function normalizeExcludePath(path: string): string { + const stripped = stripTrailingSlash(path) + return stripped.endsWith('/*') ? stripped : `${stripped}/**` +} + +function stripTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path +} diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts new file mode 100644 index 000000000..0fb180be2 --- /dev/null +++ b/src/commands/scan/exclude-paths.test.mts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { + assertNoNegationPatterns, + excludePathToProjectIgnorePath, + normalizeExcludePath, +} from './exclude-paths.mts' +import { InputError } from '../../utils/errors.mts' + +describe('exclude-paths', () => { + describe('assertNoNegationPatterns', () => { + it('allows positive patterns', () => { + expect(() => + assertNoNegationPatterns(['tests', 'packages/*']), + ).not.toThrow() + }) + + it('rejects negation patterns', () => { + expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( + InputError, + ) + expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( + "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", + ) + }) + }) + + describe('excludePathToProjectIgnorePath', () => { + it.each([ + ['packages/*', 'packages/*/**'], + ['tests', 'tests/**'], + ['tests/', 'tests/**'], + ['tests/**', 'tests/**'], + ])('converts %s to %s', (input, expected) => { + expect(excludePathToProjectIgnorePath(input)).toBe(expected) + }) + }) + + describe('normalizeExcludePath', () => { + it.each([ + ['tests', 'tests/**'], + ['tests/', 'tests/**'], + ['tests/*', 'tests/*'], + ['tests/**', 'tests/**/**'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeExcludePath(input)).toBe(expected) + }) + }) +}) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 800d37323..7b5e3c20c 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -6,6 +6,10 @@ import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import { pluralize } from '@socketsecurity/registry/lib/words' +import { + excludePathToProjectIgnorePath, + normalizeExcludePath, +} 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,8 +176,27 @@ export async function handleCreateNewScan({ ? socketYmlResult.data?.parsed : undefined + const excludePaths = reach.runReachabilityAnalysis ? reach.excludePaths : [] + const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) + const coanaExcludeGlobs = excludePaths.map(normalizeExcludePath) + const effectiveSocketConfig = scaExcludeGlobs.length + ? { + ...socketConfig, + projectIgnorePaths: [ + ...(socketConfig?.projectIgnorePaths ?? []), + ...scaExcludeGlobs, + ], + } + : socketConfig + const mergedReachabilityOptions = excludePaths.length + ? { + ...reach, + reachExcludePaths: [...reach.reachExcludePaths, ...coanaExcludeGlobs], + } + : reach + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: socketConfig, + config: effectiveSocketConfig, cwd, }) @@ -213,7 +236,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 +245,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..54f13f51d --- /dev/null +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -0,0 +1,209 @@ +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 }, + { + config: { + projectIgnorePaths: ['fixtures/**', 'tests/**', 'packages/*/**'], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['dist', 'tests/**', 'packages/*'], + }), + }), + ) + }) + + it('does not apply excludePaths 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 }, + { + config: { projectIgnorePaths: ['fixtures/**'] }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() + }) +}) diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 7363d0e45..bdd5f3261 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -1,6 +1,10 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { pluralize } from '@socketsecurity/registry/lib/words' +import { + excludePathToProjectIgnorePath, + normalizeExcludePath, +} 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 +37,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,8 +59,32 @@ export async function handleScanReach({ ? socketYmlResult.data?.parsed : undefined + const { excludePaths } = reachabilityOptions + const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) + const coanaExcludeGlobs = excludePaths.map(normalizeExcludePath) + + const effectiveSocketConfig = scaExcludeGlobs.length + ? { + ...socketConfig, + projectIgnorePaths: [ + ...(socketConfig?.projectIgnorePaths ?? []), + ...scaExcludeGlobs, + ], + } + : socketConfig + + const mergedReachabilityOptions = excludePaths.length + ? { + ...reachabilityOptions, + reachExcludePaths: [ + ...reachabilityOptions.reachExcludePaths, + ...coanaExcludeGlobs, + ], + } + : reachabilityOptions + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: socketConfig, + config: effectiveSocketConfig, cwd, }) @@ -86,7 +114,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..9af9e3b8e --- /dev/null +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { handleScanReach } from './handle-scan-reach.mts' + +const { + mockCheckCommandInput, + mockFetchSupportedScanFileNames, + mockFindSocketYmlSync, + mockGetPackageFilesForScan, + mockOutputScanReach, + mockPerformReachabilityAnalysis, +} = vi.hoisted(() => ({ + mockCheckCommandInput: vi.fn(), + mockFetchSupportedScanFileNames: vi.fn(), + mockFindSocketYmlSync: vi.fn(), + mockGetPackageFilesForScan: vi.fn(), + mockOutputScanReach: vi.fn(), + mockPerformReachabilityAnalysis: vi.fn(), +})) + +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: { + spinner: { + start: vi.fn(), + stop: vi.fn(), + successAndStop: vi.fn(), + }, + }, +})) + +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' } } }, + { + config: { + projectIgnorePaths: ['vendor/**', 'tests/**', 'packages/*/**'], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['node_modules', 'tests/**', 'packages/*'], + }), + }), + ) + }) +}) 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..8eac80a8d 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -3,9 +3,11 @@ import constants from '../../constants.mts' import type { MeowFlags } from '../../flags.mts' export const reachabilityFlags: MeowFlags = { - reachVersion: { + excludePaths: { type: 'string', - description: `Override the version of @coana-tech/cli used for reachability analysis. Default: ${constants.ENV.INLINED_SOCKET_CLI_COANA_TECH_CLI_VERSION}.`, + isMultiple: true, + description: + 'List of glob patterns to exclude from the entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', }, reachAnalysisMemoryLimit: { type: 'number', @@ -49,11 +51,6 @@ export const reachabilityFlags: MeowFlags = { description: 'Continue reachability analysis when a workspace contains no source files for its ecosystem. By default, the CLI halts.', }, - reachDisableExternalToolChecks: { - type: 'boolean', - default: false, - description: 'Disable external tool checks during reachability analysis.', - }, reachDebug: { type: 'boolean', default: false, @@ -66,12 +63,6 @@ export const reachabilityFlags: MeowFlags = { description: 'A log file with detailed analysis logs is written to root of each analyzed workspace.', }, - reachDisableAnalytics: { - type: 'boolean', - default: false, - description: - 'Disable reachability analytics sharing with Socket. Also disables caching-based optimizations.', - }, reachDisableAnalysisSplitting: { type: 'boolean', default: false, @@ -79,11 +70,16 @@ export const reachabilityFlags: MeowFlags = { description: 'Deprecated: Analysis splitting is now disabled by default. This flag is a no-op.', }, - reachEnableAnalysisSplitting: { + reachDisableAnalytics: { type: 'boolean', default: false, description: - 'Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default.', + 'Disable reachability analytics sharing with Socket. Also disables caching-based optimizations.', + }, + reachDisableExternalToolChecks: { + type: 'boolean', + default: false, + description: 'Disable external tool checks during reachability analysis.', }, reachEcosystems: { type: 'string', @@ -91,6 +87,12 @@ export const reachabilityFlags: MeowFlags = { description: 'List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems.', }, + reachEnableAnalysisSplitting: { + type: 'boolean', + default: false, + description: + 'Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default.', + }, reachExcludePaths: { type: 'string', isMultiple: true, @@ -115,4 +117,8 @@ export const reachabilityFlags: MeowFlags = { description: 'When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project.', }, + reachVersion: { + type: 'string', + description: `Override the version of @coana-tech/cli used for reachability analysis. Default: ${constants.ENV.INLINED_SOCKET_CLI_COANA_TECH_CLI_VERSION}.`, + }, } From a370ba3a52691f945f94846b7ad306c492ac52f6 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:20:31 -0700 Subject: [PATCH 02/20] fix(scan): align exclude paths with socket config ignores --- src/commands/install/socket-completion.bash | 4 ++-- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/exclude-paths.mts | 24 ++++++++++++++++++- src/commands/scan/exclude-paths.test.mts | 23 +++++++++++++++++- src/commands/scan/handle-create-new-scan.mts | 13 +++++++++- .../scan/handle-create-new-scan.test.mts | 10 +++++++- src/commands/scan/handle-scan-reach.mts | 8 +++++++ src/commands/scan/handle-scan-reach.test.mts | 17 ++++++++++++- 8 files changed, 93 insertions(+), 8 deletions(-) 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.test.mts b/src/commands/scan/cmd-scan-create.test.mts index d5fb9e1f3..f5a72262f 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -55,7 +55,7 @@ describe('socket scan create', async () => { --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) - --exclude-paths List of glob patterns to exclude from the entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from the entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. 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. diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 127c9fbe5..4099ebffd 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -17,7 +17,29 @@ export function assertNoNegationPatterns(paths: readonly string[]): void { export function normalizeExcludePath(path: string): string { const stripped = stripTrailingSlash(path) - return stripped.endsWith('/*') ? stripped : `${stripped}/**` + return stripped.endsWith('/*') || stripped.endsWith('/**') + ? stripped + : `${stripped}/**` +} + +export function projectIgnorePathsToReachExcludePaths( + paths: readonly string[] | undefined, +): string[] { + if (!Array.isArray(paths) || paths.some(path => path.includes('!'))) { + return [] + } + return paths.flatMap(path => { + const firstSlash = path.indexOf('/') + const prefix = + firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : '' + const normalized = stripTrailingSlash( + path.startsWith('/') ? path.slice(1) : path, + ) + const pattern = `${prefix}${normalized}` + return pattern.endsWith('/*') || pattern.endsWith('/**') + ? [pattern] + : [pattern, `${pattern}/**`] + }) } function stripTrailingSlash(path: string): string { diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index 0fb180be2..6ecf4487a 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -4,6 +4,7 @@ import { assertNoNegationPatterns, excludePathToProjectIgnorePath, normalizeExcludePath, + projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' import { InputError } from '../../utils/errors.mts' @@ -41,9 +42,29 @@ describe('exclude-paths', () => { ['tests', 'tests/**'], ['tests/', 'tests/**'], ['tests/*', 'tests/*'], - ['tests/**', 'tests/**/**'], + ['tests/**', 'tests/**'], ])('normalizes %s to %s', (input, expected) => { expect(normalizeExcludePath(input)).toBe(expected) }) }) + + describe('projectIgnorePathsToReachExcludePaths', () => { + it('normalizes positive project ignore paths for Coana', () => { + expect( + projectIgnorePathsToReachExcludePaths(['tests', 'dist/', 'fixtures/**']), + ).toEqual([ + '**/tests', + '**/tests/**', + '**/dist', + '**/dist/**', + 'fixtures/**', + ]) + }) + + it('returns no paths when project ignore paths use negation', () => { + expect( + projectIgnorePathsToReachExcludePaths(['fixtures/**', '!fixtures/keep']), + ).toEqual([]) + }) + }) }) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 7b5e3c20c..f9e6fffd1 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -9,6 +9,7 @@ import { pluralize } from '@socketsecurity/registry/lib/words' import { excludePathToProjectIgnorePath, normalizeExcludePath, + projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' @@ -179,9 +180,15 @@ export async function handleCreateNewScan({ const excludePaths = reach.runReachabilityAnalysis ? reach.excludePaths : [] const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) const coanaExcludeGlobs = excludePaths.map(normalizeExcludePath) + const socketConfigReachExcludeGlobs = excludePaths.length + ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths) + : [] const effectiveSocketConfig = scaExcludeGlobs.length ? { ...socketConfig, + version: socketConfig?.version ?? 2, + issueRules: socketConfig?.issueRules ?? {}, + githubApp: socketConfig?.githubApp ?? {}, projectIgnorePaths: [ ...(socketConfig?.projectIgnorePaths ?? []), ...scaExcludeGlobs, @@ -191,7 +198,11 @@ export async function handleCreateNewScan({ const mergedReachabilityOptions = excludePaths.length ? { ...reach, - reachExcludePaths: [...reach.reachExcludePaths, ...coanaExcludeGlobs], + reachExcludePaths: [ + ...socketConfigReachExcludeGlobs, + ...reach.reachExcludePaths, + ...coanaExcludeGlobs, + ], } : reach diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 54f13f51d..08870aa3a 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -138,6 +138,9 @@ describe('handleCreateNewScan excludePaths', () => { { size: 1 }, { config: { + version: 2, + issueRules: {}, + githubApp: {}, projectIgnorePaths: ['fixtures/**', 'tests/**', 'packages/*/**'], }, cwd: '/repo', @@ -146,7 +149,12 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ reachabilityOptions: expect.objectContaining({ - reachExcludePaths: ['dist', 'tests/**', 'packages/*'], + reachExcludePaths: [ + 'fixtures/**', + 'dist', + 'tests/**', + 'packages/*', + ], }), }), ) diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index bdd5f3261..d878193e0 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -4,6 +4,7 @@ import { pluralize } from '@socketsecurity/registry/lib/words' import { excludePathToProjectIgnorePath, normalizeExcludePath, + projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { outputScanReach } from './output-scan-reach.mts' @@ -62,10 +63,16 @@ export async function handleScanReach({ const { excludePaths } = reachabilityOptions const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) const coanaExcludeGlobs = excludePaths.map(normalizeExcludePath) + const socketConfigReachExcludeGlobs = excludePaths.length + ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths) + : [] const effectiveSocketConfig = scaExcludeGlobs.length ? { ...socketConfig, + version: socketConfig?.version ?? 2, + issueRules: socketConfig?.issueRules ?? {}, + githubApp: socketConfig?.githubApp ?? {}, projectIgnorePaths: [ ...(socketConfig?.projectIgnorePaths ?? []), ...scaExcludeGlobs, @@ -77,6 +84,7 @@ export async function handleScanReach({ ? { ...reachabilityOptions, reachExcludePaths: [ + ...socketConfigReachExcludeGlobs, ...reachabilityOptions.reachExcludePaths, ...coanaExcludeGlobs, ], diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 9af9e3b8e..5ad778bc7 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -9,6 +9,7 @@ const { mockGetPackageFilesForScan, mockOutputScanReach, mockPerformReachabilityAnalysis, + mockSentryInternalsSymbol, } = vi.hoisted(() => ({ mockCheckCommandInput: vi.fn(), mockFetchSupportedScanFileNames: vi.fn(), @@ -16,6 +17,7 @@ const { mockGetPackageFilesForScan: vi.fn(), mockOutputScanReach: vi.fn(), mockPerformReachabilityAnalysis: vi.fn(), + mockSentryInternalsSymbol: Symbol('kInternalsSymbol'), })) vi.mock('./fetch-supported-scan-file-names.mts', () => ({ @@ -32,12 +34,17 @@ vi.mock('./perform-reachability-analysis.mts', () => ({ vi.mock('../../constants.mts', () => ({ default: { + kInternalsSymbol: mockSentryInternalsSymbol, + [mockSentryInternalsSymbol]: { + getSentry: vi.fn(() => undefined), + }, spinner: { start: vi.fn(), stop: vi.fn(), successAndStop: vi.fn(), }, }, + UNKNOWN_ERROR: 'unknown', })) vi.mock('../../utils/check-input.mts', () => ({ @@ -118,6 +125,9 @@ describe('handleScanReach', () => { { npm: { packageJson: { pattern: 'package.json' } } }, { config: { + version: 2, + issueRules: {}, + githubApp: {}, projectIgnorePaths: ['vendor/**', 'tests/**', 'packages/*/**'], }, cwd: '/repo', @@ -126,7 +136,12 @@ describe('handleScanReach', () => { expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ reachabilityOptions: expect.objectContaining({ - reachExcludePaths: ['node_modules', 'tests/**', 'packages/*'], + reachExcludePaths: [ + 'vendor/**', + 'node_modules', + 'tests/**', + 'packages/*', + ], }), }), ) From d855e80759b8b74840f5a49bc8af27789f235ec7 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:31:52 -0700 Subject: [PATCH 03/20] fix(scan): keep full excludes project-root relative --- src/commands/scan/exclude-paths.mts | 77 ++++++++++++++++--- src/commands/scan/exclude-paths.test.mts | 28 ++++++- src/commands/scan/handle-create-new-scan.mts | 14 +++- .../scan/handle-create-new-scan.test.mts | 2 +- src/commands/scan/handle-scan-reach.mts | 14 +++- src/commands/scan/handle-scan-reach.test.mts | 61 ++++++++++++++- 6 files changed, 174 insertions(+), 22 deletions(-) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 4099ebffd..0f800a0ec 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -1,3 +1,5 @@ +import path from 'node:path' + import { InputError } from '../../utils/errors.mts' export function excludePathToProjectIgnorePath(path: string): string { @@ -24,22 +26,73 @@ export function normalizeExcludePath(path: string): string { export function projectIgnorePathsToReachExcludePaths( paths: readonly string[] | undefined, + options: { cwd: string; target: string }, ): string[] { if (!Array.isArray(paths) || paths.some(path => path.includes('!'))) { return [] } - return paths.flatMap(path => { - const firstSlash = path.indexOf('/') - const prefix = - firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : '' - const normalized = stripTrailingSlash( - path.startsWith('/') ? path.slice(1) : path, - ) - const pattern = `${prefix}${normalized}` - return pattern.endsWith('/*') || pattern.endsWith('/**') - ? [pattern] - : [pattern, `${pattern}/**`] - }) + const targetPath = path.isAbsolute(options.target) + ? path.relative(options.cwd, options.target) + : options.target + const targetPattern = toPosixPath(stripTrailingSlash(targetPath)) + return paths.flatMap(path => + projectIgnorePathToReachExcludePaths(path, targetPattern), + ) +} + +function projectIgnorePathToReachExcludePaths( + path: string, + targetPattern: string, +): string[] { + const reachPath = pathRelativeToTarget(path, targetPattern) + if (!reachPath) { + return [] + } + return expandReachExcludePath(reachPath) +} + +function expandReachExcludePath(path: string): string[] { + if (path === '**') { + return ['**'] + } + const firstSlash = path.indexOf('/') + const prefix = firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : '' + const normalized = stripTrailingSlash( + path.startsWith('/') ? path.slice(1) : path, + ) + const pattern = `${prefix}${normalized}` + return pattern.endsWith('/*') || pattern.endsWith('/**') + ? [pattern] + : [pattern, `${pattern}/**`] +} + +function pathRelativeToTarget(path: string, target: string): string | undefined { + const normalized = normalizeProjectIgnorePath(path) + if (target === '.' || target === '') { + return normalized + } + if (normalized === target) { + return '**' + } + const targetPrefix = `${target}/` + if (normalized.startsWith(targetPrefix)) { + return normalized.slice(targetPrefix.length) + } + const recursiveTargetPrefix = `${targetPrefix}**/` + if (normalized.startsWith(recursiveTargetPrefix)) { + return normalized.slice(targetPrefix.length) + } + return undefined +} + +function normalizeProjectIgnorePath(path: string): string { + return stripTrailingSlash( + toPosixPath(path.startsWith('/') ? path.slice(1) : path), + ) +} + +function toPosixPath(path: string): string { + return path.replaceAll('\\', '/') } function stripTrailingSlash(path: string): string { diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index 6ecf4487a..541ed1420 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -51,7 +51,13 @@ describe('exclude-paths', () => { describe('projectIgnorePathsToReachExcludePaths', () => { it('normalizes positive project ignore paths for Coana', () => { expect( - projectIgnorePathsToReachExcludePaths(['tests', 'dist/', 'fixtures/**']), + projectIgnorePathsToReachExcludePaths( + ['tests', 'dist/', 'fixtures/**'], + { + cwd: '/repo', + target: '/repo', + }, + ), ).toEqual([ '**/tests', '**/tests/**', @@ -61,9 +67,27 @@ describe('exclude-paths', () => { ]) }) + it('keeps project-root paths relative to nested Coana targets', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['tests/**', 'apps/api/tests/**', 'apps/api/packages/*/**'], + { + cwd: '/repo', + target: '/repo/apps/api', + }, + ), + ).toEqual(['tests/**', 'packages/*/**']) + }) + it('returns no paths when project ignore paths use negation', () => { expect( - projectIgnorePathsToReachExcludePaths(['fixtures/**', '!fixtures/keep']), + projectIgnorePathsToReachExcludePaths( + ['fixtures/**', '!fixtures/keep'], + { + cwd: '/repo', + target: '/repo', + }, + ), ).toEqual([]) }) }) diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index f9e6fffd1..2f03509e9 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -8,7 +8,6 @@ import { pluralize } from '@socketsecurity/registry/lib/words' import { excludePathToProjectIgnorePath, - normalizeExcludePath, projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' @@ -179,9 +178,18 @@ export async function handleCreateNewScan({ const excludePaths = reach.runReachabilityAnalysis ? reach.excludePaths : [] const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) - const coanaExcludeGlobs = excludePaths.map(normalizeExcludePath) + const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( + scaExcludeGlobs, + { + cwd, + target: targets[0]!, + }, + ) const socketConfigReachExcludeGlobs = excludePaths.length - ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths) + ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { + cwd, + target: targets[0]!, + }) : [] const effectiveSocketConfig = scaExcludeGlobs.length ? { diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 08870aa3a..a5799b673 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -153,7 +153,7 @@ describe('handleCreateNewScan excludePaths', () => { 'fixtures/**', 'dist', 'tests/**', - 'packages/*', + 'packages/*/**', ], }), }), diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index d878193e0..66b3f96da 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -3,7 +3,6 @@ import { pluralize } from '@socketsecurity/registry/lib/words' import { excludePathToProjectIgnorePath, - normalizeExcludePath, projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' @@ -62,9 +61,18 @@ export async function handleScanReach({ const { excludePaths } = reachabilityOptions const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) - const coanaExcludeGlobs = excludePaths.map(normalizeExcludePath) + const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( + scaExcludeGlobs, + { + cwd, + target: targets[0]!, + }, + ) const socketConfigReachExcludeGlobs = excludePaths.length - ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths) + ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { + cwd, + target: targets[0]!, + }) : [] const effectiveSocketConfig = scaExcludeGlobs.length diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 5ad778bc7..8c8c614fb 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -140,10 +140,69 @@ describe('handleScanReach', () => { 'vendor/**', 'node_modules', 'tests/**', - 'packages/*', + 'packages/*/**', ], }), }), ) }) + + it('translates excludePaths from project 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' } } }, + { + config: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: [ + 'vendor/**', + 'apps/api/tests/**', + 'dist/**', + ], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['node_modules', 'tests/**'], + }), + }), + ) + }) }) From 1c948f5354e8ed42d3955a4d074ed117690326c6 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:48:01 -0700 Subject: [PATCH 04/20] docs(scan): document exclude path translation --- src/commands/scan/exclude-paths.mts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 0f800a0ec..bbc1a106a 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -28,9 +28,15 @@ export function projectIgnorePathsToReachExcludePaths( paths: readonly string[] | undefined, options: { cwd: string; target: string }, ): string[] { + // GitHub App-style projectIgnorePaths support negation. Coana's + // --exclude-dirs does not, so keep the existing Coana behavior and let it + // infer config ignores itself when any negation is present. if (!Array.isArray(paths) || paths.some(path => path.includes('!'))) { return [] } + + // projectIgnorePaths are rooted at the project cwd. Coana receives excludes + // relative to its analysis target, so nested target scans need translation. const targetPath = path.isAbsolute(options.target) ? path.relative(options.cwd, options.target) : options.target @@ -71,6 +77,10 @@ function pathRelativeToTarget(path: string, target: string): string | undefined if (target === '.' || target === '') { return normalized } + + // Ignore paths outside the analysis target. They still affect SCA manifest + // discovery through projectIgnorePaths, but Coana cannot exclude directories + // outside the target it is analyzing. if (normalized === target) { return '**' } From 14890dc1b8cbb483ef9bf19b2787f3fa04fb2a96 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:49:23 -0700 Subject: [PATCH 05/20] docs(scan): add exclude helper doc comments --- src/commands/scan/exclude-paths.mts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index bbc1a106a..2976ea130 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -2,11 +2,19 @@ import path from 'node:path' import { InputError } from '../../utils/errors.mts' +/** + * Converts a user-facing full-scan exclude path into the socket.yml + * projectIgnorePaths shape used by SCA manifest discovery. + */ export function excludePathToProjectIgnorePath(path: string): string { const stripped = stripTrailingSlash(path) return stripped.endsWith('/**') ? stripped : `${stripped}/**` } +/** + * Rejects gitignore-style negation patterns for --exclude-paths because the + * flag is a positive full-exclusion list, not a complete ignore language. + */ export function assertNoNegationPatterns(paths: readonly string[]): void { for (const path of paths) { if (path.startsWith('!')) { @@ -17,6 +25,10 @@ export function assertNoNegationPatterns(paths: readonly string[]): void { } } +/** + * Normalizes a reachability exclude path to a recursive directory glob without + * changing explicit one-level or recursive glob suffixes. + */ export function normalizeExcludePath(path: string): string { const stripped = stripTrailingSlash(path) return stripped.endsWith('/*') || stripped.endsWith('/**') @@ -24,6 +36,10 @@ export function normalizeExcludePath(path: string): string { : `${stripped}/**` } +/** + * Translates project-root projectIgnorePaths into Coana --exclude-dirs values, + * which are interpreted relative to the current reachability analysis target. + */ export function projectIgnorePathsToReachExcludePaths( paths: readonly string[] | undefined, options: { cwd: string; target: string }, From 5c8b48f1f4a5a179dd7faa54852076d8167fcf82 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:59:00 -0700 Subject: [PATCH 06/20] refactor(scan): centralize full exclude handling --- src/commands/scan/exclude-paths.mts | 67 ++++++++++++++++++++ src/commands/scan/handle-create-new-scan.mts | 46 ++------------ src/commands/scan/handle-scan-reach.mts | 47 ++------------ 3 files changed, 80 insertions(+), 80 deletions(-) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 2976ea130..341fb0b64 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -2,6 +2,22 @@ import path from 'node:path' import { InputError } from '../../utils/errors.mts' +import type { ReachabilityOptions } from './perform-reachability-analysis.mts' +import type { SocketYml } from '@socketsecurity/config' + +type ApplyFullExcludePathsOptions = { + cwd: string + enabled?: boolean | undefined + reachabilityOptions: ReachabilityOptions + socketConfig: SocketYml | undefined + target: string +} + +type ApplyFullExcludePathsResult = { + effectiveSocketConfig: SocketYml | undefined + mergedReachabilityOptions: ReachabilityOptions +} + /** * Converts a user-facing full-scan exclude path into the socket.yml * projectIgnorePaths shape used by SCA manifest discovery. @@ -36,6 +52,57 @@ export function normalizeExcludePath(path: string): string { : `${stripped}/**` } +/** + * Applies --exclude-paths consistently to SCA manifest discovery and Coana. + */ +export function applyFullExcludePaths({ + cwd, + enabled = true, + reachabilityOptions, + socketConfig, + target, +}: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { + const excludePaths = enabled ? reachabilityOptions.excludePaths : [] + const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) + const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( + scaExcludeGlobs, + { + cwd, + target, + }, + ) + const socketConfigReachExcludeGlobs = excludePaths.length + ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { + cwd, + target, + }) + : [] + const effectiveSocketConfig = scaExcludeGlobs.length + ? { + ...socketConfig, + version: socketConfig?.version ?? 2, + issueRules: socketConfig?.issueRules ?? {}, + githubApp: socketConfig?.githubApp ?? {}, + projectIgnorePaths: [ + ...(socketConfig?.projectIgnorePaths ?? []), + ...scaExcludeGlobs, + ], + } + : socketConfig + const mergedReachabilityOptions = excludePaths.length + ? { + ...reachabilityOptions, + reachExcludePaths: [ + ...socketConfigReachExcludeGlobs, + ...reachabilityOptions.reachExcludePaths, + ...coanaExcludeGlobs, + ], + } + : reachabilityOptions + + return { effectiveSocketConfig, mergedReachabilityOptions } +} + /** * Translates project-root projectIgnorePaths into Coana --exclude-dirs values, * which are interpreted relative to the current reachability analysis target. diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 2f03509e9..244928cf8 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -6,10 +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 { - excludePathToProjectIgnorePath, - projectIgnorePathsToReachExcludePaths, -} from './exclude-paths.mts' +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' @@ -176,43 +173,14 @@ export async function handleCreateNewScan({ ? socketYmlResult.data?.parsed : undefined - const excludePaths = reach.runReachabilityAnalysis ? reach.excludePaths : [] - const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) - const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( - scaExcludeGlobs, - { + const { effectiveSocketConfig, mergedReachabilityOptions } = + applyFullExcludePaths({ cwd, + enabled: reach.runReachabilityAnalysis, + reachabilityOptions: reach, + socketConfig, target: targets[0]!, - }, - ) - const socketConfigReachExcludeGlobs = excludePaths.length - ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { - cwd, - target: targets[0]!, - }) - : [] - const effectiveSocketConfig = scaExcludeGlobs.length - ? { - ...socketConfig, - version: socketConfig?.version ?? 2, - issueRules: socketConfig?.issueRules ?? {}, - githubApp: socketConfig?.githubApp ?? {}, - projectIgnorePaths: [ - ...(socketConfig?.projectIgnorePaths ?? []), - ...scaExcludeGlobs, - ], - } - : socketConfig - const mergedReachabilityOptions = excludePaths.length - ? { - ...reach, - reachExcludePaths: [ - ...socketConfigReachExcludeGlobs, - ...reach.reachExcludePaths, - ...coanaExcludeGlobs, - ], - } - : reach + }) const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { config: effectiveSocketConfig, diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 66b3f96da..34d002b4d 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -1,10 +1,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { pluralize } from '@socketsecurity/registry/lib/words' -import { - excludePathToProjectIgnorePath, - projectIgnorePathsToReachExcludePaths, -} from './exclude-paths.mts' +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' @@ -59,45 +56,13 @@ export async function handleScanReach({ ? socketYmlResult.data?.parsed : undefined - const { excludePaths } = reachabilityOptions - const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) - const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( - scaExcludeGlobs, - { + const { effectiveSocketConfig, mergedReachabilityOptions } = + applyFullExcludePaths({ cwd, + reachabilityOptions, + socketConfig, target: targets[0]!, - }, - ) - const socketConfigReachExcludeGlobs = excludePaths.length - ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { - cwd, - target: targets[0]!, - }) - : [] - - const effectiveSocketConfig = scaExcludeGlobs.length - ? { - ...socketConfig, - version: socketConfig?.version ?? 2, - issueRules: socketConfig?.issueRules ?? {}, - githubApp: socketConfig?.githubApp ?? {}, - projectIgnorePaths: [ - ...(socketConfig?.projectIgnorePaths ?? []), - ...scaExcludeGlobs, - ], - } - : socketConfig - - const mergedReachabilityOptions = excludePaths.length - ? { - ...reachabilityOptions, - reachExcludePaths: [ - ...socketConfigReachExcludeGlobs, - ...reachabilityOptions.reachExcludePaths, - ...coanaExcludeGlobs, - ], - } - : reachabilityOptions + }) const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { config: effectiveSocketConfig, From afaf550b32ac52283b0bd6366e09aa0a1be59cb7 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 16:43:55 -0700 Subject: [PATCH 07/20] feat(scan): allow --exclude-paths without --reach Lift the --reach gate on --exclude-paths so the flag can filter SCA/SBOM manifest discovery on its own. The Coana --exclude-dirs merge happens unconditionally; consumers (handle-create-new-scan) only run reachability when --reach is set, so the merged options are simply unused otherwise. Move excludePaths out of reachabilityFlags into its own excludePathsFlag export so scan create lists it under the main Options block instead of the reach-only section. scan reach keeps it under Reachability Options since the command is reach-only by definition. --- src/commands/scan/cmd-scan-create.mts | 8 +++----- src/commands/scan/cmd-scan-create.test.mts | 16 ++++++---------- src/commands/scan/cmd-scan-reach.mts | 5 +++-- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/exclude-paths.mts | 7 ++++--- src/commands/scan/handle-create-new-scan.mts | 1 - .../scan/handle-create-new-scan.test.mts | 9 +++++++-- src/commands/scan/reachability-flags.mts | 7 +++++-- 8 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 8a83dfa4a..3f6f272db 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -6,7 +6,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { assertNoNegationPatterns } 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' @@ -172,6 +172,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => ` @@ -182,7 +183,7 @@ async function run( ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} Options - ${getFlagListOutput(generalFlags)} + ${getFlagListOutput({ ...generalFlags, ...excludePathsFlag })} Reachability Options (when --reach is used) ${getFlagListOutput(reachabilityFlags)} @@ -471,8 +472,6 @@ async function run( const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) // Validation helpers for better readability. - const hasExcludePaths = excludePaths.length > 0 - const hasReachEcosystems = reachEcosystems.length > 0 const hasReachExcludePaths = reachExcludePaths.length > 0 @@ -495,7 +494,6 @@ async function run( reachVersion !== reachabilityFlags['reachVersion']?.default const isUsingAnyReachabilityFlags = - hasExcludePaths || hasReachEcosystems || hasReachExcludePaths || isUsingNonDefaultAnalytics || diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index f5a72262f..631130891 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 matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. 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 @@ -55,7 +56,6 @@ describe('socket scan create', async () => { --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) - --exclude-paths List of glob patterns to exclude from the entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. 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. @@ -203,18 +203,14 @@ describe('socket scan create', async () => { FLAG_CONFIG, '{"apiToken":"fakeToken"}', ], - 'should fail when --exclude-paths is used without --reach', + 'should succeed when --exclude-paths is used without --reach', async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) expect( code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) + 'should exit with code 0 when --exclude-paths is used standalone', + ).toBe(0) }, ) diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 6a20d78aa..30b0b2970 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -5,7 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { assertNoNegationPatterns } 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' @@ -75,6 +75,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => @@ -89,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 diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index dab3c4198..4871d034f 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,7 +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 entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --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 matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. 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. diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 341fb0b64..2788bc6ea 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -7,7 +7,6 @@ import type { SocketYml } from '@socketsecurity/config' type ApplyFullExcludePathsOptions = { cwd: string - enabled?: boolean | undefined reachabilityOptions: ReachabilityOptions socketConfig: SocketYml | undefined target: string @@ -54,15 +53,17 @@ export function normalizeExcludePath(path: string): string { /** * Applies --exclude-paths consistently to SCA manifest discovery and Coana. + * SCA exclusion always applies when paths are provided. The reachability + * options are merged unconditionally; callers decide whether to actually run + * reachability and consume them. */ export function applyFullExcludePaths({ cwd, - enabled = true, reachabilityOptions, socketConfig, target, }: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { - const excludePaths = enabled ? reachabilityOptions.excludePaths : [] + const { excludePaths } = reachabilityOptions const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( scaExcludeGlobs, diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 244928cf8..352e1549f 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -176,7 +176,6 @@ export async function handleCreateNewScan({ const { effectiveSocketConfig, mergedReachabilityOptions } = applyFullExcludePaths({ cwd, - enabled: reach.runReachabilityAnalysis, reachabilityOptions: reach, socketConfig, 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 index a5799b673..0fb727308 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -160,7 +160,7 @@ describe('handleCreateNewScan excludePaths', () => { ) }) - it('does not apply excludePaths when reachability is disabled', async () => { + it('applies excludePaths to SCA discovery even when reachability is disabled', async () => { await handleCreateNewScan({ autoManifest: false, branchName: 'main', @@ -208,7 +208,12 @@ describe('handleCreateNewScan excludePaths', () => { ['/repo'], { size: 1 }, { - config: { projectIgnorePaths: ['fixtures/**'] }, + config: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['fixtures/**', 'tests/**'], + }, cwd: '/repo', }, ) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 8eac80a8d..7653e2c7f 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -2,13 +2,16 @@ import constants from '../../constants.mts' import type { MeowFlags } from '../../flags.mts' -export const reachabilityFlags: MeowFlags = { +export const excludePathsFlag: MeowFlags = { excludePaths: { type: 'string', isMultiple: true, description: - 'List of glob patterns to exclude from the entire Tier 1 scan, including SCA/SBOM manifest discovery. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + '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 matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', }, +} + +export const reachabilityFlags: MeowFlags = { reachAnalysisMemoryLimit: { type: 'number', default: 8192, From cdd425cc057febf60f175fa73b51c79d73ed1151 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 18:17:19 -0700 Subject: [PATCH 08/20] refactor(scan): drop dead normalizeExcludePath, document **/ expansion Verified against @coana-tech/cli v14.12.219 source: --exclude-dirs is matched via micromatch's isMatch on relative(projectRoot, file) and already auto-appends /** to bare names. So Coana anchors at the project root and does not auto-prefix bare names with **/. Our **/ expansion in expandReachExcludePath is therefore load-bearing only for socket.yml projectIgnorePaths (gitignore semantics: bare names match at any depth) and intentionally redundant for user-supplied --exclude-paths input (already turned into tests/** by excludePathToProjectIgnorePath). Inline a comment explaining the asymmetry, and remove normalizeExcludePath which was exported and tested but had no production callers. --- src/commands/scan/exclude-paths.mts | 17 ++++++----------- src/commands/scan/exclude-paths.test.mts | 12 ------------ 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 2788bc6ea..04a7fd475 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -40,17 +40,6 @@ export function assertNoNegationPatterns(paths: readonly string[]): void { } } -/** - * Normalizes a reachability exclude path to a recursive directory glob without - * changing explicit one-level or recursive glob suffixes. - */ -export function normalizeExcludePath(path: string): string { - const stripped = stripTrailingSlash(path) - return stripped.endsWith('/*') || stripped.endsWith('/**') - ? stripped - : `${stripped}/**` -} - /** * Applies --exclude-paths consistently to SCA manifest discovery and Coana. * SCA exclusion always applies when paths are provided. The reachability @@ -145,6 +134,12 @@ function expandReachExcludePath(path: string): string[] { if (path === '**') { return ['**'] } + // Coana anchors --exclude-dirs at the project root (matches via + // micromatch's isMatch on `relative(projectRoot, file)`), so a bare name + // with no slash would not match nested occurrences. socket.yml + // projectIgnorePaths use gitignore semantics where a bare name matches at + // any depth — bridge the gap by prepending **/ when the input has no path + // separator. Inputs that already contain a slash are kept anchored. const firstSlash = path.indexOf('/') const prefix = firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : '' const normalized = stripTrailingSlash( diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index 541ed1420..174814fa2 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { assertNoNegationPatterns, excludePathToProjectIgnorePath, - normalizeExcludePath, projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' import { InputError } from '../../utils/errors.mts' @@ -37,17 +36,6 @@ describe('exclude-paths', () => { }) }) - describe('normalizeExcludePath', () => { - it.each([ - ['tests', 'tests/**'], - ['tests/', 'tests/**'], - ['tests/*', 'tests/*'], - ['tests/**', 'tests/**'], - ])('normalizes %s to %s', (input, expected) => { - expect(normalizeExcludePath(input)).toBe(expected) - }) - }) - describe('projectIgnorePathsToReachExcludePaths', () => { it('normalizes positive project ignore paths for Coana', () => { expect( From d188d3c7e64cf4b271e4b2ceaaa59f94410c2caa Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Wed, 6 May 2026 10:52:05 -0700 Subject: [PATCH 09/20] refactor(scan): make --exclude-paths verbatim anchored from project root Previously --exclude-paths followed gitignore-style semantics on the reachability side: a bare name like `tests` was bridged to `**/tests` and emitted as both `**/tests` and `**/tests/**` so it would match at any depth, mirroring how socket.yml projectIgnorePaths behave for SCA. That made the flag a different dialect from --reach-exclude-paths (which is anchored micromatch from the analysis target) even though the two share the same downstream sink. Users had to learn two languages to write equivalent exclusions. Switch --exclude-paths to anchored micromatch from the project root -- the same dialect as --reach-exclude-paths, just anchored at cwd instead of the analysis target. `tests` now matches only `./tests`; users write `**/tests` themselves to match at any depth. Implementation: - Drop expandReachExcludePath (the **/ prefix bridge and dual-pattern emission). User input flows through projectIgnorePathsToReachExcludePaths with target re-anchoring only. - Drop dead recursiveTargetPrefix branch in pathRelativeToTarget; the prior startsWith(targetPrefix) branch already covered the same case. - Keep excludePathToProjectIgnorePath as a SCA-side adapter. socket.yml's gitignore matcher (ignorePatternToMinimatch in glob.mts) translates a bare `tests` to `**/tests`, so we anchor by appending `/**` before the pattern reaches projectIgnorePaths. - Reorder functions in exclude-paths.mts: private helpers first, exported functions next, alphabetical within each group. - Align negation detection: projectIgnorePathsToReachExcludePaths now uses startsWith('\!') to match assertNoNegationPatterns. Side effect: the socket.yml -> reachability forwarding path no longer applies the **/ bridge. This is a behavior change only for users who both have bare-name entries in socket.yml projectIgnorePaths and use --exclude-paths. Without --exclude-paths, coana's own inferExcludeDirsFromConfigurationFiles already reads those entries verbatim (no **/ prefix), so dropping our bridge actually aligns the forwarding path with coana's native behavior. Help text updated. Snapshots in cmd-scan-create.test.mts and cmd-scan-reach.test.mts refreshed. Three new exclude-paths.test.mts cases lock in: literal "." target equivalence, trailing-slash inputs under nested targets, and the SCA-vs-reach asymmetry when socket.yml contains negation patterns. --- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/exclude-paths.mts | 185 ++++++++---------- src/commands/scan/exclude-paths.test.mts | 92 ++++++++- .../scan/handle-create-new-scan.test.mts | 4 +- src/commands/scan/handle-scan-reach.test.mts | 6 +- src/commands/scan/reachability-flags.mts | 2 +- 7 files changed, 167 insertions(+), 126 deletions(-) diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 631130891..195cf46ca 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -40,7 +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 matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --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 project root: \`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 diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 4871d034f..f21d8dddd 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,7 +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 matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --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 project root: \`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. diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 04a7fd475..18009860e 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -17,34 +17,47 @@ type ApplyFullExcludePathsResult = { mergedReachabilityOptions: ReachabilityOptions } -/** - * Converts a user-facing full-scan exclude path into the socket.yml - * projectIgnorePaths shape used by SCA manifest discovery. - */ -export function excludePathToProjectIgnorePath(path: string): string { - const stripped = stripTrailingSlash(path) - return stripped.endsWith('/**') ? stripped : `${stripped}/**` +function normalizeProjectIgnorePath(path: string): string { + return stripTrailingSlash( + toPosixPath(path.startsWith('/') ? path.slice(1) : path), + ) } -/** - * Rejects gitignore-style negation patterns for --exclude-paths because the - * flag is a positive full-exclusion list, not a complete ignore language. - */ -export function assertNoNegationPatterns(paths: readonly string[]): void { - for (const path of paths) { - if (path.startsWith('!')) { - throw new InputError( - `--exclude-paths does not support negation patterns. Got: '${path}'.`, - ) - } +function pathRelativeToTarget( + path: string, + target: string, +): string | undefined { + const normalized = normalizeProjectIgnorePath(path) + if (target === '.' || target === '') { + return normalized + } + if (normalized === target) { + return '**' } + const targetPrefix = `${target}/` + if (normalized.startsWith(targetPrefix)) { + return normalized.slice(targetPrefix.length) + } + return undefined +} + +function stripTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path +} + +function toPosixPath(path: string): string { + return path.replaceAll('\\', '/') } /** - * Applies --exclude-paths consistently to SCA manifest discovery and Coana. - * SCA exclusion always applies when paths are provided. The reachability - * options are merged unconditionally; callers decide whether to actually run - * reachability and consume them. + * Fans --exclude-paths out to both exclusion sinks: the SCA manifest-discovery + * pipeline (via socket.yml `projectIgnorePaths`) and the reachability analyzer + * (via `reachExcludePaths`, ultimately coana's --exclude-dirs). Existing + * `projectIgnorePaths` from socket.yml are also forwarded to reachability so + * coana sees the same effective set on both sides — coana's own socket.yml + * inference fires only when --exclude-dirs isn't passed, so once we forward + * the user's patterns we have to also forward the existing ones to keep the + * analyzer's view in sync. */ export function applyFullExcludePaths({ cwd, @@ -55,11 +68,8 @@ export function applyFullExcludePaths({ const { excludePaths } = reachabilityOptions const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( - scaExcludeGlobs, - { - cwd, - target, - }, + excludePaths, + { cwd, target }, ) const socketConfigReachExcludeGlobs = excludePaths.length ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { @@ -94,96 +104,55 @@ export function applyFullExcludePaths({ } /** - * Translates project-root projectIgnorePaths into Coana --exclude-dirs values, - * which are interpreted relative to the current reachability analysis target. + * Rejects gitignore-style negation patterns for --exclude-paths. The flag is + * a positive exclusion list; coana's --exclude-dirs has no negation form, so + * accepting `!path` would be a lie on the reachability side. + */ +export function assertNoNegationPatterns(paths: readonly string[]): void { + for (const path of paths) { + if (path.startsWith('!')) { + throw new InputError( + `--exclude-paths does not support negation patterns. Got: '${path}'.`, + ) + } + } +} + +/** + * SCA-side adapter. The user-facing contract for --exclude-paths is anchored + * micromatch from the project root, but socket.yml `projectIgnorePaths` is + * gitignore-style and expands a bare name to a match-anywhere pattern. Append + * `/**` so the pattern contains a slash and gets anchored by the gitignore + * translator, matching files under the named directory at the user-specified + * depth instead of any depth. + */ +export function excludePathToProjectIgnorePath(path: string): string { + const stripped = stripTrailingSlash(path) + return stripped.endsWith('/**') ? stripped : `${stripped}/**` +} + +/** + * Re-anchors project-root 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, project-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[] { - // GitHub App-style projectIgnorePaths support negation. Coana's - // --exclude-dirs does not, so keep the existing Coana behavior and let it - // infer config ignores itself when any negation is present. - if (!Array.isArray(paths) || paths.some(path => path.includes('!'))) { + if (!Array.isArray(paths) || paths.some(p => p.startsWith('!'))) { return [] } - - // projectIgnorePaths are rooted at the project cwd. Coana receives excludes - // relative to its analysis target, so nested target scans need translation. const targetPath = path.isAbsolute(options.target) ? path.relative(options.cwd, options.target) : options.target const targetPattern = toPosixPath(stripTrailingSlash(targetPath)) - return paths.flatMap(path => - projectIgnorePathToReachExcludePaths(path, targetPattern), - ) -} - -function projectIgnorePathToReachExcludePaths( - path: string, - targetPattern: string, -): string[] { - const reachPath = pathRelativeToTarget(path, targetPattern) - if (!reachPath) { - return [] - } - return expandReachExcludePath(reachPath) -} - -function expandReachExcludePath(path: string): string[] { - if (path === '**') { - return ['**'] - } - // Coana anchors --exclude-dirs at the project root (matches via - // micromatch's isMatch on `relative(projectRoot, file)`), so a bare name - // with no slash would not match nested occurrences. socket.yml - // projectIgnorePaths use gitignore semantics where a bare name matches at - // any depth — bridge the gap by prepending **/ when the input has no path - // separator. Inputs that already contain a slash are kept anchored. - const firstSlash = path.indexOf('/') - const prefix = firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : '' - const normalized = stripTrailingSlash( - path.startsWith('/') ? path.slice(1) : path, - ) - const pattern = `${prefix}${normalized}` - return pattern.endsWith('/*') || pattern.endsWith('/**') - ? [pattern] - : [pattern, `${pattern}/**`] -} - -function pathRelativeToTarget(path: string, target: string): string | undefined { - const normalized = normalizeProjectIgnorePath(path) - if (target === '.' || target === '') { - return normalized - } - - // Ignore paths outside the analysis target. They still affect SCA manifest - // discovery through projectIgnorePaths, but Coana cannot exclude directories - // outside the target it is analyzing. - if (normalized === target) { - return '**' - } - const targetPrefix = `${target}/` - if (normalized.startsWith(targetPrefix)) { - return normalized.slice(targetPrefix.length) - } - const recursiveTargetPrefix = `${targetPrefix}**/` - if (normalized.startsWith(recursiveTargetPrefix)) { - return normalized.slice(targetPrefix.length) - } - return undefined -} - -function normalizeProjectIgnorePath(path: string): string { - return stripTrailingSlash( - toPosixPath(path.startsWith('/') ? path.slice(1) : path), - ) -} - -function toPosixPath(path: string): string { - return path.replaceAll('\\', '/') -} - -function stripTrailingSlash(path: string): string { - return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path + 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 index 174814fa2..59c24cf5c 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -1,12 +1,42 @@ import { describe, expect, it } from 'vitest' import { + applyFullExcludePaths, assertNoNegationPatterns, excludePathToProjectIgnorePath, 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('assertNoNegationPatterns', () => { it('allows positive patterns', () => { @@ -31,13 +61,13 @@ describe('exclude-paths', () => { ['tests', 'tests/**'], ['tests/', 'tests/**'], ['tests/**', 'tests/**'], - ])('converts %s to %s', (input, expected) => { + ])('anchors %s as %s for the SCA gitignore matcher', (input, expected) => { expect(excludePathToProjectIgnorePath(input)).toBe(expected) }) }) describe('projectIgnorePathsToReachExcludePaths', () => { - it('normalizes positive project ignore paths for Coana', () => { + it('passes patterns through verbatim when target equals project root', () => { expect( projectIgnorePathsToReachExcludePaths( ['tests', 'dist/', 'fixtures/**'], @@ -46,16 +76,19 @@ describe('exclude-paths', () => { target: '/repo', }, ), - ).toEqual([ - '**/tests', - '**/tests/**', - '**/dist', - '**/dist/**', - 'fixtures/**', - ]) + ).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('keeps project-root paths relative to nested Coana targets', () => { + it('strips the target prefix and drops out-of-target patterns for nested targets', () => { expect( projectIgnorePathsToReachExcludePaths( ['tests/**', 'apps/api/tests/**', 'apps/api/packages/*/**'], @@ -67,6 +100,18 @@ describe('exclude-paths', () => { ).toEqual(['tests/**', 'packages/*/**']) }) + 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( @@ -79,4 +124,31 @@ describe('exclude-paths', () => { ).toEqual([]) }) }) + + describe('applyFullExcludePaths', () => { + it('keeps socket.yml negation entries on the SCA side but drops them from the reachability forwarding', () => { + const result = applyFullExcludePaths({ + cwd: '/repo', + reachabilityOptions: makeReachOptions({ + excludePaths: ['tests'], + }), + socketConfig: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['fixtures/**', '!fixtures/keep'], + }, + target: '/repo', + }) + + expect(result.effectiveSocketConfig?.projectIgnorePaths).toEqual([ + 'fixtures/**', + '!fixtures/keep', + 'tests/**', + ]) + expect(result.mergedReachabilityOptions.reachExcludePaths).toEqual([ + 'tests', + ]) + }) + }) }) diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 0fb727308..ce4927854 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -152,8 +152,8 @@ describe('handleCreateNewScan excludePaths', () => { reachExcludePaths: [ 'fixtures/**', 'dist', - 'tests/**', - 'packages/*/**', + 'tests', + 'packages/*', ], }), }), diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 8c8c614fb..1fd12cc02 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -139,8 +139,8 @@ describe('handleScanReach', () => { reachExcludePaths: [ 'vendor/**', 'node_modules', - 'tests/**', - 'packages/*/**', + 'tests', + 'packages/*', ], }), }), @@ -200,7 +200,7 @@ describe('handleScanReach', () => { expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ reachabilityOptions: expect.objectContaining({ - reachExcludePaths: ['node_modules', 'tests/**'], + reachExcludePaths: ['node_modules', 'tests'], }), }), ) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 7653e2c7f..d81da4e8d 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -7,7 +7,7 @@ export const excludePathsFlag: MeowFlags = { 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 matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + '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 project root: `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.', }, } From 51c6cb5cf5c20b8c78e650813a14832abbbdc527 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 11:37:17 +0200 Subject: [PATCH 10/20] Fix exclude path reachability translation --- src/commands/scan/cmd-scan-create.test.mts | 30 +++++++- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/exclude-paths.mts | 43 +++++------ src/commands/scan/exclude-paths.test.mts | 20 ++++- .../scan/handle-create-new-scan.test.mts | 74 ++++++++++++++++++- src/commands/scan/handle-scan-reach.test.mts | 63 ++++++++++++++-- src/commands/scan/reachability-flags.mts | 2 +- 7 files changed, 198 insertions(+), 36 deletions(-) diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 195cf46ca..ca8a7f25e 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -40,7 +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 project root: \`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. + --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 @@ -214,6 +214,34 @@ describe('socket scan create', async () => { }, ) + 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', diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index f21d8dddd..47dff750d 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,7 +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 project root: \`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. + --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. diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 18009860e..9cf14b362 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -23,21 +23,33 @@ function normalizeProjectIgnorePath(path: string): string { ) } +/** + * 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; and for paths outside the target we 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) { - return '**' + // Whole target excluded: manifest discovery should stop before Coana runs. + return undefined } 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 } @@ -52,12 +64,9 @@ function toPosixPath(path: string): string { /** * Fans --exclude-paths out to both exclusion sinks: the SCA manifest-discovery * pipeline (via socket.yml `projectIgnorePaths`) and the reachability analyzer - * (via `reachExcludePaths`, ultimately coana's --exclude-dirs). Existing - * `projectIgnorePaths` from socket.yml are also forwarded to reachability so - * coana sees the same effective set on both sides — coana's own socket.yml - * inference fires only when --exclude-dirs isn't passed, so once we forward - * the user's patterns we have to also forward the existing ones to keep the - * analyzer's view in sync. + * (via `reachExcludePaths`, ultimately coana's --exclude-dirs). This only + * translates user-provided --exclude-paths; existing socket.yml + * `projectIgnorePaths` keep their previous reachability behavior. */ export function applyFullExcludePaths({ cwd, @@ -71,12 +80,6 @@ export function applyFullExcludePaths({ excludePaths, { cwd, target }, ) - const socketConfigReachExcludeGlobs = excludePaths.length - ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { - cwd, - target, - }) - : [] const effectiveSocketConfig = scaExcludeGlobs.length ? { ...socketConfig, @@ -93,7 +96,6 @@ export function applyFullExcludePaths({ ? { ...reachabilityOptions, reachExcludePaths: [ - ...socketConfigReachExcludeGlobs, ...reachabilityOptions.reachExcludePaths, ...coanaExcludeGlobs, ], @@ -120,7 +122,7 @@ export function assertNoNegationPatterns(paths: readonly string[]): void { /** * SCA-side adapter. The user-facing contract for --exclude-paths is anchored - * micromatch from the project root, but socket.yml `projectIgnorePaths` is + * micromatch from the Socket scan root, but socket.yml `projectIgnorePaths` is * gitignore-style and expands a bare name to a match-anywhere pattern. Append * `/**` so the pattern contains a slash and gets anchored by the gitignore * translator, matching files under the named directory at the user-specified @@ -132,9 +134,9 @@ export function excludePathToProjectIgnorePath(path: string): string { } /** - * Re-anchors project-root patterns onto the reachability analysis target. + * Re-anchors Socket-scan-root 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, project-root + * 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 @@ -147,10 +149,9 @@ export function projectIgnorePathsToReachExcludePaths( if (!Array.isArray(paths) || paths.some(p => p.startsWith('!'))) { return [] } - const targetPath = path.isAbsolute(options.target) - ? path.relative(options.cwd, options.target) - : options.target - const targetPattern = toPosixPath(stripTrailingSlash(targetPath)) + 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 index 59c24cf5c..947116e36 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -88,6 +88,24 @@ describe('exclude-paths', () => { ).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( @@ -126,7 +144,7 @@ describe('exclude-paths', () => { }) describe('applyFullExcludePaths', () => { - it('keeps socket.yml negation entries on the SCA side but drops them from the reachability forwarding', () => { + it('keeps socket.yml projectIgnorePaths on the SCA side without forwarding them to reachability', () => { const result = applyFullExcludePaths({ cwd: '/repo', reachabilityOptions: makeReachOptions({ diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index ce4927854..3bd5c6e54 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -149,12 +149,78 @@ describe('handleCreateNewScan excludePaths', () => { expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ reachabilityOptions: expect.objectContaining({ - reachExcludePaths: [ + 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 }, + { + config: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: [ 'fixtures/**', - 'dist', - 'tests', - 'packages/*', + 'apps/api/tests/**', + 'dist/**', ], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + target: '/repo/apps/api', + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: ['node_modules', 'tests'], }), }), ) diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 1fd12cc02..6b721f62f 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -136,18 +136,13 @@ describe('handleScanReach', () => { expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ reachabilityOptions: expect.objectContaining({ - reachExcludePaths: [ - 'vendor/**', - 'node_modules', - 'tests', - 'packages/*', - ], + reachExcludePaths: ['node_modules', 'tests', 'packages/*'], }), }), ) }) - it('translates excludePaths from project root for nested targets', async () => { + it('translates excludePaths from the scan root for nested targets', async () => { const reachabilityOptions = { excludePaths: ['apps/api/tests', 'dist'], reachAnalysisMemoryLimit: 8192, @@ -205,4 +200,58 @@ describe('handleScanReach', () => { }), ) }) + + 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' } } }, + { + config: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['vendor/**', 'apps/api/**'], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() + }) }) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index d81da4e8d..8e7813e16 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -7,7 +7,7 @@ export const excludePathsFlag: MeowFlags = { 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 project root: `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.', + '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.', }, } From 0d3f305d9cebbb31062eabb6f8bf3b0ba6e8139c Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 11:57:29 +0200 Subject: [PATCH 11/20] Test scan create target exclusion behavior --- .../scan/handle-create-new-scan.test.mts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 3bd5c6e54..2be1c2403 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -285,4 +285,66 @@ describe('handleCreateNewScan excludePaths', () => { ) 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 }, + { + config: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['fixtures/**', 'apps/api/**'], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() + }) }) From 0ae1a5aefeae6edc37b8882724589018ea638d49 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 13:37:12 +0200 Subject: [PATCH 12/20] chore: gitignore stray .Trash directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 18835274daa98f7eceb633e300091ad7ae4ab6f2 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 13:37:13 +0200 Subject: [PATCH 13/20] refactor(scan): route --exclude-paths through a dedicated minimatch ignore channel Previously --exclude-paths patterns were appended with /** and merged into socketConfig.projectIgnorePaths so the gitignore translator would anchor them. The composition `tests` -> `tests/**` -> `tests/**/*` happened to work for non-star patterns, but `packages/*` -> `packages/*/**` -> `packages/*/**/*` only matched paths >=3 segments deep under packages/, silently leaving direct file children like packages/stray.json in the scan. Stop piggybacking CLI patterns on the gitignore translator. The new helper excludePathToScanIgnores returns ready-to-use minimatch patterns that fan out a user pattern into its entry form plus a /** subtree form. globWithGitIgnore gains an additionalIgnores option that bypasses the ignore() matcher in the streaming-negation path, keeping CLI patterns anchored regardless of whether nested .gitignore files contain negations. applyFullExcludePaths no longer synthesizes a SocketYml with default version/issueRules/githubApp fields; the user's socket.yml is passed through unchanged. --- src/commands/scan/exclude-paths.mts | 61 +++++++++---------- src/commands/scan/exclude-paths.test.mts | 40 ++++++------ src/commands/scan/handle-create-new-scan.mts | 3 +- .../scan/handle-create-new-scan.test.mts | 41 ++++--------- src/commands/scan/handle-scan-reach.mts | 3 +- src/commands/scan/handle-scan-reach.test.mts | 33 ++++------ src/utils/glob.mts | 16 ++++- src/utils/glob.test.mts | 49 +++++++++++++++ src/utils/path-resolve.mts | 8 ++- 9 files changed, 147 insertions(+), 107 deletions(-) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 9cf14b362..66f3620ed 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -13,6 +13,7 @@ type ApplyFullExcludePathsOptions = { } type ApplyFullExcludePathsResult = { + additionalScaIgnores: string[] effectiveSocketConfig: SocketYml | undefined mergedReachabilityOptions: ReachabilityOptions } @@ -63,10 +64,10 @@ function toPosixPath(path: string): string { /** * Fans --exclude-paths out to both exclusion sinks: the SCA manifest-discovery - * pipeline (via socket.yml `projectIgnorePaths`) and the reachability analyzer - * (via `reachExcludePaths`, ultimately coana's --exclude-dirs). This only - * translates user-provided --exclude-paths; existing socket.yml - * `projectIgnorePaths` keep their previous reachability behavior. + * pipeline (via the fast-glob ignore set) and the reachability analyzer (via + * `reachExcludePaths`, ultimately coana's --exclude-dirs). The returned + * `additionalScaIgnores` are already in minimatch form and bypass the + * gitignore translator. The user's socket.yml is passed through unchanged. */ export function applyFullExcludePaths({ cwd, @@ -75,23 +76,11 @@ export function applyFullExcludePaths({ target, }: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { const { excludePaths } = reachabilityOptions - const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) - const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( - excludePaths, - { cwd, target }, - ) - const effectiveSocketConfig = scaExcludeGlobs.length - ? { - ...socketConfig, - version: socketConfig?.version ?? 2, - issueRules: socketConfig?.issueRules ?? {}, - githubApp: socketConfig?.githubApp ?? {}, - projectIgnorePaths: [ - ...(socketConfig?.projectIgnorePaths ?? []), - ...scaExcludeGlobs, - ], - } - : socketConfig + const additionalScaIgnores = excludePaths.flatMap(excludePathToScanIgnores) + const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths(excludePaths, { + cwd, + target, + }) const mergedReachabilityOptions = excludePaths.length ? { ...reachabilityOptions, @@ -102,7 +91,11 @@ export function applyFullExcludePaths({ } : reachabilityOptions - return { effectiveSocketConfig, mergedReachabilityOptions } + return { + additionalScaIgnores, + effectiveSocketConfig: socketConfig, + mergedReachabilityOptions, + } } /** @@ -121,16 +114,22 @@ export function assertNoNegationPatterns(paths: readonly string[]): void { } /** - * SCA-side adapter. The user-facing contract for --exclude-paths is anchored - * micromatch from the Socket scan root, but socket.yml `projectIgnorePaths` is - * gitignore-style and expands a bare name to a match-anywhere pattern. Append - * `/**` so the pattern contains a slash and gets anchored by the gitignore - * translator, matching files under the named directory at the user-specified - * depth instead of any depth. + * 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 excludePathToProjectIgnorePath(path: string): string { - const stripped = stripTrailingSlash(path) - return stripped.endsWith('/**') ? stripped : `${stripped}/**` +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}/**`] } /** diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index 947116e36..90dedb4e2 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { applyFullExcludePaths, assertNoNegationPatterns, - excludePathToProjectIgnorePath, + excludePathToScanIgnores, projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' import { InputError } from '../../utils/errors.mts' @@ -55,14 +55,14 @@ describe('exclude-paths', () => { }) }) - describe('excludePathToProjectIgnorePath', () => { - it.each([ - ['packages/*', 'packages/*/**'], - ['tests', 'tests/**'], - ['tests/', 'tests/**'], - ['tests/**', 'tests/**'], - ])('anchors %s as %s for the SCA gitignore matcher', (input, expected) => { - expect(excludePathToProjectIgnorePath(input)).toBe(expected) + 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) }) }) @@ -144,26 +144,24 @@ describe('exclude-paths', () => { }) describe('applyFullExcludePaths', () => { - it('keeps socket.yml projectIgnorePaths on the SCA side without forwarding them to reachability', () => { + it('routes exclude-paths through additionalScaIgnores and leaves socket.yml untouched', () => { + const socketConfig = { + version: 2 as const, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['fixtures/**', '!fixtures/keep'], + } const result = applyFullExcludePaths({ cwd: '/repo', reachabilityOptions: makeReachOptions({ excludePaths: ['tests'], }), - socketConfig: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: ['fixtures/**', '!fixtures/keep'], - }, + socketConfig, target: '/repo', }) - expect(result.effectiveSocketConfig?.projectIgnorePaths).toEqual([ - 'fixtures/**', - '!fixtures/keep', - 'tests/**', - ]) + expect(result.additionalScaIgnores).toEqual(['tests', 'tests/**']) + expect(result.effectiveSocketConfig).toBe(socketConfig) 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 352e1549f..91cf28f39 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -173,7 +173,7 @@ export async function handleCreateNewScan({ ? socketYmlResult.data?.parsed : undefined - const { effectiveSocketConfig, mergedReachabilityOptions } = + const { additionalScaIgnores, effectiveSocketConfig, mergedReachabilityOptions } = applyFullExcludePaths({ cwd, reachabilityOptions: reach, @@ -182,6 +182,7 @@ export async function handleCreateNewScan({ }) const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { + additionalIgnores: additionalScaIgnores, config: effectiveSocketConfig, cwd, }) diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 2be1c2403..7c41ff268 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -137,12 +137,8 @@ describe('handleCreateNewScan excludePaths', () => { ['/repo'], { size: 1 }, { - config: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: ['fixtures/**', 'tests/**', 'packages/*/**'], - }, + additionalIgnores: ['tests', 'tests/**', 'packages/*', 'packages/*/**'], + config: { projectIgnorePaths: ['fixtures/**'] }, cwd: '/repo', }, ) @@ -203,16 +199,13 @@ describe('handleCreateNewScan excludePaths', () => { ['/repo/apps/api'], { size: 1 }, { - config: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: [ - 'fixtures/**', - 'apps/api/tests/**', - 'dist/**', - ], - }, + additionalIgnores: [ + 'apps/api/tests', + 'apps/api/tests/**', + 'dist', + 'dist/**', + ], + config: { projectIgnorePaths: ['fixtures/**'] }, cwd: '/repo', }, ) @@ -274,12 +267,8 @@ describe('handleCreateNewScan excludePaths', () => { ['/repo'], { size: 1 }, { - config: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: ['fixtures/**', 'tests/**'], - }, + additionalIgnores: ['tests', 'tests/**'], + config: { projectIgnorePaths: ['fixtures/**'] }, cwd: '/repo', }, ) @@ -336,12 +325,8 @@ describe('handleCreateNewScan excludePaths', () => { ['/repo/apps/api'], { size: 1 }, { - config: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: ['fixtures/**', 'apps/api/**'], - }, + additionalIgnores: ['apps/api', 'apps/api/**'], + config: { projectIgnorePaths: ['fixtures/**'] }, cwd: '/repo', }, ) diff --git a/src/commands/scan/handle-scan-reach.mts b/src/commands/scan/handle-scan-reach.mts index 34d002b4d..22fea17db 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -56,7 +56,7 @@ export async function handleScanReach({ ? socketYmlResult.data?.parsed : undefined - const { effectiveSocketConfig, mergedReachabilityOptions } = + const { additionalScaIgnores, effectiveSocketConfig, mergedReachabilityOptions } = applyFullExcludePaths({ cwd, reachabilityOptions, @@ -65,6 +65,7 @@ export async function handleScanReach({ }) const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { + additionalIgnores: additionalScaIgnores, config: effectiveSocketConfig, cwd, }) diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index 6b721f62f..c17c815dd 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -124,12 +124,8 @@ describe('handleScanReach', () => { ['.'], { npm: { packageJson: { pattern: 'package.json' } } }, { - config: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: ['vendor/**', 'tests/**', 'packages/*/**'], - }, + additionalIgnores: ['tests', 'tests/**', 'packages/*', 'packages/*/**'], + config: { projectIgnorePaths: ['vendor/**'] }, cwd: '/repo', }, ) @@ -179,16 +175,13 @@ describe('handleScanReach', () => { ['/repo/apps/api'], { npm: { packageJson: { pattern: 'package.json' } } }, { - config: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: [ - 'vendor/**', - 'apps/api/tests/**', - 'dist/**', - ], - }, + additionalIgnores: [ + 'apps/api/tests', + 'apps/api/tests/**', + 'dist', + 'dist/**', + ], + config: { projectIgnorePaths: ['vendor/**'] }, cwd: '/repo', }, ) @@ -243,12 +236,8 @@ describe('handleScanReach', () => { ['/repo/apps/api'], { npm: { packageJson: { pattern: 'package.json' } } }, { - config: { - version: 2, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: ['vendor/**', 'apps/api/**'], - }, + additionalIgnores: ['apps/api', 'apps/api/**'], + config: { projectIgnorePaths: ['vendor/**'] }, cwd: '/repo', }, ) diff --git a/src/utils/glob.mts b/src/utils/glob.mts index 06f57c7de..069ee1812 100644 --- a/src/utils/glob.mts +++ b/src/utils/glob.mts @@ -204,6 +204,10 @@ export function getSupportedFilePatterns( } type GlobWithGitIgnoreOptions = GlobOptions & { + // Already-anchored minimatch patterns appended to the ignore set without + // going through the gitignore translator. Use this for CLI-provided + // exclusions whose semantics are anchored micromatch from `cwd`. + 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 +220,7 @@ export async function globWithGitIgnore( options: GlobWithGitIgnoreOptions, ): Promise { const { + additionalIgnores, cwd = process.cwd(), filter, socketConfig, @@ -265,14 +270,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..560192415 100644 --- a/src/utils/glob.test.mts +++ b/src/utils/glob.test.mts @@ -13,6 +13,7 @@ import { globWithGitIgnore, pathsToGlobPatterns, } from './glob.mts' +import { excludePathToScanIgnores } from '../commands/scan/exclude-paths.mts' import type FileSystem from 'mock-fs/lib/filesystem' @@ -201,6 +202,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: excludePathToScanIgnores('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: excludePathToScanIgnores('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..88ccd3dfd 100644 --- a/src/utils/path-resolve.mts +++ b/src/utils/path-resolve.mts @@ -100,6 +100,7 @@ export function findNpmDirPathSync(npmBinPath: string): string | undefined { } export type PackageFilesForScanOptions = { + additionalIgnores?: readonly string[] | undefined cwd?: string | undefined config?: SocketYml | undefined } @@ -109,7 +110,11 @@ export async function getPackageFilesForScan( 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 @@ -122,6 +127,7 @@ export async function getPackageFilesForScan( return await globWithGitIgnore( pathsToGlobPatterns(inputPaths, options?.cwd), { + additionalIgnores, cwd, filter, socketConfig, From 6a385c135d3a2bf3d24d7768b368de4e9c69faed Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 13:57:52 +0200 Subject: [PATCH 14/20] refactor(scan): harden --exclude-paths validation and tighten exclude-paths plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses follow-ups from the C1 fix review: - assertValidExcludePaths (renamed from assertNoNegationPatterns) now also rejects match-everything sentinels (`.`, `**`, `/`, `./`, `/**`, empty), absolute paths (silent no-op on both sinks today), and paths that escape the scan root via `..`. The flag's contract is explicitly relative micromatch from the scan root; sharp edges that produced silent empty scans now fail with an InputError. - applyFullExcludePaths no longer accepts or returns the SocketYml — its output was always the input unchanged after the C1 fix dropped the synthetic merge. Callers pass socketConfig straight to getPackageFilesForScan. - stripTrailingSlash deduplicated; exclude-paths.mts imports the canonical glob.mts copy. - additionalIgnores docstring clarifies it bypasses the gitignore translator and pairs with socketConfig.projectIgnorePaths for the gitignore-style channel. - Handlers gain a test for the socket.yml-absent case to lock in the config: undefined pass-through. - Dropped a dead `excludePaths: string[] | undefined` fragment from the cmd-scan-create flags cast — the flag is read via cli.flags['excludePaths'] later, not destructured. --- src/commands/scan/cmd-scan-create.mts | 5 +- src/commands/scan/cmd-scan-reach.mts | 4 +- src/commands/scan/exclude-paths.mts | 63 +++++++++++++------ src/commands/scan/exclude-paths.test.mts | 47 +++++++++----- src/commands/scan/handle-create-new-scan.mts | 5 +- .../scan/handle-create-new-scan.test.mts | 57 +++++++++++++++++ src/commands/scan/handle-scan-reach.mts | 5 +- src/commands/scan/handle-scan-reach.test.mts | 49 +++++++++++++++ src/utils/glob.mts | 11 ++-- src/utils/path-resolve.mts | 4 ++ 10 files changed, 201 insertions(+), 49 deletions(-) diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 3f6f272db..502559346 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -3,7 +3,7 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' -import { assertNoNegationPatterns } from './exclude-paths.mts' +import { assertValidExcludePaths } from './exclude-paths.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' @@ -281,7 +281,6 @@ async function run( setAsAlertsPage: boolean tmp: boolean // Reachability flags. - excludePaths: string[] | undefined reach: boolean reachAnalysisMemoryLimit: number reachAnalysisTimeout: number @@ -467,7 +466,7 @@ async function run( } const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) - assertNoNegationPatterns(excludePaths) + assertValidExcludePaths(excludePaths) const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 30b0b2970..9c366259f 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -3,7 +3,7 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' -import { assertNoNegationPatterns } from './exclude-paths.mts' +import { assertValidExcludePaths } from './exclude-paths.mts' import { handleScanReach } from './handle-scan-reach.mts' import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' import { suggestTarget } from './suggest_target.mts' @@ -172,7 +172,7 @@ async function run( const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) - assertNoNegationPatterns(excludePaths) + assertValidExcludePaths(excludePaths) // Validate ecosystem values. const reachEcosystems: PURL_Type[] = [] diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 66f3620ed..d57e1b364 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -1,20 +1,18 @@ 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' -import type { SocketYml } from '@socketsecurity/config' type ApplyFullExcludePathsOptions = { cwd: string reachabilityOptions: ReachabilityOptions - socketConfig: SocketYml | undefined target: string } type ApplyFullExcludePathsResult = { additionalScaIgnores: string[] - effectiveSocketConfig: SocketYml | undefined mergedReachabilityOptions: ReachabilityOptions } @@ -54,25 +52,19 @@ function pathRelativeToTarget( return undefined } -function stripTrailingSlash(path: string): string { - return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path -} - function toPosixPath(path: string): string { return path.replaceAll('\\', '/') } /** * Fans --exclude-paths out to both exclusion sinks: the SCA manifest-discovery - * pipeline (via the fast-glob ignore set) and the reachability analyzer (via - * `reachExcludePaths`, ultimately coana's --exclude-dirs). The returned - * `additionalScaIgnores` are already in minimatch form and bypass the - * gitignore translator. The user's socket.yml is passed through unchanged. + * pipeline (via fast-glob's `ignore` option, as already-anchored minimatch + * patterns) and the reachability analyzer (via `reachExcludePaths`, ultimately + * coana's --exclude-dirs). */ export function applyFullExcludePaths({ cwd, reachabilityOptions, - socketConfig, target, }: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { const { excludePaths } = reachabilityOptions @@ -93,26 +85,57 @@ export function applyFullExcludePaths({ return { additionalScaIgnores, - effectiveSocketConfig: socketConfig, 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([ + '', + '.', + './', + './**', + '/', + '**', + '/**', +]) + /** - * Rejects gitignore-style negation patterns for --exclude-paths. The flag is - * a positive exclusion list; coana's --exclude-dirs has no negation form, so - * accepting `!path` would be a lie on the reachability side. + * Validates --exclude-paths entries before they reach either exclusion sink. + * Rejects gitignore-style negations (coana's --exclude-dirs has no negation + * form), absolute paths (`/repo/tests` silently no-ops on both sinks today), + * patterns escaping the scan root via `..`, and degenerate match-everything + * sentinels like `.`, `**`, `/`. */ -export function assertNoNegationPatterns(paths: readonly string[]): void { - for (const path of paths) { - if (path.startsWith('!')) { +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: '${path}'.`, + `--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 diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index 90dedb4e2..7d6afe096 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { applyFullExcludePaths, - assertNoNegationPatterns, + assertValidExcludePaths, excludePathToScanIgnores, projectIgnorePathsToReachExcludePaths, } from './exclude-paths.mts' @@ -38,21 +38,48 @@ function makeReachOptions( } describe('exclude-paths', () => { - describe('assertNoNegationPatterns', () => { + describe('assertValidExcludePaths', () => { it('allows positive patterns', () => { expect(() => - assertNoNegationPatterns(['tests', 'packages/*']), + assertValidExcludePaths(['tests', 'packages/*', 'a/b/c']), ).not.toThrow() }) it('rejects negation patterns', () => { - expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( + expect(() => assertValidExcludePaths(['!tests/keep'])).toThrow( InputError, ) - expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( + 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', () => { @@ -144,24 +171,16 @@ describe('exclude-paths', () => { }) describe('applyFullExcludePaths', () => { - it('routes exclude-paths through additionalScaIgnores and leaves socket.yml untouched', () => { - const socketConfig = { - version: 2 as const, - issueRules: {}, - githubApp: {}, - projectIgnorePaths: ['fixtures/**', '!fixtures/keep'], - } + it('expands exclude-paths into SCA ignores and re-anchored Coana excludes', () => { const result = applyFullExcludePaths({ cwd: '/repo', reachabilityOptions: makeReachOptions({ excludePaths: ['tests'], }), - socketConfig, target: '/repo', }) expect(result.additionalScaIgnores).toEqual(['tests', 'tests/**']) - expect(result.effectiveSocketConfig).toBe(socketConfig) 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 91cf28f39..aa660f58f 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -173,17 +173,16 @@ export async function handleCreateNewScan({ ? socketYmlResult.data?.parsed : undefined - const { additionalScaIgnores, effectiveSocketConfig, mergedReachabilityOptions } = + const { additionalScaIgnores, mergedReachabilityOptions } = applyFullExcludePaths({ cwd, reachabilityOptions: reach, - socketConfig, target: targets[0]!, }) const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { additionalIgnores: additionalScaIgnores, - config: effectiveSocketConfig, + config: socketConfig, cwd, }) diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index 7c41ff268..a3b04ea18 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -332,4 +332,61 @@ describe('handleCreateNewScan excludePaths', () => { ) 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 22fea17db..9df5c2a17 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -56,17 +56,16 @@ export async function handleScanReach({ ? socketYmlResult.data?.parsed : undefined - const { additionalScaIgnores, effectiveSocketConfig, mergedReachabilityOptions } = + const { additionalScaIgnores, mergedReachabilityOptions } = applyFullExcludePaths({ cwd, reachabilityOptions, - socketConfig, target: targets[0]!, }) const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { additionalIgnores: additionalScaIgnores, - config: effectiveSocketConfig, + config: socketConfig, cwd, }) diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index c17c815dd..c15cbeb98 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -44,6 +44,9 @@ vi.mock('../../constants.mts', () => ({ 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', })) @@ -243,4 +246,50 @@ describe('handleScanReach', () => { ) 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/utils/glob.mts b/src/utils/glob.mts index 069ee1812..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,9 +204,12 @@ export function getSupportedFilePatterns( } type GlobWithGitIgnoreOptions = GlobOptions & { - // Already-anchored minimatch patterns appended to the ignore set without - // going through the gitignore translator. Use this for CLI-provided - // exclusions whose semantics are anchored micromatch from `cwd`. + // 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. diff --git a/src/utils/path-resolve.mts b/src/utils/path-resolve.mts index 88ccd3dfd..e18ae88d4 100644 --- a/src/utils/path-resolve.mts +++ b/src/utils/path-resolve.mts @@ -100,6 +100,10 @@ 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 From a2e3799858598fcdd174b654f03bf90b90156982 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 14:43:08 +0200 Subject: [PATCH 15/20] fix(scan): normalize absolute scan targets for excludes --- src/utils/glob.test.mts | 5 ++--- src/utils/path-resolve.mts | 21 ++++++++++++++++++++- src/utils/path-resolve.test.mts | 23 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/utils/glob.test.mts b/src/utils/glob.test.mts index 560192415..fdec8a636 100644 --- a/src/utils/glob.test.mts +++ b/src/utils/glob.test.mts @@ -13,7 +13,6 @@ import { globWithGitIgnore, pathsToGlobPatterns, } from './glob.mts' -import { excludePathToScanIgnores } from '../commands/scan/exclude-paths.mts' import type FileSystem from 'mock-fs/lib/filesystem' @@ -216,7 +215,7 @@ describe('glob utilities', () => { }) const results = await globWithGitIgnore(['**/*.json'], { - additionalIgnores: excludePathToScanIgnores('tests'), + additionalIgnores: ['tests', 'tests/**'], cwd: mockFixturePath, }) @@ -241,7 +240,7 @@ describe('glob utilities', () => { }) const results = await globWithGitIgnore(['**/*.json'], { - additionalIgnores: excludePathToScanIgnores('packages/*'), + additionalIgnores: ['packages/*', 'packages/*/**'], cwd: mockFixturePath, }) diff --git a/src/utils/path-resolve.mts b/src/utils/path-resolve.mts index e18ae88d4..bc1d5340b 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' @@ -109,6 +110,20 @@ export type PackageFilesForScanOptions = { config?: SocketYml | undefined } +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'], @@ -128,8 +143,12 @@ 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, 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`]: '{}', From cb4aea9ee0dcbcb8620bfdd3184670ce4e631642 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 15:20:14 +0200 Subject: [PATCH 16/20] docs(scan): clarify exclude path handling --- src/commands/scan/exclude-paths.mts | 15 +++++++-------- src/utils/path-resolve.mts | 6 ++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index d57e1b364..0b83da216 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -57,10 +57,9 @@ function toPosixPath(path: string): string { } /** - * Fans --exclude-paths out to both exclusion sinks: the SCA manifest-discovery - * pipeline (via fast-glob's `ignore` option, as already-anchored minimatch - * patterns) and the reachability analyzer (via `reachExcludePaths`, ultimately - * coana's --exclude-dirs). + * 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, @@ -105,9 +104,9 @@ 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 (`/repo/tests` silently no-ops on both sinks today), - * patterns escaping the scan root via `..`, and degenerate match-everything - * sentinels like `.`, `**`, `/`. + * 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) { @@ -156,7 +155,7 @@ export function excludePathToScanIgnores(input: string): string[] { } /** - * Re-anchors Socket-scan-root patterns onto the reachability analysis target. + * 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 diff --git a/src/utils/path-resolve.mts b/src/utils/path-resolve.mts index bc1d5340b..247d81ede 100644 --- a/src/utils/path-resolve.mts +++ b/src/utils/path-resolve.mts @@ -110,6 +110,12 @@ export type PackageFilesForScanOptions = { 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 From 869c848b2170cb491e3d539cf463c80546eff484 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 15:27:02 +0200 Subject: [PATCH 17/20] chore(scan): reduce reachability flag diff noise --- src/commands/scan/reachability-flags.mts | 46 ++++++++++++------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 8e7813e16..102a32195 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -2,16 +2,11 @@ import constants from '../../constants.mts' import type { MeowFlags } from '../../flags.mts' -export const excludePathsFlag: MeowFlags = { - excludePaths: { +export const reachabilityFlags: MeowFlags = { + reachVersion: { 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.', + description: `Override the version of @coana-tech/cli used for reachability analysis. Default: ${constants.ENV.INLINED_SOCKET_CLI_COANA_TECH_CLI_VERSION}.`, }, -} - -export const reachabilityFlags: MeowFlags = { reachAnalysisMemoryLimit: { type: 'number', default: 8192, @@ -54,6 +49,11 @@ export const reachabilityFlags: MeowFlags = { description: 'Continue reachability analysis when a workspace contains no source files for its ecosystem. By default, the CLI halts.', }, + reachDisableExternalToolChecks: { + type: 'boolean', + default: false, + description: 'Disable external tool checks during reachability analysis.', + }, reachDebug: { type: 'boolean', default: false, @@ -66,23 +66,24 @@ export const reachabilityFlags: MeowFlags = { description: 'A log file with detailed analysis logs is written to root of each analyzed workspace.', }, - reachDisableAnalysisSplitting: { + reachDisableAnalytics: { type: 'boolean', default: false, - hidden: true, description: - 'Deprecated: Analysis splitting is now disabled by default. This flag is a no-op.', + 'Disable reachability analytics sharing with Socket. Also disables caching-based optimizations.', }, - reachDisableAnalytics: { + reachDisableAnalysisSplitting: { type: 'boolean', default: false, + hidden: true, description: - 'Disable reachability analytics sharing with Socket. Also disables caching-based optimizations.', + 'Deprecated: Analysis splitting is now disabled by default. This flag is a no-op.', }, - reachDisableExternalToolChecks: { + reachEnableAnalysisSplitting: { type: 'boolean', default: false, - description: 'Disable external tool checks during reachability analysis.', + description: + 'Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default.', }, reachEcosystems: { type: 'string', @@ -90,12 +91,6 @@ export const reachabilityFlags: MeowFlags = { description: 'List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems.', }, - reachEnableAnalysisSplitting: { - type: 'boolean', - default: false, - description: - 'Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default.', - }, reachExcludePaths: { type: 'string', isMultiple: true, @@ -120,8 +115,13 @@ export const reachabilityFlags: MeowFlags = { description: 'When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project.', }, - reachVersion: { +} + +export const excludePathsFlag: MeowFlags = { + excludePaths: { type: 'string', - description: `Override the version of @coana-tech/cli used for reachability analysis. Default: ${constants.ENV.INLINED_SOCKET_CLI_COANA_TECH_CLI_VERSION}.`, + 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.', }, } From d91567db2169f671993be2771de887d144050f89 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 11 May 2026 19:02:05 +0200 Subject: [PATCH 18/20] Preserve globstar excludes for nested reach targets --- src/commands/scan/exclude-paths.mts | 10 ++++++++-- src/commands/scan/exclude-paths.test.mts | 9 +++++++++ src/commands/scan/handle-create-new-scan.test.mts | 8 ++++---- src/commands/scan/handle-scan-reach.test.mts | 8 ++++---- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 0b83da216..bed3b4ca2 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -27,8 +27,10 @@ function normalizeProjectIgnorePath(path: string): string { * 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; and for paths outside the target we return - * undefined because Coana cannot exclude directories it is not analyzing. + * 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, @@ -43,6 +45,10 @@ function pathRelativeToTarget( // 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. diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index 7d6afe096..bac07a2fa 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -145,6 +145,15 @@ describe('exclude-paths', () => { ).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( diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts index a3b04ea18..29f5f9974 100644 --- a/src/commands/scan/handle-create-new-scan.test.mts +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -166,7 +166,7 @@ describe('handleCreateNewScan excludePaths', () => { pendingHead: false, pullRequest: 0, reach: { - excludePaths: ['apps/api/tests', 'dist'], + excludePaths: ['apps/api/tests', '**/dist'], reachAnalysisMemoryLimit: 8192, reachAnalysisTimeout: 0, reachConcurrency: 1, @@ -202,8 +202,8 @@ describe('handleCreateNewScan excludePaths', () => { additionalIgnores: [ 'apps/api/tests', 'apps/api/tests/**', - 'dist', - 'dist/**', + '**/dist', + '**/dist/**', ], config: { projectIgnorePaths: ['fixtures/**'] }, cwd: '/repo', @@ -213,7 +213,7 @@ describe('handleCreateNewScan excludePaths', () => { expect.objectContaining({ target: '/repo/apps/api', reachabilityOptions: expect.objectContaining({ - reachExcludePaths: ['node_modules', 'tests'], + reachExcludePaths: ['node_modules', 'tests', '**/dist'], }), }), ) diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts index c15cbeb98..6d94d9a21 100644 --- a/src/commands/scan/handle-scan-reach.test.mts +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -143,7 +143,7 @@ describe('handleScanReach', () => { it('translates excludePaths from the scan root for nested targets', async () => { const reachabilityOptions = { - excludePaths: ['apps/api/tests', 'dist'], + excludePaths: ['apps/api/tests', '**/dist'], reachAnalysisMemoryLimit: 8192, reachAnalysisTimeout: 0, reachConcurrency: 1, @@ -181,8 +181,8 @@ describe('handleScanReach', () => { additionalIgnores: [ 'apps/api/tests', 'apps/api/tests/**', - 'dist', - 'dist/**', + '**/dist', + '**/dist/**', ], config: { projectIgnorePaths: ['vendor/**'] }, cwd: '/repo', @@ -191,7 +191,7 @@ describe('handleScanReach', () => { expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( expect.objectContaining({ reachabilityOptions: expect.objectContaining({ - reachExcludePaths: ['node_modules', 'tests'], + reachExcludePaths: ['node_modules', 'tests', '**/dist'], }), }), ) From b2d9b2b861e522c96a81a25cd27144b97a996973 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Tue, 12 May 2026 08:45:28 +0200 Subject: [PATCH 19/20] Hide deprecated reach exclude flag --- src/commands/install/socket-completion.bash | 4 ++-- src/commands/scan/cmd-scan-create.test.mts | 1 - src/commands/scan/cmd-scan-reach.test.mts | 1 - src/commands/scan/reachability-flags.mts | 3 ++- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/commands/install/socket-completion.bash b/src/commands/install/socket-completion.bash index 5a486e6ef..c03348d49 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 --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 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 --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]="--exclude-paths --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" [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.test.mts b/src/commands/scan/cmd-scan-create.test.mts index ca8a7f25e..630df8003 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -69,7 +69,6 @@ describe('socket scan create', async () => { --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. - --reach-exclude-paths List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 47dff750d..8ac8bb22c 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -51,7 +51,6 @@ describe('socket scan reach', async () => { --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems. --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysis runs. May improve accuracy, but not recommended by default. - --reach-exclude-paths List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags. --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: . diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 102a32195..c36a39f90 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -94,8 +94,9 @@ export const reachabilityFlags: MeowFlags = { reachExcludePaths: { type: 'string', isMultiple: true, + hidden: true, description: - 'List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags.', + 'Deprecated: use --exclude-paths instead. List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags.', }, reachLazyMode: { type: 'boolean', From 67e387e2453ef869c3f3e3547b139a1c0d9cceff Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Tue, 12 May 2026 08:49:50 +0200 Subject: [PATCH 20/20] Format scan reach helpers --- src/commands/scan/exclude-paths.mts | 12 +++++++----- src/commands/scan/exclude-paths.test.mts | 8 ++------ src/commands/scan/output-scan-reach.mts | 4 +--- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index bed3b4ca2..4f16bcce2 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -74,10 +74,13 @@ export function applyFullExcludePaths({ }: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { const { excludePaths } = reachabilityOptions const additionalScaIgnores = excludePaths.flatMap(excludePathToScanIgnores) - const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths(excludePaths, { - cwd, - target, - }) + const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( + excludePaths, + { + cwd, + target, + }, + ) const mergedReachabilityOptions = excludePaths.length ? { ...reachabilityOptions, @@ -140,7 +143,6 @@ export function assertValidExcludePaths(paths: readonly string[]): void { } } - /** * Expands an anchored-micromatch --exclude-paths entry into the minimatch * patterns fast-glob needs to skip both the matched entry itself (file-shaped diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index bac07a2fa..517773649 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -46,9 +46,7 @@ describe('exclude-paths', () => { }) it('rejects negation patterns', () => { - expect(() => assertValidExcludePaths(['!tests/keep'])).toThrow( - InputError, - ) + expect(() => assertValidExcludePaths(['!tests/keep'])).toThrow(InputError) expect(() => assertValidExcludePaths(['!tests/keep'])).toThrow( "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", ) @@ -66,9 +64,7 @@ describe('exclude-paths', () => { it.each(['/repo/tests', '/etc/passwd'])( 'rejects absolute path %j', input => { - expect(() => assertValidExcludePaths([input])).toThrow( - /absolute path/, - ) + expect(() => assertValidExcludePaths([input])).toThrow(/absolute path/) }, ) diff --git a/src/commands/scan/output-scan-reach.mts b/src/commands/scan/output-scan-reach.mts index 4f1bdb860..dc425e33f 100644 --- a/src/commands/scan/output-scan-reach.mts +++ b/src/commands/scan/output-scan-reach.mts @@ -33,9 +33,7 @@ export async function outputScanReach( logger.info(`Reachability report has been written to: ${actualOutputPath}`) // Warn about individual vulnerabilities where reachability analysis errored. - const errors = extractReachabilityErrors( - result.data.reachabilityReport, - ) + const errors = extractReachabilityErrors(result.data.reachabilityReport) if (errors.length) { logger.log('') logger.warn(