Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/detectors/shell-untrusted-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
57 changes: 55 additions & 2 deletions packages/core/src/internal/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions packages/core/test/unit/layer-b.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────
Expand Down
Loading