From d1060bc7789fddc869d0d098eb4960826da7c948 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 17:49:07 +0000 Subject: [PATCH 1/7] 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 9a3520c8188515d9ed1c1b307f4fc6d7a777b18d Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:20:31 -0700 Subject: [PATCH 2/7] 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 fc453812a27928e8e23b4bb81bd9a6c70673e858 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:31:52 -0700 Subject: [PATCH 3/7] 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 410c823d957da6bdf42cb39b1a233c810a232f23 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:48:01 -0700 Subject: [PATCH 4/7] 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 e4dedc9f3337ee1d762d864d82bc18e2269d8e7b Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:49:23 -0700 Subject: [PATCH 5/7] 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 239f154d825faf01cd3e7e3ac3e136a03043cdf3 Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 15:59:00 -0700 Subject: [PATCH 6/7] 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 5d80176dcc943e79a57d5b2f20522e6c00bf9eaf Mon Sep 17 00:00:00 2001 From: Simon Jensen Date: Mon, 4 May 2026 16:43:55 -0700 Subject: [PATCH 7/7] 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,