feat: add ast safety scanner

This commit is contained in:
artale 2026-06-21 01:25:53 +02:00
parent 5112394d0d
commit 590ce30bee
4 changed files with 129 additions and 0 deletions

View File

@ -91,6 +91,7 @@ fable-agent plinius godmode "improve explanation quality"
### `security` ### `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 - `security scan <target>`: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers
- `--include-fixtures`: include intentional red-team fixtures/generators - `--include-fixtures`: include intentional red-team fixtures/generators

View File

@ -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");
});
});

81
src/core/ast-safety.ts Normal file
View File

@ -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();
}

View File

@ -3066,6 +3066,16 @@ cyber
if (receipt.decision !== "allow_read_only") process.exit(1); 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 security
.command("scan <target>") .command("scan <target>")
.description("Scan files for prompt-injection markers") .description("Scan files for prompt-injection markers")