feat: flag glyph covert-channel risks
This commit is contained in:
parent
7172570351
commit
ec26b560ab
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { findHiddenUnicode, hasHiddenUnicode, stripHiddenUnicode } from "./unicode-safety.js";
|
||||
import { assessLegibilityRisk, findHiddenUnicode, hasHiddenUnicode, stripHiddenUnicode } from "./unicode-safety.js";
|
||||
|
||||
describe("unicode safety", () => {
|
||||
it("strips hidden instruction carriers", () => {
|
||||
|
|
@ -10,4 +10,17 @@ describe("unicode safety", () => {
|
|||
expect(stripHiddenUnicode(text)).toBe("safetext");
|
||||
expect(findHiddenUnicode(text).map((hit) => hit.codePoint)).toEqual(["U+E0069", "U+200B", "U+202E"]);
|
||||
});
|
||||
|
||||
it("flags private-use glyph covert channels", () => {
|
||||
const risk = assessLegibilityRisk(`function ${String.fromCharCode(0xe000)}() { return ${String.fromCharCode(0xe001)}; }`);
|
||||
|
||||
expect(risk.risky).toBe(true);
|
||||
expect(risk.reason).toContain("private-use");
|
||||
});
|
||||
|
||||
it("allows ordinary non-English text", () => {
|
||||
const risk = assessLegibilityRisk("Привет мир, обычный текст без кода");
|
||||
|
||||
expect(risk.risky).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
const HIDDEN_UNICODE = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFE00-\uFE0F\u{E0000}-\u{E007F}]/gu;
|
||||
const CODE_LIKE = /\b(function|const|let|var|class|import|export|return|if|for|while|=>|docker|curl|bash|npm|node|python)\b|[{}();=<>]/u;
|
||||
|
||||
export interface LegibilityRisk {
|
||||
risky: boolean;
|
||||
reason: string;
|
||||
nonAsciiRatio: number;
|
||||
symbolRatio: number;
|
||||
privateUseCount: number;
|
||||
}
|
||||
|
||||
export function stripHiddenUnicode(text: string): string {
|
||||
// ponytail: strips known invisible instruction carriers; add reporting metadata if UI needs forensics.
|
||||
|
|
@ -17,3 +26,22 @@ export function findHiddenUnicode(text: string): { index: number; codePoint: str
|
|||
codePoint: `U+${match[0].codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function assessLegibilityRisk(text: string): LegibilityRisk {
|
||||
const chars = [...stripHiddenUnicode(text)].filter((c) => !/\s/u.test(c));
|
||||
if (chars.length === 0) return { risky: false, reason: "empty", nonAsciiRatio: 0, symbolRatio: 0, privateUseCount: 0 };
|
||||
const nonAscii = chars.filter((c) => c.codePointAt(0)! > 0x7f).length;
|
||||
const symbols = chars.filter((c) => /[\p{S}\p{Co}]/u.test(c)).length;
|
||||
const privateUseCount = chars.filter((c) => /\p{Co}/u.test(c)).length;
|
||||
const nonAsciiRatio = nonAscii / chars.length;
|
||||
const symbolRatio = symbols / chars.length;
|
||||
const codeLike = CODE_LIKE.test(text);
|
||||
// ponytail: conservative covert-channel tripwire; tune only with real false positives.
|
||||
const risky = privateUseCount > 0 || (codeLike && chars.length >= 20 && nonAsciiRatio > 0.35 && symbolRatio > 0.2);
|
||||
const reason = privateUseCount > 0
|
||||
? "private-use glyphs present"
|
||||
: risky
|
||||
? "code-like text has low human legibility"
|
||||
: "legible enough";
|
||||
return { risky, reason, nonAsciiRatio, symbolRatio, privateUseCount };
|
||||
}
|
||||
|
|
|
|||
12
src/index.ts
12
src/index.ts
|
|
@ -3072,6 +3072,7 @@ security
|
|||
.option("--include-fixtures", "Include intentional red-team fixtures/generators")
|
||||
.action(async (target: string, opts: { includeFixtures?: boolean }) => {
|
||||
const { findPromptInjection } = await import("./core/prompt-injection-safety.js");
|
||||
const { assessLegibilityRisk } = await import("./core/unicode-safety.js");
|
||||
const root = path.resolve(target);
|
||||
const files: string[] = [];
|
||||
|
||||
|
|
@ -3094,10 +3095,13 @@ security
|
|||
walk(root);
|
||||
let hits = 0;
|
||||
for (const file of files) {
|
||||
const found = findPromptInjection(fs.readFileSync(file, "utf-8")).filter((h) => h.kind !== "instruction-smuggling");
|
||||
if (found.length === 0) continue;
|
||||
hits += found.length;
|
||||
console.log(`${file}: ${found.map((h) => `${h.kind}:${h.match}@${h.index}`).join(", ")}`);
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
const found = findPromptInjection(content).filter((h) => h.kind !== "instruction-smuggling");
|
||||
const legibility = assessLegibilityRisk(content);
|
||||
if (found.length === 0 && !legibility.risky) continue;
|
||||
hits += found.length + (legibility.risky ? 1 : 0);
|
||||
if (found.length > 0) console.log(`${file}: ${found.map((h) => `${h.kind}:${h.match}@${h.index}`).join(", ")}`);
|
||||
if (legibility.risky) console.log(`${file}: human-legibility-risk:${legibility.reason}`);
|
||||
}
|
||||
|
||||
if (hits > 0) {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ describe("ContentSafetyGate", () => {
|
|||
expect(v.action).toBe("allow");
|
||||
});
|
||||
|
||||
it("blocks human-unreadable glyph code", () => {
|
||||
const glyph = String.fromCharCode(0xe000);
|
||||
const v = gate.evaluate(`function ${glyph}() { return ${glyph}; }`);
|
||||
expect(v.action).toBe("block");
|
||||
expect(v.classification.reason).toContain("human-legibility");
|
||||
});
|
||||
|
||||
it("reformulates medium-risk financial advice", () => {
|
||||
const v = gate.evaluate("Give me stock trading advice");
|
||||
expect(["reformulate", "block", "allow"]).toContain(v.action);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { assessLegibilityRisk } from "../core/unicode-safety.js";
|
||||
|
||||
/**
|
||||
* Content Safety Gate — classifies tasks by risk domain before they reach the loop.
|
||||
*
|
||||
|
|
@ -139,6 +141,17 @@ export class ContentSafetyGate {
|
|||
};
|
||||
}
|
||||
|
||||
const legibility = assessLegibilityRisk(task);
|
||||
if (legibility.risky) {
|
||||
return {
|
||||
domain: "cybersecurity_exploit",
|
||||
level: "high",
|
||||
confidence: 0.9,
|
||||
reason: `Task has human-legibility/covert-channel risk: ${legibility.reason}`,
|
||||
reformulation: "Provide reviewable ASCII/source text before requesting execution, deployment, or code review.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check each domain for matches
|
||||
for (const [domain, patterns] of this.domainPatterns) {
|
||||
for (const pattern of patterns) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue