Skip to content

Commit 99cee31

Browse files
authored
Merge pull request #22 from AgentSeal/dev/js-guard-v08
feat(js): Guard v0.8 feature parity - config, rules, registry, history, CLI
2 parents d3648ac + f27f7b0 commit 99cee31

28 files changed

Lines changed: 6271 additions & 92 deletions

js/bin/agentseal.ts

Lines changed: 429 additions & 1 deletion
Large diffs are not rendered by default.

js/package-lock.json

Lines changed: 513 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agentseal",
3-
"version": "0.5.2",
3+
"version": "0.6.0",
44
"description": "Security validator for AI agents — 225+ attack probes to test prompt injection and extraction defenses",
55
"type": "module",
66
"main": "./dist/index.cjs",
@@ -61,9 +61,11 @@
6161
"node": ">=18.0.0"
6262
},
6363
"dependencies": {
64-
"commander": "^12.1.0"
64+
"commander": "^12.1.0",
65+
"yaml": "^2.8.3"
6566
},
6667
"devDependencies": {
68+
"@types/better-sqlite3": "^7.6.13",
6769
"@types/node": "^25.3.5",
6870
"@vitest/coverage-v8": "^2.1.0",
6971
"tsup": "^8.3.0",
@@ -89,5 +91,8 @@
8991
"@langchain/core": {
9092
"optional": true
9193
}
94+
},
95+
"optionalDependencies": {
96+
"better-sqlite3": "^12.8.0"
9297
}
9398
}

