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`
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue