|
| 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 | +} |
0 commit comments