diff --git a/packages/core/src/detectors/shell-untrusted-input.ts b/packages/core/src/detectors/shell-untrusted-input.ts index 5b773a6..a070c08 100644 --- a/packages/core/src/detectors/shell-untrusted-input.ts +++ b/packages/core/src/detectors/shell-untrusted-input.ts @@ -34,7 +34,7 @@ function compile(config: LanguageConfig): RegExp | null { cache.set(config, null); return null; } - const alt = namedCallAlternation(list); + const alt = namedCallAlternation(list, { allowUnsafeBareTails: true }); // Capture sink + the first 80 chars of arguments const re = new RegExp(`(?:^|[^A-Za-z0-9_$])((?:${alt}))\\s*\\(([^)\\n]{0,200})`, "g"); cache.set(config, re); diff --git a/packages/core/src/internal/patterns.ts b/packages/core/src/internal/patterns.ts index 8048ffd..3b417f4 100644 --- a/packages/core/src/internal/patterns.ts +++ b/packages/core/src/internal/patterns.ts @@ -25,6 +25,50 @@ export function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +const UNSAFE_BARE_TAILS = new Set([ + "call", + "compile", + "constructor", + "decode", + "do", + "exec", + "execute", + "from", + "get", + "import", + "invoke", + "load", + "new", + "open", + "parse", + "post", + "request", + "require", + "run", + "send", + "source", + "spawn", + "start", + "system", + "use", +]); + +function isUnsafeBareTail(tail: string): boolean { + return UNSAFE_BARE_TAILS.has(tail.toLowerCase()); +} + +function qualifiedSuffix(raw: string): string | null { + const parts = raw.split(/(\.|::)/); + const segments = parts.filter((_, i) => i % 2 === 0 && parts[i] !== ""); + const separators = parts.filter((_, i) => i % 2 === 1); + if (segments.length < 2 || separators.length < 1) return null; + const a = segments[segments.length - 2]; + const b = segments[segments.length - 1]; + const sep = separators[separators.length - 1]; + if (!a || !b || !sep) return null; + return `${a}${sep}${b}`; +} + /** * Build a regex source matching any of the configured names as a function * call. Accepts qualified names — for `base64.b64decode` the regex matches @@ -35,7 +79,10 @@ export function escapeRegex(s: string): string { * * Returns an inner alternation suitable for inclusion in a larger pattern. */ -export function namedCallAlternation(names: readonly string[]): string { +export function namedCallAlternation( + names: readonly string[], + options: { allowUnsafeBareTails?: boolean } = {}, +): string { const alts: string[] = []; for (const raw of names) { if (!raw) continue; @@ -56,7 +103,13 @@ export function namedCallAlternation(names: readonly string[]): string { const tail = parts[parts.length - 1] ?? raw; alts.push(escapeRegex(raw)); if (parts.length > 1 && tail !== raw && tail.length > 0) { - alts.push(escapeRegex(tail)); + if (isUnsafeBareTail(tail) && !options.allowUnsafeBareTails) { + const suffix = qualifiedSuffix(raw); + if (suffix && suffix !== raw) alts.push(escapeRegex(suffix)); + alts.push(`(?:\\.|::)${escapeRegex(tail)}`); + } else { + alts.push(escapeRegex(tail)); + } } } // Deduplicate while preserving order diff --git a/packages/core/test/unit/layer-b.test.ts b/packages/core/test/unit/layer-b.test.ts index bfbaf73..6b8ae0d 100644 --- a/packages/core/test/unit/layer-b.test.ts +++ b/packages/core/test/unit/layer-b.test.ts @@ -129,6 +129,21 @@ describe("Layer B: dynamic-exec-with-non-literal", () => { const result = await scan(input, { fileResolver, ...silentOptions() }); expect(result).toNotFlag("obf.dynamic-exec-with-non-literal"); }); + + it("does NOT flag a TypeScript class constructor", async () => { + const src = ` +class Scanner { + private onStatus: (update: ScannerStatusUpdate) => void; + + constructor(onStatus: (update: ScannerStatusUpdate) => void) { + this.onStatus = onStatus; + } +} +`; + const { input, fileResolver } = virtualFiles({ "src/scanner.ts": src }); + const result = await scan(input, { fileResolver, ...silentOptions() }); + expect(result).toNotFlag("obf.dynamic-exec-with-non-literal"); + }); }); // ─── network-then-exec ───────────────────────────────────────────────────── @@ -168,6 +183,23 @@ describe("Layer B: network-then-exec", () => { const result = await scan(input, { fileResolver, ...silentOptions() }); expect(result).toBlock("obf.network-then-exec"); }); + + it("does NOT flag a plain TypeScript fetch call", async () => { + const src = ` +class ManifestLoader { + constructor(private manifestUrl: string) {} + + async load() { + const response = await fetch(this.manifestUrl, { cache: 'no-store' }); + return response.json(); + } +} +`; + const { input, fileResolver } = virtualFiles({ "src/loader.ts": src }); + const result = await scan(input, { fileResolver, ...silentOptions() }); + expect(result).toNotFlag("obf.network-then-exec"); + expect(result).toNotFlag("obf.dynamic-exec-with-non-literal"); + }); }); // ─── deserializer-untrusted ──────────────────────────────────────────────── @@ -278,6 +310,15 @@ function go(name) { exec(\`ls \${name}\`); } const result = await scan(input, { fileResolver, ...silentOptions() }); expect(result).toFlag("obf.shell-with-untrusted-input"); }); + + it("does NOT flag a TypeScript string that builds but does not run a command", async () => { + const src = ` +const command = \`Expand-Archive -Path '\${escapedArchive}' -DestinationPath '\${escapedOutput}' -Force\`; +`; + const { input, fileResolver } = virtualFiles({ "src/archive.ts": src }); + const result = await scan(input, { fileResolver, ...silentOptions() }); + expect(result).toNotFlag("obf.shell-with-untrusted-input"); + }); }); // ─── library-load-non-literal ──────────────────────────────────────────────