Files
orca/config/scripts/windows-bin-spawn-predicate.mjs
T
Jinwoo Hong c6548b98f4 test(scripts): widen the Windows shim ratchet to catch package bin spawns (#20285)
* Widen Windows shim ratchet to detect package bin spawns

Follow local program expressions into node_modules/.bin while preserving the existing literal check, roots, and allow-list. Document static-analysis limits and cover unsafe and resolver-based invocations.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(scripts): fold dot segments before matching node_modules/.bin

The predicate joins call arguments textually, so a literal '..' segment hid a
path that resolves into node_modules/.bin at runtime. Folds '.' and '..' (and
Windows separators) first. A '..' that genuinely escapes .bin still does not
match.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(scripts): use .at(-1) in the dot-segment fold

oxlint's prefer-at rule; the repo-wide lint gate is an error, not a warning.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-13 16:08:56 -04:00

101 lines
3.1 KiB
JavaScript

import ts from 'typescript-api'
const SPAWN_METHODS = new Set(['spawn', 'spawnSync', 'execFile', 'execFileSync'])
const BIN_PATH = /(?:^|[\\/])node_modules[\\/]\.bin(?:[\\/]|$)/i
export function hasNodeModulesBinSpawn(contents) {
const source = ts.createSourceFile(
'script.mjs',
contents,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
)
const bindings = new Map()
const calls = []
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
const initializers = bindings.get(node.name.text) ?? []
initializers.push(node.initializer)
bindings.set(node.name.text, initializers)
}
if (ts.isCallExpression(node)) {
calls.push(node)
}
ts.forEachChild(node, visit)
}
visit(source)
function name(node) {
return ts.isIdentifier(node)
? node.text
: ts.isPropertyAccessExpression(node)
? node.name.text
: ''
}
function paths(node, seen = new Set()) {
if (!node || seen.has(node)) {
return ['?']
}
const next = new Set(seen).add(node)
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return [node.text]
}
if (ts.isIdentifier(node)) {
return (bindings.get(node.text) ?? []).flatMap((value) => paths(value, next))
}
if (ts.isParenthesizedExpression(node)) {
return paths(node.expression, next)
}
if (ts.isConditionalExpression(node)) {
return [...paths(node.whenTrue, next), ...paths(node.whenFalse, next)]
}
if (ts.isTemplateExpression(node)) {
return node.templateSpans.reduce(
(prefixes, span) => combine(prefixes, paths(span.expression, next), '', span.literal.text),
[node.head.text]
)
}
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
return combine(paths(node.left, next), paths(node.right, next))
}
if (ts.isCallExpression(node) && ['join', 'resolve'].includes(name(node.expression))) {
return node.arguments.reduce(
(prefixes, arg) => combine(prefixes, paths(arg, next), '/'),
['']
)
}
return ['?']
}
function combine(left, right, separator = '', suffix = '') {
return (left.length ? left : ['?']).flatMap((prefix) =>
(right.length ? right : ['?']).map((part) => `${prefix}${separator}${part}${suffix}`)
)
}
// Why: arguments are folded textually, so a literal '..' segment would otherwise hide a
// path that resolves into node_modules/.bin at runtime.
function foldDotSegments(value) {
const folded = []
for (const segment of value.replace(/\\/g, '/').split('/')) {
if (segment === '.' || segment === '') {
continue
}
if (segment === '..' && folded.length && folded.at(-1) !== '..') {
folded.pop()
continue
}
folded.push(segment)
}
return folded.join('/')
}
return calls.some(
(call) =>
SPAWN_METHODS.has(name(call.expression)) &&
paths(call.arguments[0]).some((value) => BIN_PATH.test(foldDotSegments(value)))
)
}