Skip to content

Commit 3cfb459

Browse files
committed
feat(scan): add --exclude-paths flag for full Tier 1 exclusion (port of #1298)
Port of #1298 (originally targeted v1.x by @simonhj) to main. Adds a --exclude-paths flag to socket scan create and socket scan reach that excludes the listed glob patterns from BOTH 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 (tests -> tests/**); trailing slashes are stripped; gitignore-style negation patterns (!path) are rejected up front. Internally, --exclude-paths is wired into projectIgnorePaths for SCA manifest discovery and into Coana's --exclude-dirs for reachability, preserving existing --reach-exclude-paths semantics for users who only need the Coana-side exclusion. Translation notes for v1.x -> main: - @socketsecurity/registry/lib/* -> @socketsecurity/lib/* - ../../utils/errors.mts -> ../../utils/error/errors.mts - co-located tests live under packages/cli/test/{integration,unit}/... - preserved existing test snapshots; only the new --exclude-paths line was added to help-text snapshots. DISABLE_PRECOMMIT_TEST=1 used for this commit because pre-existing unrelated analytics tests are broken on origin/main (verified against a pristine checkout). Type checks and the new exclude-paths unit tests all pass.
1 parent d1a95db commit 3cfb459

15 files changed

Lines changed: 506 additions & 15 deletions

packages/cli/data/socket-completion.bash

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,12 @@ FLAGS=(
125125
[repos update]="--default-branch --homepage --interactive --org --repo-description --repo-name --visibility"
126126
[repos view]="--interactive --org --repo-name"
127127
[scan]=""
128-
[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"
128+
[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"
129129
[scan del]="--interactive --org"
130130
[scan diff]="--depth --file --interactive --org"
131131
[scan list]="--branch --direction --from-time --interactive --json --markdown --org --page --per-page --repo --sort --until-time"
132132
[scan metadata]="--interactive --org"
133-
[scan reach]="--reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths"
133+
[scan reach]="--exclude-paths --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths"
134134
[scan report]="--fold --interactive --license --org --report-level --short"
135135
[scan view]="--interactive --org --stream"
136136
[threat-feed]="--direction --eco --filter --interactive --json --markdown --org --page --per-page"

packages/cli/src/commands/ci/handle-ci.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export async function handleCi(autoManifest: boolean): Promise<void> {
5151
pendingHead: true,
5252
pullRequest: 0,
5353
reach: {
54+
excludePaths: [],
5455
reachAnalysisMemoryLimit: 0,
5556
reachAnalysisTimeout: 0,
5657
reachConcurrency: 1,

packages/cli/src/commands/scan/cmd-scan-create.mts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { getDefaultLogger } from '@socketsecurity/lib/logger'
55

66
const logger = getDefaultLogger()
77

8+
import { assertNoNegationPatterns } from './exclude-paths.mts'
89
import { handleCreateNewScan } from './handle-create-new-scan.mts'
910
import { outputCreateNewScan } from './output-create-new-scan.mts'
10-
import { reachabilityFlags } from './reachability-flags.mts'
11+
import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts'
1112
import { suggestOrgSlug } from './suggest-org-slug.mts'
1213
import { suggestTarget } from './suggest_target.mts'
1314
import { validateReachabilityTarget } from './validate-reachability-target.mts'
@@ -307,6 +308,7 @@ async function run(
307308
hidden,
308309
flags: {
309310
...generalFlags,
311+
...excludePathsFlag,
310312
...reachabilityFlags,
311313
},
312314
help: command => `
@@ -317,7 +319,7 @@ async function run(
317319
${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)}
318320
319321
Options
320-
${getFlagListOutput(generalFlags)}
322+
${getFlagListOutput({ ...generalFlags, ...excludePathsFlag })}
321323
322324
Reachability Options (when --reach is used)
323325
${getFlagListOutput(reachabilityFlags)}
@@ -587,6 +589,9 @@ async function run(
587589
logger.error('')
588590
}
589591

592+
const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths'])
593+
assertNoNegationPatterns(excludePaths)
594+
590595
const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths'])
591596

592597
// Validation helpers for better readability.
@@ -785,6 +790,7 @@ async function run(
785790
pendingHead: Boolean(pendingHead),
786791
pullRequest: validatedPullRequest,
787792
reach: {
793+
excludePaths,
788794
runReachabilityAnalysis: Boolean(reach),
789795
reachAnalysisMemoryLimit: validatedReachAnalysisMemoryLimit,
790796
reachAnalysisTimeout: validatedReachAnalysisTimeout,

packages/cli/src/commands/scan/cmd-scan-reach.mts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import path from 'node:path'
22

33
import { joinAnd } from '@socketsecurity/lib/arrays'
44

5+
import { assertNoNegationPatterns } from './exclude-paths.mts'
56
import { handleScanReach } from './handle-scan-reach.mts'
6-
import { reachabilityFlags } from './reachability-flags.mts'
7+
import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts'
78
import { suggestTarget } from './suggest_target.mts'
89
import { validateReachabilityTarget } from './validate-reachability-target.mts'
910
import { outputDryRunExecute } from '../../utils/dry-run/output.mts'
@@ -98,6 +99,7 @@ async function run(
9899
hidden,
99100
flags: {
100101
...generalFlags,
102+
...excludePathsFlag,
101103
...reachabilityFlags,
102104
},
103105
help: command =>
@@ -112,7 +114,7 @@ async function run(
112114
${getFlagListOutput(generalFlags)}
113115
114116
Reachability Options
115-
${getFlagListOutput(reachabilityFlags)}
117+
${getFlagListOutput({ ...excludePathsFlag, ...reachabilityFlags })}
116118
117119
Runs the Socket reachability analysis without creating a scan in Socket.
118120
The output is written to .socket.facts.json in the current working directory
@@ -164,8 +166,10 @@ async function run(
164166
const dryRun = !!cli.flags['dryRun']
165167

166168
// Process comma-separated values for isMultiple flags.
169+
const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths'])
167170
const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems'])
168171
const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths'])
172+
assertNoNegationPatterns(excludePaths)
169173

170174
// Validate ecosystem values.
171175
const reachEcosystems: PURL_Type[] = []
@@ -313,6 +317,7 @@ async function run(
313317
outputPath: outputPath || '',
314318
targets,
315319
reachabilityOptions: {
320+
excludePaths,
316321
reachAnalysisMemoryLimit: validatedReachAnalysisMemoryLimit,
317322
reachAnalysisTimeout: validatedReachAnalysisTimeout,
318323
reachConcurrency: validatedReachConcurrency,

packages/cli/src/commands/scan/create-scan-from-github.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ async function scanOneRepo(
299299
pendingHead: true,
300300
pullRequest: 0,
301301
reach: {
302+
excludePaths: [],
302303
runReachabilityAnalysis: false,
303304
reachAnalysisMemoryLimit: 0,
304305
reachAnalysisTimeout: 0,
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import path from 'node:path'
2+
3+
import { InputError } from '../../utils/error/errors.mts'
4+
5+
import type { ReachabilityOptions } from './perform-reachability-analysis.mts'
6+
import type { SocketYml } from '@socketsecurity/config'
7+
8+
type ApplyFullExcludePathsOptions = {
9+
cwd: string
10+
reachabilityOptions: ReachabilityOptions
11+
socketConfig: SocketYml | undefined
12+
target: string
13+
}
14+
15+
type ApplyFullExcludePathsResult = {
16+
effectiveSocketConfig: SocketYml | undefined
17+
mergedReachabilityOptions: ReachabilityOptions
18+
}
19+
20+
/**
21+
* Converts a user-facing full-scan exclude path into the socket.yml
22+
* projectIgnorePaths shape used by SCA manifest discovery.
23+
*/
24+
export function excludePathToProjectIgnorePath(path: string): string {
25+
const stripped = stripTrailingSlash(path)
26+
return stripped.endsWith('/**') ? stripped : `${stripped}/**`
27+
}
28+
29+
/**
30+
* Rejects gitignore-style negation patterns for --exclude-paths because the
31+
* flag is a positive full-exclusion list, not a complete ignore language.
32+
*/
33+
export function assertNoNegationPatterns(paths: readonly string[]): void {
34+
for (const path of paths) {
35+
if (path.startsWith('!')) {
36+
throw new InputError(
37+
`--exclude-paths does not support negation patterns. Got: '${path}'.`,
38+
)
39+
}
40+
}
41+
}
42+
43+
/**
44+
* Normalizes a reachability exclude path to a recursive directory glob without
45+
* changing explicit one-level or recursive glob suffixes.
46+
*/
47+
export function normalizeExcludePath(path: string): string {
48+
const stripped = stripTrailingSlash(path)
49+
return stripped.endsWith('/*') || stripped.endsWith('/**')
50+
? stripped
51+
: `${stripped}/**`
52+
}
53+
54+
/**
55+
* Applies --exclude-paths consistently to SCA manifest discovery and Coana.
56+
* SCA exclusion always applies when paths are provided. The reachability
57+
* options are merged unconditionally; callers decide whether to actually run
58+
* reachability and consume them.
59+
*/
60+
export function applyFullExcludePaths({
61+
cwd,
62+
reachabilityOptions,
63+
socketConfig,
64+
target,
65+
}: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult {
66+
const { excludePaths } = reachabilityOptions
67+
const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath)
68+
const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths(
69+
scaExcludeGlobs,
70+
{
71+
cwd,
72+
target,
73+
},
74+
)
75+
const socketConfigReachExcludeGlobs = excludePaths.length
76+
? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, {
77+
cwd,
78+
target,
79+
})
80+
: []
81+
const effectiveSocketConfig = scaExcludeGlobs.length
82+
? {
83+
...socketConfig,
84+
version: socketConfig?.version ?? 2,
85+
issueRules: socketConfig?.issueRules ?? {},
86+
githubApp: socketConfig?.githubApp ?? {},
87+
projectIgnorePaths: [
88+
...(socketConfig?.projectIgnorePaths ?? []),
89+
...scaExcludeGlobs,
90+
],
91+
}
92+
: socketConfig
93+
const mergedReachabilityOptions = excludePaths.length
94+
? {
95+
...reachabilityOptions,
96+
reachExcludePaths: [
97+
...socketConfigReachExcludeGlobs,
98+
...reachabilityOptions.reachExcludePaths,
99+
...coanaExcludeGlobs,
100+
],
101+
}
102+
: reachabilityOptions
103+
104+
return { effectiveSocketConfig, mergedReachabilityOptions }
105+
}
106+
107+
/**
108+
* Translates project-root projectIgnorePaths into Coana --exclude-dirs values,
109+
* which are interpreted relative to the current reachability analysis target.
110+
*/
111+
export function projectIgnorePathsToReachExcludePaths(
112+
paths: readonly string[] | undefined,
113+
options: { cwd: string; target: string },
114+
): string[] {
115+
// GitHub App-style projectIgnorePaths support negation. Coana's
116+
// --exclude-dirs does not, so keep the existing Coana behavior and let it
117+
// infer config ignores itself when any negation is present.
118+
if (!Array.isArray(paths) || paths.some(path => path.includes('!'))) {
119+
return []
120+
}
121+
122+
// projectIgnorePaths are rooted at the project cwd. Coana receives excludes
123+
// relative to its analysis target, so nested target scans need translation.
124+
const targetPath = path.isAbsolute(options.target)
125+
? path.relative(options.cwd, options.target)
126+
: options.target
127+
const targetPattern = toPosixPath(stripTrailingSlash(targetPath))
128+
return paths.flatMap(path =>
129+
projectIgnorePathToReachExcludePaths(path, targetPattern),
130+
)
131+
}
132+
133+
function projectIgnorePathToReachExcludePaths(
134+
path: string,
135+
targetPattern: string,
136+
): string[] {
137+
const reachPath = pathRelativeToTarget(path, targetPattern)
138+
if (!reachPath) {
139+
return []
140+
}
141+
return expandReachExcludePath(reachPath)
142+
}
143+
144+
function expandReachExcludePath(path: string): string[] {
145+
if (path === '**') {
146+
return ['**']
147+
}
148+
const firstSlash = path.indexOf('/')
149+
const prefix = firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : ''
150+
const normalized = stripTrailingSlash(
151+
path.startsWith('/') ? path.slice(1) : path,
152+
)
153+
const pattern = `${prefix}${normalized}`
154+
return pattern.endsWith('/*') || pattern.endsWith('/**')
155+
? [pattern]
156+
: [pattern, `${pattern}/**`]
157+
}
158+
159+
function pathRelativeToTarget(path: string, target: string): string | undefined {
160+
const normalized = normalizeProjectIgnorePath(path)
161+
if (target === '.' || target === '') {
162+
return normalized
163+
}
164+
165+
// Ignore paths outside the analysis target. They still affect SCA manifest
166+
// discovery through projectIgnorePaths, but Coana cannot exclude directories
167+
// outside the target it is analyzing.
168+
if (normalized === target) {
169+
return '**'
170+
}
171+
const targetPrefix = `${target}/`
172+
if (normalized.startsWith(targetPrefix)) {
173+
return normalized.slice(targetPrefix.length)
174+
}
175+
const recursiveTargetPrefix = `${targetPrefix}**/`
176+
if (normalized.startsWith(recursiveTargetPrefix)) {
177+
return normalized.slice(targetPrefix.length)
178+
}
179+
return undefined
180+
}
181+
182+
function normalizeProjectIgnorePath(path: string): string {
183+
return stripTrailingSlash(
184+
toPosixPath(path.startsWith('/') ? path.slice(1) : path),
185+
)
186+
}
187+
188+
function toPosixPath(path: string): string {
189+
return path.replaceAll('\\', '/')
190+
}
191+
192+
function stripTrailingSlash(path: string): string {
193+
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path
194+
}

packages/cli/src/commands/scan/handle-create-new-scan.mts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { pluralize } from '@socketsecurity/lib/words'
88

99
const logger = getDefaultLogger()
1010

11+
import { applyFullExcludePaths } from './exclude-paths.mts'
1112
import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts'
1213
import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts'
1314
import { finalizeTier1Scan } from './finalize-tier1-scan.mts'
@@ -157,8 +158,16 @@ export async function handleCreateNewScan({
157158
? socketYmlResult.data?.parsed
158159
: undefined
159160

161+
const { effectiveSocketConfig, mergedReachabilityOptions } =
162+
applyFullExcludePaths({
163+
cwd,
164+
reachabilityOptions: reach,
165+
socketConfig,
166+
target: targets[0]!,
167+
})
168+
160169
const packagePaths = await getPackageFilesForScan(targets, supportedFiles, {
161-
config: socketConfig,
170+
config: effectiveSocketConfig,
162171
cwd,
163172
})
164173

@@ -209,7 +218,7 @@ export async function handleCreateNewScan({
209218
logger.error('')
210219
logger.info('Starting reachability analysis...')
211220
debug('notice', 'Reachability analysis enabled')
212-
debugDir('inspect', { reachabilityOptions: reach })
221+
debugDir('inspect', { reachabilityOptions: mergedReachabilityOptions })
213222

214223
spinner.start()
215224

@@ -218,7 +227,7 @@ export async function handleCreateNewScan({
218227
cwd,
219228
orgSlug,
220229
packagePaths,
221-
reachabilityOptions: reach,
230+
reachabilityOptions: mergedReachabilityOptions,
222231
repoName,
223232
spinner,
224233
target: firstTarget,

packages/cli/src/commands/scan/handle-scan-reach.mts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { pluralize } from '@socketsecurity/lib/words'
44

55
const logger = getDefaultLogger()
66

7+
import { applyFullExcludePaths } from './exclude-paths.mts'
78
import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts'
89
import { outputScanReach } from './output-scan-reach.mts'
910
import { performReachabilityAnalysis } from './perform-reachability-analysis.mts'
@@ -57,8 +58,16 @@ export async function handleScanReach({
5758
? socketYmlResult.data?.parsed
5859
: undefined
5960

61+
const { effectiveSocketConfig, mergedReachabilityOptions } =
62+
applyFullExcludePaths({
63+
cwd,
64+
reachabilityOptions,
65+
socketConfig,
66+
target: targets[0]!,
67+
})
68+
6069
const packagePaths = await getPackageFilesForScan(targets, supportedFiles, {
61-
config: socketConfig,
70+
config: effectiveSocketConfig,
6271
cwd,
6372
})
6473

@@ -88,7 +97,7 @@ export async function handleScanReach({
8897
orgSlug,
8998
outputPath,
9099
packagePaths,
91-
reachabilityOptions,
100+
reachabilityOptions: mergedReachabilityOptions,
92101
spinner,
93102
target: targets[0]!,
94103
uploadManifests: true,

0 commit comments

Comments
 (0)