js/src/baselines.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ export interface BaselineChange {
4848
// ═══════════════════════════════════════════════════════════════════════
4949

5050
function configFingerprint(server: Record<string, any>): string {
51-
const command = server.command ?? "";
52-
const args = (server.args ?? [])
53-
.filter((a: any): a is string => typeof a === "string")
54-
.sort();
55-
const envKeys = Object.keys(server.env ?? {})
56-
.filter((k): k is string => typeof k === "string")
57-
.sort();
58-
59-
const parts = [command, JSON.stringify(args), JSON.stringify(envKeys)];
51+
const rawCmd = server.command ?? "";
52+
const cmdStr = Array.isArray(rawCmd) ? rawCmd.join(" ") : String(rawCmd);
53+
const parts = [
54+
cmdStr,
55+
JSON.stringify([...(server.args ?? [])].map(String).sort()),
56+
JSON.stringify(Object.keys(server.env ?? {}).map(String).sort()),
57+
server.url ?? "",
58+
JSON.stringify(Object.keys(server.headers ?? {}).map(String).sort()),
59+
];
6060
return createHash("sha256").update(parts.join("|")).digest("hex");
6161
}
6262

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

js/src/blocklist.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,26 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
1313
import { homedir } from "node:os";
1414
import { join } from "node:path";
1515

16+
const SEED_HASHES = new Set([
17+
"854aa9bd5a641b03fcf2e4a26affb33057af3238a10a83e194c05384f371734f", // credential-theft-cursorrules
18+
"46315c1d4dcd39199c6d0e43985c5007c1156bc538e3a82ba9b2883f363eab35", // markdown-image-exfil
19+
"0b2ca8fedb87a97de9f5c462e09110febf887516dd62877d7e95a5556ef90905", // reverse-shell-instruction
20+
"2b5a339d00216894c7bd3620e008e5443f4e30b9e9883a2b15c082d076775084", // curl-exfil-instruction
21+
"eccb3a65c459a6b69223d38726e3fddb6184a6e7c52935148fdcd84961a6f9df", // prompt-injection-override
22+
"f554a511faaca2431265399a9d5b2f7184778b9521952dc757257dbe0aab2a46", // supply-chain-install
23+
"323b9121b6e320fb04bae89c963690069c5172dca017469be2917e5feaec886c", // obfuscated-credential-theft
24+
"4826c0e8aef00f902190ab32519e4533b7e4b725f46fb70156705ea8708a7385", // social-engineering-exfil
25+
"3951cdb38bbc37e28f98448e0478b93d319d892783efb23462b59fedea52189d", // mcp-config-injection
26+
"a7ddd5ce6c41055b4ef808810ac6f1b09dc4ae05eecc2f89dc64ac4682502d99", // keylogger-instruction
27+
"eab3b7330de3b61fae1b5cba738ae499424e1c45ef1b025c560cca410e6cd16b", // crypto-miner-injection
28+
"d71ceee36d1e136a5cddc0d5b416210d94635a71fa90f9ef817f4f74a7b21603", // dns-exfil-instruction
29+
]);
30+
1631
export class Blocklist {
1732
static readonly REMOTE_URL = "https://agentseal.org/api/v1/blocklist/skills.json";
1833
static readonly CACHE_TTL = 3600; // 1 hour in seconds
1934

20-
private _hashes = new Set<string>();
35+
private _hashes = new Set<string>(SEED_HASHES);
2136
private _loaded = false;
2237
private _cacheDir: string;
2338
private _cachePath: string;
@@ -32,7 +47,7 @@ export class Blocklist {
3247
this._cacheDir = dir;
3348
this._cachePath = join(dir, "blocklist.json");
3449
this._loaded = false;
35-
this._hashes.clear();
50+
this._hashes = new Set<string>(SEED_HASHES);
3651
}
3752

3853
private _load(): void {
@@ -70,10 +85,11 @@ export class Blocklist {
7085
try {
7186
const raw = readFileSync(path, "utf-8");
7287
const data = JSON.parse(raw);
73-
const hashes: string[] = data.sha256_hashes ?? [];
74-
this._hashes = new Set(hashes);
88+
for (const h of (data.sha256_hashes ?? [])) {
89+
this._hashes.add(h);
90+
}
7591
} catch {
76-
this._hashes = new Set();
92+
// parse failed — keep seed hashes intact
7793
}
7894
}
7995

@@ -110,7 +126,9 @@ export class Blocklist {
110126
});
111127
if (resp.ok) {
112128
const data = await resp.json() as { sha256_hashes?: string[] };
113-
this._hashes = new Set(data.sha256_hashes ?? []);
129+
for (const h of (data.sha256_hashes ?? [])) {
130+
this._hashes.add(h);
131+
}
114132
// Cache locally
115133
mkdirSync(this._cacheDir, { recursive: true });
116134
writeFileSync(this._cachePath, JSON.stringify(data), "utf-8");

js/src/deobfuscate.ts

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,61 @@ export function hasInvisibleChars(text: string): boolean {
9090
// TRANSFORM FUNCTIONS
9191
// ═══════════════════════════════════════════════════════════════════════
9292

93-
/** Apply NFKC unicode normalization (homoglyphs → ASCII). */
93+
/**
94+
* TR39 confusable character mappings.
95+
* Characters from other scripts that visually resemble ASCII but have
96+
* different codepoints. Applied AFTER NFKC to catch what normalization misses.
97+
*/
98+
const CONFUSABLES: Map<string, string> = new Map([
99+
// — Cyrillic uppercase —
100+
["\u0410", "A"], ["\u0412", "B"], ["\u0421", "C"], ["\u0415", "E"],
101+
["\u041D", "H"], ["\u0406", "I"], ["\u0408", "J"], ["\u041A", "K"],
102+
["\u041C", "M"], ["\u041E", "O"], ["\u0420", "P"], ["\u0405", "S"],
103+
["\u0422", "T"], ["\u0425", "X"], ["\u0423", "Y"], ["\u0417", "Z"],
104+
// — Cyrillic lowercase —
105+
["\u0430", "a"], ["\u0441", "c"], ["\u0435", "e"], ["\u04BB", "h"],
106+
["\u0456", "i"], ["\u0458", "j"], ["\u043E", "o"], ["\u0440", "p"],
107+
["\u0455", "s"], ["\u0445", "x"], ["\u0443", "y"],
108+
// — Greek uppercase —
109+
["\u0391", "A"], ["\u0392", "B"], ["\u0395", "E"], ["\u0397", "H"],
110+
["\u0399", "I"], ["\u039A", "K"], ["\u039C", "M"], ["\u039D", "N"],
111+
["\u039F", "O"], ["\u03A1", "P"], ["\u03A4", "T"], ["\u03A7", "X"],
112+
["\u03A5", "Y"], ["\u0396", "Z"],
113+
// — Greek lowercase —
114+
["\u03BF", "o"], ["\u03B1", "a"],
115+
// — Cherokee —
116+
["\u13A0", "D"], ["\u13A1", "R"], ["\u13A2", "T"], ["\u13AA", "G"],
117+
["\u13B3", "W"], ["\u13D2", "S"], ["\u13DA", "S"],
118+
["\uAB4E", "s"], ["\uAB4F", "s"], ["\uABA3", "s"], ["\uABAA", "s"],
119+
// — Turkish dotless i —
120+
["\u0131", "i"],
121+
// — Small caps —
122+
["\u1D00", "A"], ["\u0299", "B"], ["\u1D04", "C"],
123+
// — Fullwidth Latin uppercase A–Z (U+FF21–U+FF3A) —
124+
...Array.from({ length: 26 }, (_, i): [string, string] => [
125+
String.fromCharCode(0xFF21 + i),
126+
String.fromCharCode(0x41 + i),
127+
]),
128+
// — Fullwidth Latin lowercase a–z (U+FF41–U+FF5A) —
129+
...Array.from({ length: 26 }, (_, i): [string, string] => [
130+
String.fromCharCode(0xFF41 + i),
131+
String.fromCharCode(0x61 + i),
132+
]),
133+
]);
134+
135+
/**
136+
* Apply NFKC unicode normalization then TR39 confusable mapping.
137+
* NFKC handles compatibility decompositions (fullwidth, ligatures).
138+
* The confusable map catches cross-script homoglyphs that NFKC misses
139+
* (Cyrillic, Greek, Cherokee, etc.).
140+
*/
94141
export function normalizeUnicode(text: string): string {
95-
return text.normalize("NFKC");
142+
let result = text.normalize("NFKC");
143+
let out = "";
144+
for (const ch of result) {
145+
out += CONFUSABLES.get(ch) ?? ch;
146+
}
147+
return out;
96148
}
97149

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

241+
// ═══════════════════════════════════════════════════════════════════════
242+
// HTML ENTITY DECODING
243+
// ═══════════════════════════════════════════════════════════════════════
244+
245+
const NAMED_ENTITIES: Record<string, string> = {
246+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
247+
nbsp: "\u00A0", copy: "\u00A9", reg: "\u00AE",
248+
};
249+
250+
/**
251+
* Decode HTML character references (numeric, hex, and named).
252+
* Handles &#99; (decimal), &#x63; (hex), and &amp; (named) forms.
253+
*/
254+
export function decodeHtmlEntities(text: string): string {
255+
return text
256+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
257+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
258+
.replace(/&([a-zA-Z]+);/g, (match, name) => NAMED_ENTITIES[name.toLowerCase()] ?? match);
259+
}
260+
189261
// ═══════════════════════════════════════════════════════════════════════
190262
// MAIN PIPELINE
191263
// ═══════════════════════════════════════════════════════════════════════
192264

193265
/**
194-
* Apply all deobfuscation transforms to text.
266+
* Single deobfuscation pass — all transforms in order.
195267
*
196-
* Returns cleaned text for regex pattern matching.
197-
* Transforms applied in order (same as Python):
198268
* 1. stripZeroWidth
199269
* 2. stripTagChars
200270
* 3. stripVariationSelectors
201271
* 4. stripBidiControls
202272
* 5. stripHtmlComments
203-
* 6. normalizeUnicode (NFKC)
204-
* 7. decodeBase64Blocks
205-
* 8. unescapeSequences
206-
* 9. expandStringConcat
273+
* 6. decodeHtmlEntities
274+
* 7. normalizeUnicode (NFKC + TR39 confusables)
275+
* 8. decodeBase64Blocks
276+
* 9. unescapeSequences
277+
* 10. expandStringConcat
207278
*/
208-
export function deobfuscate(text: string): string {
279+
function _deobfuscatePass(text: string): string {
209280
text = stripZeroWidth(text);
210281
text = stripTagChars(text);
211282
text = stripVariationSelectors(text);
212283
text = stripBidiControls(text);
213284
text = stripHtmlComments(text);
285+
text = decodeHtmlEntities(text);
214286
text = normalizeUnicode(text);
215287
text = decodeBase64Blocks(text);
216288
text = unescapeSequences(text);
217289
text = expandStringConcat(text);
218290
return text;
219291
}
292+
293+
/**
294+
* Apply all deobfuscation transforms to text (2-pass pipeline).
295+
*
296+
* Two passes catch nested obfuscation where the first pass reveals
297+
* content that a second pass can further decode (e.g. base64 hidden
298+
* inside zero-width splits, or escape sequences inside HTML entities).
299+
*/
300+
export function deobfuscate(text: string): string {
301+
text = _deobfuscatePass(text);
302+
text = _deobfuscatePass(text);
303+
return text;
304+
}

0 commit comments

Comments
 (0)