diff --git a/src/commands/ci/handle-ci.mts b/src/commands/ci/handle-ci.mts index 34f72609e..d959e7988 100644 --- a/src/commands/ci/handle-ci.mts +++ b/src/commands/ci/handle-ci.mts @@ -51,6 +51,7 @@ export async function handleCi(autoManifest: boolean): Promise { pendingHead: true, pullRequest: 0, reach: { + excludePaths: [], reachAnalysisMemoryLimit: 0, reachAnalysisTimeout: 0, reachConcurrency: 1, diff --git a/src/commands/install/socket-completion.bash b/src/commands/install/socket-completion.bash index 4619cc7d8..5a486e6ef 100755 --- a/src/commands/install/socket-completion.bash +++ b/src/commands/install/socket-completion.bash @@ -125,12 +125,12 @@ FLAGS=( [repos update]="--default-branch --homepage --interactive --org --repo-description --repo-name --visibility" [repos view]="--interactive --org --repo-name" [scan]="" - [scan create]="--auto-manifest --branch --commit-hash --commit-message --committers --cwd --default-branch --interactive --json --markdown --org --pull-request --reach --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths --read-only --repo --report --set-as-alerts-page --tmp" + [scan create]="--auto-manifest --branch --commit-hash --commit-message --committers --cwd --default-branch --exclude-paths --interactive --json --markdown --org --pull-request --reach --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths --read-only --repo --report --set-as-alerts-page --tmp" [scan del]="--interactive --org" [scan diff]="--depth --file --interactive --org" [scan list]="--branch --direction --from-time --interactive --json --markdown --org --page --per-page --repo --sort --until-time" [scan metadata]="--interactive --org" - [scan reach]="--reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths" + [scan reach]="--exclude-paths --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths" [scan report]="--fold --interactive --license --org --report-level --short" [scan view]="--interactive --org --stream" [threat-feed]="--direction --eco --filter --interactive --json --markdown --org --page --per-page" diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 24c1c98ea..3f6f272db 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -3,9 +3,10 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { 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' @@ -171,6 +172,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => ` @@ -181,7 +183,7 @@ async function run( ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} Options - ${getFlagListOutput(generalFlags)} + ${getFlagListOutput({ ...generalFlags, ...excludePathsFlag })} Reachability Options (when --reach is used) ${getFlagListOutput(reachabilityFlags)} @@ -279,6 +281,7 @@ async function run( setAsAlertsPage: boolean tmp: boolean // Reachability flags. + excludePaths: string[] | undefined reach: boolean reachAnalysisMemoryLimit: number reachAnalysisTimeout: number @@ -463,6 +466,9 @@ async function run( logger.error('') } + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertNoNegationPatterns(excludePaths) + const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) // Validation helpers for better readability. @@ -608,6 +614,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..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 @@ -185,6 +186,34 @@ describe('socket scan create', async () => { }, ) + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--exclude-paths', + 'tests', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should succeed when --exclude-paths is used without --reach', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect( + code, + 'should exit with code 0 when --exclude-paths is used standalone', + ).toBe(0) + }, + ) + cmdit( [ 'scan', @@ -437,6 +466,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..30b0b2970 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -3,8 +3,9 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' +import { 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' @@ -74,6 +75,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => @@ -88,7 +90,7 @@ async function run( ${getFlagListOutput(generalFlags)} Reachability Options - ${getFlagListOutput(reachabilityFlags)} + ${getFlagListOutput({ ...excludePathsFlag, ...reachabilityFlags })} Runs the Socket reachability analysis without creating a scan in Socket. The output is written to .socket.facts.json in the current working directory @@ -167,8 +169,10 @@ async function run( const dryRun = !!cli.flags['dryRun'] // Process comma-separated values for isMultiple flags. + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) + assertNoNegationPatterns(excludePaths) // Validate ecosystem values. const reachEcosystems: PURL_Type[] = [] @@ -272,6 +276,7 @@ async function run( outputKind, outputPath: outputPath || '', reachabilityOptions: { + excludePaths, reachAnalysisMemoryLimit: Number(reachAnalysisMemoryLimit), reachAnalysisTimeout: Number(reachAnalysisTimeout), reachConcurrency: Number(reachConcurrency), diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index f3f67e1d5..4871d034f 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -37,6 +37,7 @@ describe('socket scan reach', async () => { --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory. Reachability Options + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are 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..2788bc6ea --- /dev/null +++ b/src/commands/scan/exclude-paths.mts @@ -0,0 +1,194 @@ +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 + 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. + */ +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('!')) { + throw new InputError( + `--exclude-paths does not support negation patterns. Got: '${path}'.`, + ) + } + } +} + +/** + * 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 + * options are merged unconditionally; callers decide whether to actually run + * reachability and consume them. + */ +export function applyFullExcludePaths({ + cwd, + reachabilityOptions, + socketConfig, + target, +}: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { + const { excludePaths } = reachabilityOptions + 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. + */ +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 + 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 + } + + // 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 +} diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts new file mode 100644 index 000000000..541ed1420 --- /dev/null +++ b/src/commands/scan/exclude-paths.test.mts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { + assertNoNegationPatterns, + excludePathToProjectIgnorePath, + normalizeExcludePath, + projectIgnorePathsToReachExcludePaths, +} 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) + }) + }) + + describe('projectIgnorePathsToReachExcludePaths', () => { + it('normalizes positive project ignore paths for Coana', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['tests', 'dist/', 'fixtures/**'], + { + cwd: '/repo', + target: '/repo', + }, + ), + ).toEqual([ + '**/tests', + '**/tests/**', + '**/dist', + '**/dist/**', + 'fixtures/**', + ]) + }) + + 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'], + { + 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 800d37323..352e1549f 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -6,6 +6,7 @@ import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import { pluralize } from '@socketsecurity/registry/lib/words' +import { applyFullExcludePaths } from './exclude-paths.mts' import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { finalizeTier1Scan } from './finalize-tier1-scan.mts' @@ -172,8 +173,16 @@ export async function handleCreateNewScan({ ? socketYmlResult.data?.parsed : undefined + const { effectiveSocketConfig, mergedReachabilityOptions } = + applyFullExcludePaths({ + cwd, + reachabilityOptions: reach, + socketConfig, + target: targets[0]!, + }) + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: socketConfig, + config: effectiveSocketConfig, cwd, }) @@ -213,7 +222,7 @@ export async function handleCreateNewScan({ logger.error('') logger.info('Starting reachability analysis...') debugFn('notice', 'Reachability analysis enabled') - debugDir('inspect', { reachabilityOptions: reach }) + debugDir('inspect', { reachabilityOptions: mergedReachabilityOptions }) spinner.start() @@ -222,7 +231,7 @@ export async function handleCreateNewScan({ cwd, orgSlug, packagePaths, - reachabilityOptions: reach, + reachabilityOptions: mergedReachabilityOptions, repoName, spinner, target: targets[0]!, diff --git a/src/commands/scan/handle-create-new-scan.test.mts b/src/commands/scan/handle-create-new-scan.test.mts new file mode 100644 index 000000000..0fb727308 --- /dev/null +++ b/src/commands/scan/handle-create-new-scan.test.mts @@ -0,0 +1,222 @@ +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: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['fixtures/**', 'tests/**', 'packages/*/**'], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: [ + 'fixtures/**', + 'dist', + 'tests/**', + 'packages/*/**', + ], + }), + }), + ) + }) + + it('applies excludePaths to SCA discovery even when reachability is disabled', async () => { + await handleCreateNewScan({ + autoManifest: false, + branchName: 'main', + commitHash: '', + commitMessage: '', + committers: '', + cwd: '/repo', + defaultBranch: false, + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + pendingHead: false, + pullRequest: 0, + reach: { + excludePaths: ['tests'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: [], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + runReachabilityAnalysis: false, + }, + readOnly: false, + repoName: 'repo', + report: false, + reportLevel: 'error', + targets: ['/repo'], + tmp: false, + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['/repo'], + { size: 1 }, + { + config: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['fixtures/**', 'tests/**'], + }, + 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..34d002b4d 100644 --- a/src/commands/scan/handle-scan-reach.mts +++ b/src/commands/scan/handle-scan-reach.mts @@ -1,6 +1,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { pluralize } from '@socketsecurity/registry/lib/words' +import { applyFullExcludePaths } from './exclude-paths.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { outputScanReach } from './output-scan-reach.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' @@ -33,7 +34,7 @@ export async function handleScanReach({ }: HandleScanReachConfig) { const { spinner } = constants - // Get supported file names + // Get supported file names. const supportedFilesCResult = await fetchSupportedScanFileNames({ spinner }) if (!supportedFilesCResult.ok) { await outputScanReach(supportedFilesCResult, { @@ -55,8 +56,16 @@ export async function handleScanReach({ ? socketYmlResult.data?.parsed : undefined + const { effectiveSocketConfig, mergedReachabilityOptions } = + applyFullExcludePaths({ + cwd, + reachabilityOptions, + socketConfig, + target: targets[0]!, + }) + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: socketConfig, + config: effectiveSocketConfig, cwd, }) @@ -86,7 +95,7 @@ export async function handleScanReach({ orgSlug, outputPath, packagePaths, - reachabilityOptions, + reachabilityOptions: mergedReachabilityOptions, spinner, target: targets[0]!, uploadManifests: true, diff --git a/src/commands/scan/handle-scan-reach.test.mts b/src/commands/scan/handle-scan-reach.test.mts new file mode 100644 index 000000000..8c8c614fb --- /dev/null +++ b/src/commands/scan/handle-scan-reach.test.mts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { handleScanReach } from './handle-scan-reach.mts' + +const { + mockCheckCommandInput, + mockFetchSupportedScanFileNames, + mockFindSocketYmlSync, + mockGetPackageFilesForScan, + mockOutputScanReach, + mockPerformReachabilityAnalysis, + mockSentryInternalsSymbol, +} = vi.hoisted(() => ({ + mockCheckCommandInput: vi.fn(), + mockFetchSupportedScanFileNames: vi.fn(), + mockFindSocketYmlSync: vi.fn(), + mockGetPackageFilesForScan: vi.fn(), + mockOutputScanReach: vi.fn(), + mockPerformReachabilityAnalysis: vi.fn(), + mockSentryInternalsSymbol: Symbol('kInternalsSymbol'), +})) + +vi.mock('./fetch-supported-scan-file-names.mts', () => ({ + fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, +})) + +vi.mock('./output-scan-reach.mts', () => ({ + outputScanReach: mockOutputScanReach, +})) + +vi.mock('./perform-reachability-analysis.mts', () => ({ + performReachabilityAnalysis: mockPerformReachabilityAnalysis, +})) + +vi.mock('../../constants.mts', () => ({ + default: { + kInternalsSymbol: mockSentryInternalsSymbol, + [mockSentryInternalsSymbol]: { + getSentry: vi.fn(() => undefined), + }, + spinner: { + start: vi.fn(), + stop: vi.fn(), + successAndStop: vi.fn(), + }, + }, + UNKNOWN_ERROR: 'unknown', +})) + +vi.mock('../../utils/check-input.mts', () => ({ + checkCommandInput: mockCheckCommandInput, +})) + +vi.mock('../../utils/config.mts', () => ({ + findSocketYmlSync: mockFindSocketYmlSync, +})) + +vi.mock('../../utils/path-resolve.mts', () => ({ + getPackageFilesForScan: mockGetPackageFilesForScan, +})) + +vi.mock('@socketsecurity/registry/lib/logger', () => ({ + logger: { + success: vi.fn(), + }, +})) + +describe('handleScanReach', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckCommandInput.mockReturnValue(true) + mockFetchSupportedScanFileNames.mockResolvedValue({ + ok: true, + data: { npm: { packageJson: { pattern: 'package.json' } } }, + }) + mockFindSocketYmlSync.mockReturnValue({ + ok: true, + data: { parsed: { projectIgnorePaths: ['vendor/**'] } }, + }) + mockGetPackageFilesForScan.mockResolvedValue(['package.json']) + mockPerformReachabilityAnalysis.mockResolvedValue({ + ok: true, + data: { + reachabilityReport: '.socket.facts.json', + tier1ReachabilityScanId: undefined, + }, + }) + }) + + it('applies excludePaths to manifest discovery and reachability analysis', async () => { + const reachabilityOptions = { + excludePaths: ['tests', 'packages/*'], + reachAnalysisMemoryLimit: 8192, + reachAnalysisTimeout: 0, + reachConcurrency: 1, + reachContinueOnAnalysisErrors: false, + reachContinueOnInstallErrors: false, + reachContinueOnMissingLockFiles: false, + reachContinueOnNoSourceFiles: false, + reachDebug: false, + reachDetailedAnalysisLogFile: false, + reachDisableAnalytics: false, + reachDisableExternalToolChecks: false, + reachEcosystems: [], + reachEnableAnalysisSplitting: false, + reachExcludePaths: ['node_modules'], + reachLazyMode: false, + reachSkipCache: false, + reachUseOnlyPregeneratedSboms: false, + reachVersion: undefined, + } + + await handleScanReach({ + cwd: '/repo', + interactive: false, + orgSlug: 'fakeOrg', + outputKind: 'text', + outputPath: '', + reachabilityOptions, + targets: ['.'], + }) + + expect(mockGetPackageFilesForScan).toHaveBeenCalledWith( + ['.'], + { npm: { packageJson: { pattern: 'package.json' } } }, + { + config: { + version: 2, + issueRules: {}, + githubApp: {}, + projectIgnorePaths: ['vendor/**', 'tests/**', 'packages/*/**'], + }, + cwd: '/repo', + }, + ) + expect(mockPerformReachabilityAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + reachabilityOptions: expect.objectContaining({ + reachExcludePaths: [ + 'vendor/**', + 'node_modules', + 'tests/**', + '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/**'], + }), + }), + ) + }) +}) 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..7653e2c7f 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -2,11 +2,16 @@ import constants from '../../constants.mts' import type { MeowFlags } from '../../flags.mts' -export const reachabilityFlags: MeowFlags = { - 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 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, @@ -49,11 +54,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 +66,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 +73,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 +90,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 +120,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}.`, + }, }