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
430 changes: 429 additions & 1 deletion js/bin/agentseal.ts

Large diffs are not rendered by default.

519 changes: 513 additions & 6 deletions js/package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentseal",
"version": "0.5.2",
"version": "0.6.0",
"description": "Security validator for AI agents — 225+ attack probes to test prompt injection and extraction defenses",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -61,9 +61,11 @@
"node": ">=18.0.0"
},
"dependencies": {
"commander": "^12.1.0"
"commander": "^12.1.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.3.5",
"@vitest/coverage-v8": "^2.1.0",
"tsup": "^8.3.0",
Expand All @@ -89,5 +91,8 @@
"@langchain/core": {
"optional": true
}
},
"optionalDependencies": {
"better-sqlite3": "^12.8.0"
}
}
21 changes: 11 additions & 10 deletions js/src/baselines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ export interface BaselineChange {
// ═══════════════════════════════════════════════════════════════════════

function configFingerprint(server: Record<string, any>): string {
const command = server.command ?? "";
const args = (server.args ?? [])
.filter((a: any): a is string => typeof a === "string")
.sort();
const envKeys = Object.keys(server.env ?? {})
.filter((k): k is string => typeof k === "string")
.sort();

const parts = [command, JSON.stringify(args), JSON.stringify(envKeys)];
const rawCmd = server.command ?? "";
const cmdStr = Array.isArray(rawCmd) ? rawCmd.join(" ") : String(rawCmd);
const parts = [
cmdStr,
JSON.stringify([...(server.args ?? [])].map(String).sort()),
JSON.stringify(Object.keys(server.env ?? {}).map(String).sort()),
server.url ?? "",
JSON.stringify(Object.keys(server.headers ?? {}).map(String).sort()),
];
return createHash("sha256").update(parts.join("|")).digest("hex");
}

Expand Down Expand Up @@ -117,7 +117,8 @@ export class BaselineStore {
checkServer(server: Record<string, any>): BaselineChange | null {
const name: string = server.name ?? "unknown";
const agentType: string = server.agent_type ?? "unknown";
const command: string = server.command ?? "";
const rawCmd = server.command ?? "";
const command: string = Array.isArray(rawCmd) ? rawCmd.join(" ") : String(rawCmd);
const args = (server.args ?? []).filter((a: any): a is string => typeof a === "string");
const now = new Date().toISOString();

Expand Down
30 changes: 24 additions & 6 deletions js/src/blocklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,26 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
import { homedir } from "node:os";
import { join } from "node:path";

const SEED_HASHES = new Set([
"854aa9bd5a641b03fcf2e4a26affb33057af3238a10a83e194c05384f371734f", // credential-theft-cursorrules
"46315c1d4dcd39199c6d0e43985c5007c1156bc538e3a82ba9b2883f363eab35", // markdown-image-exfil
"0b2ca8fedb87a97de9f5c462e09110febf887516dd62877d7e95a5556ef90905", // reverse-shell-instruction
"2b5a339d00216894c7bd3620e008e5443f4e30b9e9883a2b15c082d076775084", // curl-exfil-instruction
"eccb3a65c459a6b69223d38726e3fddb6184a6e7c52935148fdcd84961a6f9df", // prompt-injection-override
"f554a511faaca2431265399a9d5b2f7184778b9521952dc757257dbe0aab2a46", // supply-chain-install
"323b9121b6e320fb04bae89c963690069c5172dca017469be2917e5feaec886c", // obfuscated-credential-theft
"4826c0e8aef00f902190ab32519e4533b7e4b725f46fb70156705ea8708a7385", // social-engineering-exfil
"3951cdb38bbc37e28f98448e0478b93d319d892783efb23462b59fedea52189d", // mcp-config-injection
"a7ddd5ce6c41055b4ef808810ac6f1b09dc4ae05eecc2f89dc64ac4682502d99", // keylogger-instruction
"eab3b7330de3b61fae1b5cba738ae499424e1c45ef1b025c560cca410e6cd16b", // crypto-miner-injection
"d71ceee36d1e136a5cddc0d5b416210d94635a71fa90f9ef817f4f74a7b21603", // dns-exfil-instruction
]);

export class Blocklist {
static readonly REMOTE_URL = "https://agentseal.org/api/v1/blocklist/skills.json";
static readonly CACHE_TTL = 3600; // 1 hour in seconds

private _hashes = new Set<string>();
private _hashes = new Set<string>(SEED_HASHES);
private _loaded = false;
private _cacheDir: string;
private _cachePath: string;
Expand All @@ -32,7 +47,7 @@ export class Blocklist {
this._cacheDir = dir;
this._cachePath = join(dir, "blocklist.json");
this._loaded = false;
this._hashes.clear();
this._hashes = new Set<string>(SEED_HASHES);
}

private _load(): void {
Expand Down Expand Up @@ -70,10 +85,11 @@ export class Blocklist {
try {
const raw = readFileSync(path, "utf-8");
const data = JSON.parse(raw);
const hashes: string[] = data.sha256_hashes ?? [];
this._hashes = new Set(hashes);
for (const h of (data.sha256_hashes ?? [])) {
this._hashes.add(h);
}
} catch {
this._hashes = new Set();
// parse failed — keep seed hashes intact
}
}

Expand Down Expand Up @@ -110,7 +126,9 @@ export class Blocklist {
});
if (resp.ok) {
const data = await resp.json() as { sha256_hashes?: string[] };
this._hashes = new Set(data.sha256_hashes ?? []);
for (const h of (data.sha256_hashes ?? [])) {
this._hashes.add(h);
}
// Cache locally
mkdirSync(this._cacheDir, { recursive: true });
writeFileSync(this._cachePath, JSON.stringify(data), "utf-8");
Expand Down
105 changes: 95 additions & 10 deletions js/src/deobfuscate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,61 @@ export function hasInvisibleChars(text: string): boolean {
// TRANSFORM FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════

/** Apply NFKC unicode normalization (homoglyphs → ASCII). */
/**
* TR39 confusable character mappings.
* Characters from other scripts that visually resemble ASCII but have
* different codepoints. Applied AFTER NFKC to catch what normalization misses.
*/
const CONFUSABLES: Map<string, string> = new Map([
// — Cyrillic uppercase —
["\u0410", "A"], ["\u0412", "B"], ["\u0421", "C"], ["\u0415", "E"],
["\u041D", "H"], ["\u0406", "I"], ["\u0408", "J"], ["\u041A", "K"],
["\u041C", "M"], ["\u041E", "O"], ["\u0420", "P"], ["\u0405", "S"],
["\u0422", "T"], ["\u0425", "X"], ["\u0423", "Y"], ["\u0417", "Z"],
// — Cyrillic lowercase —
["\u0430", "a"], ["\u0441", "c"], ["\u0435", "e"], ["\u04BB", "h"],
["\u0456", "i"], ["\u0458", "j"], ["\u043E", "o"], ["\u0440", "p"],
["\u0455", "s"], ["\u0445", "x"], ["\u0443", "y"],
// — Greek uppercase —
["\u0391", "A"], ["\u0392", "B"], ["\u0395", "E"], ["\u0397", "H"],
["\u0399", "I"], ["\u039A", "K"], ["\u039C", "M"], ["\u039D", "N"],
["\u039F", "O"], ["\u03A1", "P"], ["\u03A4", "T"], ["\u03A7", "X"],
["\u03A5", "Y"], ["\u0396", "Z"],
// — Greek lowercase —
["\u03BF", "o"], ["\u03B1", "a"],
// — Cherokee —
["\u13A0", "D"], ["\u13A1", "R"], ["\u13A2", "T"], ["\u13AA", "G"],
["\u13B3", "W"], ["\u13D2", "S"], ["\u13DA", "S"],
["\uAB4E", "s"], ["\uAB4F", "s"], ["\uABA3", "s"], ["\uABAA", "s"],
// — Turkish dotless i —
["\u0131", "i"],
// — Small caps —
["\u1D00", "A"], ["\u0299", "B"], ["\u1D04", "C"],
// — Fullwidth Latin uppercase A–Z (U+FF21–U+FF3A) —
...Array.from({ length: 26 }, (_, i): [string, string] => [
String.fromCharCode(0xFF21 + i),
String.fromCharCode(0x41 + i),
]),
// — Fullwidth Latin lowercase a–z (U+FF41–U+FF5A) —
...Array.from({ length: 26 }, (_, i): [string, string] => [
String.fromCharCode(0xFF41 + i),
String.fromCharCode(0x61 + i),
]),
]);

/**
* Apply NFKC unicode normalization then TR39 confusable mapping.
* NFKC handles compatibility decompositions (fullwidth, ligatures).
* The confusable map catches cross-script homoglyphs that NFKC misses
* (Cyrillic, Greek, Cherokee, etc.).
*/
export function normalizeUnicode(text: string): string {
return text.normalize("NFKC");
let result = text.normalize("NFKC");
let out = "";
for (const ch of result) {
out += CONFUSABLES.get(ch) ?? ch;
}
return out;
}

/** Check if decoded bytes are valid printable text. */
Expand Down Expand Up @@ -186,34 +238,67 @@ export function expandStringConcat(text: string): string {
return text;
}

// ═══════════════════════════════════════════════════════════════════════
// HTML ENTITY DECODING
// ═══════════════════════════════════════════════════════════════════════

const NAMED_ENTITIES: Record<string, string> = {
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
nbsp: "\u00A0", copy: "\u00A9", reg: "\u00AE",
};

/**
* Decode HTML character references (numeric, hex, and named).
* Handles &#99; (decimal), &#x63; (hex), and &amp; (named) forms.
*/
export function decodeHtmlEntities(text: string): string {
return text
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(/&([a-zA-Z]+);/g, (match, name) => NAMED_ENTITIES[name.toLowerCase()] ?? match);
}

// ═══════════════════════════════════════════════════════════════════════
// MAIN PIPELINE
// ═══════════════════════════════════════════════════════════════════════

/**
* Apply all deobfuscation transforms to text.
* Single deobfuscation pass — all transforms in order.
*
* Returns cleaned text for regex pattern matching.
* Transforms applied in order (same as Python):
* 1. stripZeroWidth
* 2. stripTagChars
* 3. stripVariationSelectors
* 4. stripBidiControls
* 5. stripHtmlComments
* 6. normalizeUnicode (NFKC)
* 7. decodeBase64Blocks
* 8. unescapeSequences
* 9. expandStringConcat
* 6. decodeHtmlEntities
* 7. normalizeUnicode (NFKC + TR39 confusables)
* 8. decodeBase64Blocks
* 9. unescapeSequences
* 10. expandStringConcat
*/
export function deobfuscate(text: string): string {
function _deobfuscatePass(text: string): string {
text = stripZeroWidth(text);
text = stripTagChars(text);
text = stripVariationSelectors(text);
text = stripBidiControls(text);
text = stripHtmlComments(text);
text = decodeHtmlEntities(text);
text = normalizeUnicode(text);
text = decodeBase64Blocks(text);
text = unescapeSequences(text);
text = expandStringConcat(text);
return text;
}

/**
* Apply all deobfuscation transforms to text (2-pass pipeline).
*
* Two passes catch nested obfuscation where the first pass reveals
* content that a second pass can further decode (e.g. base64 hidden
* inside zero-width splits, or escape sequences inside HTML entities).
*/
export function deobfuscate(text: string): string {
text = _deobfuscatePass(text);
text = _deobfuscatePass(text);
return text;
}
Loading
Loading