feat: add ast safety scanner
This commit is contained in:
parent
5112394d0d
commit
590ce30bee
|
|
@ -91,6 +91,7 @@ fable-agent plinius godmode "improve explanation quality"
|
|||
|
||||
### `security`
|
||||
|
||||
- `security ast <file>`: summarize JS/TS structure and risky capabilities
|
||||
- `security scan <target>`: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers
|
||||
- `--include-fixtures`: include intentional red-team fixtures/generators
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { scanAstSource } from "./ast-safety.js";
|
||||
|
||||
describe("ast safety", () => {
|
||||
it("allows simple pure code", () => {
|
||||
const report = scanAstSource("pure.ts", "export function add(a: number, b: number) { return a + b; }");
|
||||
|
||||
expect(report.exports).toEqual(["add"]);
|
||||
expect(report.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("flags shell, network, fs write, and eval-like behavior", () => {
|
||||
const report = scanAstSource("risky.ts", `
|
||||
import { execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
fetch("https://example.com");
|
||||
fs.writeFileSync("x", "y");
|
||||
execSync("echo hi");
|
||||
eval("1+1");
|
||||
`);
|
||||
|
||||
expect(report.imports).toContain("node:child_process");
|
||||
expect(report.shell_exec).toBe(true);
|
||||
expect(report.network).toBe(true);
|
||||
expect(report.fs_write).toBe(true);
|
||||
expect(report.eval_like).toBe(true);
|
||||
expect(report.decision).toBe("review");
|
||||
});
|
||||
|
||||
it("flags private-use glyph code", () => {
|
||||
const glyph = String.fromCharCode(0xe000);
|
||||
const report = scanAstSource("glyph.ts", `export const ${glyph} = () => ${glyph};`);
|
||||
|
||||
expect(report.private_glyphs).toBe(true);
|
||||
expect(report.decision).toBe("review");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import * as fs from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { assessLegibilityRisk } from "./unicode-safety.js";
|
||||
|
||||
export interface AstSafetyReport {
|
||||
file: string;
|
||||
imports: string[];
|
||||
exports: string[];
|
||||
calls: string[];
|
||||
shell_exec: boolean;
|
||||
network: boolean;
|
||||
fs_write: boolean;
|
||||
eval_like: boolean;
|
||||
private_glyphs: boolean;
|
||||
legibility_reason: string;
|
||||
decision: "allow" | "review";
|
||||
}
|
||||
|
||||
const SHELL = new Set(["exec", "execSync", "spawn", "spawnSync", "fork"]);
|
||||
const FS_WRITE = /^(writeFile|writeFileSync|appendFile|appendFileSync|rm|rmSync|unlink|unlinkSync|mkdir|mkdirSync|rename|renameSync)$/;
|
||||
const NETWORK = /^(fetch|request|get|post|put|patch|delete)$/i;
|
||||
const EVAL = /^(eval|Function|setTimeout|setInterval)$/;
|
||||
|
||||
export function scanAstFile(file: string): AstSafetyReport {
|
||||
return scanAstSource(file, fs.readFileSync(file, "utf-8"));
|
||||
}
|
||||
|
||||
export function scanAstSource(file: string, source: string): AstSafetyReport {
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
||||
const imports = new Set<string>();
|
||||
const exports = new Set<string>();
|
||||
const calls = new Set<string>();
|
||||
const risk = assessLegibilityRisk(source);
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) imports.add(node.moduleSpecifier.text);
|
||||
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) exports.add(node.moduleSpecifier.text);
|
||||
if (ts.isExportAssignment(node)) exports.add("default");
|
||||
if (ts.isFunctionDeclaration(node) && hasExport(node.modifiers)) exports.add(node.name?.text ?? "default");
|
||||
if (ts.isClassDeclaration(node) && hasExport(node.modifiers)) exports.add(node.name?.text ?? "default");
|
||||
if (ts.isVariableStatement(node) && hasExport(node.modifiers)) {
|
||||
for (const d of node.declarationList.declarations) if (ts.isIdentifier(d.name)) exports.add(d.name.text);
|
||||
}
|
||||
if (ts.isCallExpression(node) || ts.isNewExpression(node)) calls.add(callName(node.expression));
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
|
||||
const callList = [...calls].filter(Boolean).sort();
|
||||
const importList = [...imports].sort();
|
||||
const shell_exec = importList.some((i) => i === "node:child_process" || i === "child_process") || callList.some((c) => SHELL.has(c.split(".").pop() ?? c));
|
||||
const fs_write = callList.some((c) => FS_WRITE.test(c.split(".").pop() ?? c));
|
||||
const network = callList.some((c) => NETWORK.test(c.split(".").pop() ?? c)) || importList.some((i) => /^https?$|^node:https?$/.test(i));
|
||||
const eval_like = callList.some((c) => EVAL.test(c.split(".").pop() ?? c));
|
||||
const decision: AstSafetyReport["decision"] = shell_exec || fs_write || network || eval_like || risk.risky ? "review" : "allow";
|
||||
|
||||
return {
|
||||
file,
|
||||
imports: importList,
|
||||
exports: [...exports].sort(),
|
||||
calls: callList,
|
||||
shell_exec,
|
||||
network,
|
||||
fs_write,
|
||||
eval_like,
|
||||
private_glyphs: risk.privateUseCount > 0,
|
||||
legibility_reason: risk.reason,
|
||||
decision,
|
||||
};
|
||||
}
|
||||
|
||||
function hasExport(modifiers: ts.NodeArray<ts.ModifierLike> | undefined): boolean {
|
||||
return modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
||||
}
|
||||
|
||||
function callName(expr: ts.Expression): string {
|
||||
if (ts.isIdentifier(expr)) return expr.text;
|
||||
if (ts.isPropertyAccessExpression(expr)) return `${callName(expr.expression)}.${expr.name.text}`;
|
||||
if (ts.isElementAccessExpression(expr)) return callName(expr.expression);
|
||||
return expr.getText();
|
||||
}
|
||||
10
src/index.ts
10
src/index.ts
|
|
@ -3066,6 +3066,16 @@ cyber
|
|||
if (receipt.decision !== "allow_read_only") process.exit(1);
|
||||
});
|
||||
|
||||
security
|
||||
.command("ast <file>")
|
||||
.description("Summarize JS/TS structure and risky capabilities")
|
||||
.action(async (file: string) => {
|
||||
const { scanAstFile } = await import("./core/ast-safety.js");
|
||||
const report = scanAstFile(path.resolve(file));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (report.decision !== "allow") process.exit(1);
|
||||
});
|
||||
|
||||
security
|
||||
.command("scan <target>")
|
||||
.description("Scan files for prompt-injection markers")
|
||||
|
|
|
|||
Loading…
Reference in New Issue