codegate-ai 0.16.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +153 -44
- package/dist/commands/scan-command.d.ts +2 -1
- package/dist/commands/scan-command.js +6 -1
- package/dist/commands/trust.d.ts +28 -0
- package/dist/commands/trust.js +69 -0
- package/dist/config/inline-ignore.d.ts +10 -2
- package/dist/config/inline-ignore.js +5 -1
- package/dist/config/suppression-policy.d.ts +1 -1
- package/dist/config/suppression-policy.js +4 -1
- package/dist/config/trust.d.ts +2 -0
- package/dist/config/trust.js +19 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +45 -1
- package/dist/content/content-bundle.d.ts +27 -0
- package/dist/content/content-bundle.js +73 -0
- package/dist/content/content-store.d.ts +27 -0
- package/dist/content/content-store.js +0 -0
- package/dist/content/content-updater.d.ts +32 -0
- package/dist/content/content-updater.js +111 -0
- package/dist/content/known-bad.d.ts +26 -0
- package/dist/content/known-bad.js +100 -0
- package/dist/content/publisher-key.d.ts +10 -0
- package/dist/content/publisher-key.js +10 -0
- package/dist/layer1-discovery/knowledge-base.js +33 -1
- package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
- package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
- package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
- package/dist/layer2-static/detectors/known-bad.js +116 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
- package/dist/layer2-static/detectors/rule-file.js +128 -75
- package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
- package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
- package/dist/layer2-static/engine.d.ts +2 -0
- package/dist/layer2-static/engine.js +0 -0
- package/dist/layer2-static/rule-pack-loader.js +24 -1
- package/dist/layer2-static/state/scan-state.d.ts +7 -2
- package/dist/layer2-static/state/scan-state.js +74 -30
- package/dist/layer2-static/text/confusables.d.ts +7 -0
- package/dist/layer2-static/text/confusables.js +70 -0
- package/dist/layer2-static/text/edit-distance.d.ts +6 -0
- package/dist/layer2-static/text/edit-distance.js +33 -0
- package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
- package/dist/layer2-static/text/encoded-payloads.js +116 -0
- package/dist/layer2-static/text/normalize.d.ts +11 -0
- package/dist/layer2-static/text/normalize.js +19 -0
- package/dist/layer2-static/text/override-phrases.d.ts +14 -0
- package/dist/layer2-static/text/override-phrases.js +49 -0
- package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
- package/dist/layer2-static/text/threat-patterns.js +45 -0
- package/dist/layer2-static/text/unicode.d.ts +33 -0
- package/dist/layer2-static/text/unicode.js +83 -0
- package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
- package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
- package/dist/layer3-dynamic/meta-agent.js +2 -1
- package/dist/layer3-dynamic/registry-client.d.ts +26 -0
- package/dist/layer3-dynamic/registry-client.js +138 -0
- package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
- package/dist/layer3-dynamic/registry-findings.js +64 -0
- package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
- package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
- package/dist/layer3-dynamic/toxic-flow.js +41 -8
- package/dist/pipeline.d.ts +5 -2
- package/dist/pipeline.js +53 -16
- package/dist/report-summary.d.ts +1 -1
- package/dist/report-summary.js +9 -1
- package/dist/scan.d.ts +13 -0
- package/dist/scan.js +291 -17
- package/dist/types/finding.d.ts +14 -0
- package/dist/types/finding.js +14 -0
- package/dist/types/report.d.ts +2 -0
- package/dist/wrapper.js +2 -19
- package/package.json +1 -1
|
@@ -2,6 +2,9 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
+
const SCAN_STATE_FILE_VERSION = 2;
|
|
6
|
+
/** Bucket used when no project root is supplied (direct API use, tooling). */
|
|
7
|
+
const SHARED_PROJECT_KEY = "*";
|
|
5
8
|
const LAUNCHERS = new Set(["npx", "uvx", "node", "python", "python3", "deno", "bun"]);
|
|
6
9
|
const MCP_SERVER_CONTAINER_KEYS = ["mcpServers", "mcp_servers", "context_servers"];
|
|
7
10
|
const REMOTE_MCP_SERVER_ARRAY_KEYS = ["remoteMCPServers", "remote_mcp_servers"];
|
|
@@ -106,18 +109,29 @@ function normalizedUrlServerId(rawUrl) {
|
|
|
106
109
|
return `url:${trimmed}`;
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
|
-
|
|
112
|
+
const STATE_FINDING_KIND = {
|
|
113
|
+
NewServer: "NEW_SERVER",
|
|
114
|
+
ConfigChange: "CONFIG_CHANGE",
|
|
115
|
+
};
|
|
116
|
+
/** First-use of a server in an untrusted project deserves a visible review. */
|
|
117
|
+
function firstSeenSeverity(trustedTarget) {
|
|
118
|
+
return trustedTarget ? "INFO" : "MEDIUM";
|
|
119
|
+
}
|
|
120
|
+
function makeStateFinding(kind, snapshot, previousLastSeen, trustedTarget) {
|
|
110
121
|
const locationField = snapshot.serverPath ?? `mcpServers.${snapshot.serverName}`;
|
|
111
|
-
if (kind ===
|
|
122
|
+
if (kind === STATE_FINDING_KIND.NewServer) {
|
|
123
|
+
const reviewNote = trustedTarget
|
|
124
|
+
? "Not previously scanned."
|
|
125
|
+
: "Not previously scanned in this untrusted project; review its configuration before use.";
|
|
112
126
|
return {
|
|
113
127
|
rule_id: "mcp-server-first-seen",
|
|
114
128
|
finding_id: `NEW_SERVER-${snapshot.serverId}`,
|
|
115
|
-
severity:
|
|
129
|
+
severity: firstSeenSeverity(trustedTarget),
|
|
116
130
|
category: "NEW_SERVER",
|
|
117
131
|
layer: "L2",
|
|
118
132
|
file_path: snapshot.configPath,
|
|
119
133
|
location: { field: locationField },
|
|
120
|
-
description: `MCP server "${snapshot.serverId}" first seen in this project.
|
|
134
|
+
description: `MCP server "${snapshot.serverId}" first seen in this project. ${reviewNote}`,
|
|
121
135
|
affected_tools: ["claude-code", "cursor", "windsurf", "codex-cli", "opencode"],
|
|
122
136
|
cve: null,
|
|
123
137
|
owasp: ["ASI08"],
|
|
@@ -150,31 +164,22 @@ function makeStateFinding(kind, snapshot, previousLastSeen) {
|
|
|
150
164
|
export function getScanStatePath(customPath) {
|
|
151
165
|
return resolve(expandHomePath(customPath ?? defaultPath()));
|
|
152
166
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
const raw = readFileSync(path, "utf8");
|
|
161
|
-
parsed = JSON.parse(raw);
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return { servers: {} };
|
|
165
|
-
}
|
|
166
|
-
if (!isRecord(parsed) || !isRecord(parsed.servers)) {
|
|
167
|
-
return { servers: {} };
|
|
167
|
+
function projectStateKey(projectRoot) {
|
|
168
|
+
return projectRoot === undefined ? SHARED_PROJECT_KEY : resolve(projectRoot);
|
|
169
|
+
}
|
|
170
|
+
function parseServerEntries(value) {
|
|
171
|
+
if (!isRecord(value)) {
|
|
172
|
+
return {};
|
|
168
173
|
}
|
|
169
174
|
const entries = {};
|
|
170
|
-
for (const [key,
|
|
171
|
-
if (!isRecord(
|
|
175
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
176
|
+
if (!isRecord(entry)) {
|
|
172
177
|
continue;
|
|
173
178
|
}
|
|
174
|
-
const config_hash = typeof
|
|
175
|
-
const config_path = typeof
|
|
176
|
-
const first_seen = typeof
|
|
177
|
-
const last_seen = typeof
|
|
179
|
+
const config_hash = typeof entry.config_hash === "string" ? entry.config_hash : "";
|
|
180
|
+
const config_path = typeof entry.config_path === "string" ? entry.config_path : "";
|
|
181
|
+
const first_seen = typeof entry.first_seen === "string" ? entry.first_seen : "";
|
|
182
|
+
const last_seen = typeof entry.last_seen === "string" ? entry.last_seen : "";
|
|
178
183
|
if (!config_hash || !config_path || !first_seen || !last_seen) {
|
|
179
184
|
continue;
|
|
180
185
|
}
|
|
@@ -185,12 +190,47 @@ export function loadScanState(customPath) {
|
|
|
185
190
|
last_seen,
|
|
186
191
|
};
|
|
187
192
|
}
|
|
188
|
-
return
|
|
193
|
+
return entries;
|
|
189
194
|
}
|
|
190
|
-
|
|
195
|
+
function loadScanStateFile(customPath) {
|
|
196
|
+
const empty = { version: SCAN_STATE_FILE_VERSION, projects: {} };
|
|
191
197
|
const path = getScanStatePath(customPath);
|
|
198
|
+
if (!existsSync(path)) {
|
|
199
|
+
return empty;
|
|
200
|
+
}
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return empty;
|
|
207
|
+
}
|
|
208
|
+
// Legacy (unversioned, top-level `servers`) and malformed files both reset:
|
|
209
|
+
// legacy entries carry no project provenance, so trusting them would leak
|
|
210
|
+
// baselines across projects.
|
|
211
|
+
if (!isRecord(parsed) ||
|
|
212
|
+
parsed.version !== SCAN_STATE_FILE_VERSION ||
|
|
213
|
+
!isRecord(parsed.projects)) {
|
|
214
|
+
return empty;
|
|
215
|
+
}
|
|
216
|
+
const projects = {};
|
|
217
|
+
for (const [projectKey, slice] of Object.entries(parsed.projects)) {
|
|
218
|
+
if (isRecord(slice)) {
|
|
219
|
+
projects[projectKey] = { servers: parseServerEntries(slice.servers) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { version: SCAN_STATE_FILE_VERSION, projects };
|
|
223
|
+
}
|
|
224
|
+
export function loadScanState(customPath, projectRoot) {
|
|
225
|
+
const file = loadScanStateFile(customPath);
|
|
226
|
+
return file.projects[projectStateKey(projectRoot)] ?? { servers: {} };
|
|
227
|
+
}
|
|
228
|
+
export function saveScanState(state, customPath, projectRoot) {
|
|
229
|
+
const path = getScanStatePath(customPath);
|
|
230
|
+
const file = loadScanStateFile(customPath);
|
|
231
|
+
file.projects[projectStateKey(projectRoot)] = state;
|
|
192
232
|
mkdirSync(dirname(path), { recursive: true });
|
|
193
|
-
writeFileSync(path, `${JSON.stringify(
|
|
233
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
194
234
|
}
|
|
195
235
|
export function resetScanState(customPath) {
|
|
196
236
|
const path = getScanStatePath(customPath);
|
|
@@ -198,6 +238,8 @@ export function resetScanState(customPath) {
|
|
|
198
238
|
}
|
|
199
239
|
export function evaluateScanStateSnapshots(input) {
|
|
200
240
|
const nowIso = input.nowIso ?? new Date().toISOString();
|
|
241
|
+
const trustedTarget = input.trustedTarget ?? false;
|
|
242
|
+
const firstScanReview = input.firstScanReview ?? true;
|
|
201
243
|
const nextState = {
|
|
202
244
|
servers: { ...input.previousState.servers },
|
|
203
245
|
};
|
|
@@ -205,7 +247,9 @@ export function evaluateScanStateSnapshots(input) {
|
|
|
205
247
|
for (const snapshot of input.snapshots) {
|
|
206
248
|
const previous = nextState.servers[snapshot.serverId];
|
|
207
249
|
if (!previous) {
|
|
208
|
-
|
|
250
|
+
if (firstScanReview) {
|
|
251
|
+
findings.push(makeStateFinding(STATE_FINDING_KIND.NewServer, snapshot, null, trustedTarget));
|
|
252
|
+
}
|
|
209
253
|
nextState.servers[snapshot.serverId] = {
|
|
210
254
|
config_hash: snapshot.configHash,
|
|
211
255
|
config_path: snapshot.configPath,
|
|
@@ -215,7 +259,7 @@ export function evaluateScanStateSnapshots(input) {
|
|
|
215
259
|
continue;
|
|
216
260
|
}
|
|
217
261
|
if (previous.config_hash !== snapshot.configHash) {
|
|
218
|
-
findings.push(makeStateFinding(
|
|
262
|
+
findings.push(makeStateFinding(STATE_FINDING_KIND.ConfigChange, snapshot, previous.last_seen, trustedTarget));
|
|
219
263
|
nextState.servers[snapshot.serverId] = {
|
|
220
264
|
config_hash: snapshot.configHash,
|
|
221
265
|
config_path: snapshot.configPath,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal confusables map: non-Latin code points that render like Latin
|
|
3
|
+
* letters and are used to slip phrases past literal matching. NFKC already
|
|
4
|
+
* folds fullwidth/compatibility forms; this covers the Cyrillic and Greek
|
|
5
|
+
* lookalikes NFKC leaves alone. Data-driven so a content feed can extend it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CONFUSABLE_TO_LATIN: Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal confusables map: non-Latin code points that render like Latin
|
|
3
|
+
* letters and are used to slip phrases past literal matching. NFKC already
|
|
4
|
+
* folds fullwidth/compatibility forms; this covers the Cyrillic and Greek
|
|
5
|
+
* lookalikes NFKC leaves alone. Data-driven so a content feed can extend it.
|
|
6
|
+
*/
|
|
7
|
+
export const CONFUSABLE_TO_LATIN = {
|
|
8
|
+
// Cyrillic lowercase
|
|
9
|
+
а: "a",
|
|
10
|
+
в: "b",
|
|
11
|
+
е: "e",
|
|
12
|
+
ё: "e",
|
|
13
|
+
к: "k",
|
|
14
|
+
м: "m",
|
|
15
|
+
н: "h",
|
|
16
|
+
о: "o",
|
|
17
|
+
р: "p",
|
|
18
|
+
с: "c",
|
|
19
|
+
т: "t",
|
|
20
|
+
у: "y",
|
|
21
|
+
х: "x",
|
|
22
|
+
і: "i",
|
|
23
|
+
ѕ: "s",
|
|
24
|
+
ј: "j",
|
|
25
|
+
ԁ: "d",
|
|
26
|
+
ԛ: "q",
|
|
27
|
+
ѡ: "w",
|
|
28
|
+
// Cyrillic uppercase
|
|
29
|
+
А: "A",
|
|
30
|
+
В: "B",
|
|
31
|
+
Е: "E",
|
|
32
|
+
К: "K",
|
|
33
|
+
М: "M",
|
|
34
|
+
Н: "H",
|
|
35
|
+
О: "O",
|
|
36
|
+
Р: "P",
|
|
37
|
+
С: "C",
|
|
38
|
+
Т: "T",
|
|
39
|
+
У: "Y",
|
|
40
|
+
Х: "X",
|
|
41
|
+
І: "I",
|
|
42
|
+
Ѕ: "S",
|
|
43
|
+
Ј: "J",
|
|
44
|
+
// Greek lowercase
|
|
45
|
+
α: "a",
|
|
46
|
+
ε: "e",
|
|
47
|
+
η: "n",
|
|
48
|
+
ι: "i",
|
|
49
|
+
κ: "k",
|
|
50
|
+
ν: "v",
|
|
51
|
+
ο: "o",
|
|
52
|
+
ρ: "p",
|
|
53
|
+
τ: "t",
|
|
54
|
+
υ: "u",
|
|
55
|
+
// Greek uppercase
|
|
56
|
+
Α: "A",
|
|
57
|
+
Β: "B",
|
|
58
|
+
Ε: "E",
|
|
59
|
+
Ζ: "Z",
|
|
60
|
+
Η: "H",
|
|
61
|
+
Ι: "I",
|
|
62
|
+
Κ: "K",
|
|
63
|
+
Μ: "M",
|
|
64
|
+
Ν: "N",
|
|
65
|
+
Ο: "O",
|
|
66
|
+
Ρ: "P",
|
|
67
|
+
Τ: "T",
|
|
68
|
+
Υ: "Y",
|
|
69
|
+
Χ: "X",
|
|
70
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Damerau-Levenshtein distance (optimal string alignment variant):
|
|
3
|
+
* insertions, deletions, substitutions, and adjacent transpositions each
|
|
4
|
+
* cost 1. Small inputs only (package names), so the O(n*m) table is fine.
|
|
5
|
+
*/
|
|
6
|
+
export declare function damerauLevenshtein(left: string, right: string): number;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Damerau-Levenshtein distance (optimal string alignment variant):
|
|
3
|
+
* insertions, deletions, substitutions, and adjacent transpositions each
|
|
4
|
+
* cost 1. Small inputs only (package names), so the O(n*m) table is fine.
|
|
5
|
+
*/
|
|
6
|
+
export function damerauLevenshtein(left, right) {
|
|
7
|
+
if (left === right) {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
const rows = left.length + 1;
|
|
11
|
+
const cols = right.length + 1;
|
|
12
|
+
const table = Array.from({ length: rows }, () => new Array(cols).fill(0));
|
|
13
|
+
for (let row = 0; row < rows; row += 1) {
|
|
14
|
+
table[row][0] = row;
|
|
15
|
+
}
|
|
16
|
+
for (let col = 0; col < cols; col += 1) {
|
|
17
|
+
table[0][col] = col;
|
|
18
|
+
}
|
|
19
|
+
for (let row = 1; row < rows; row += 1) {
|
|
20
|
+
for (let col = 1; col < cols; col += 1) {
|
|
21
|
+
const cost = left[row - 1] === right[col - 1] ? 0 : 1;
|
|
22
|
+
let value = Math.min(table[row - 1][col] + 1, table[row][col - 1] + 1, table[row - 1][col - 1] + cost);
|
|
23
|
+
if (row > 1 &&
|
|
24
|
+
col > 1 &&
|
|
25
|
+
left[row - 1] === right[col - 2] &&
|
|
26
|
+
left[row - 2] === right[col - 1]) {
|
|
27
|
+
value = Math.min(value, table[row - 2][col - 2] + 1);
|
|
28
|
+
}
|
|
29
|
+
table[row][col] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return table[rows - 1][cols - 1];
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const ENCODED_PAYLOAD_KIND: {
|
|
2
|
+
readonly Base64: "base64";
|
|
3
|
+
readonly Hex: "hex";
|
|
4
|
+
};
|
|
5
|
+
export type EncodedPayloadKind = (typeof ENCODED_PAYLOAD_KIND)[keyof typeof ENCODED_PAYLOAD_KIND];
|
|
6
|
+
export interface EncodedPayloadMatch {
|
|
7
|
+
kind: EncodedPayloadKind;
|
|
8
|
+
line: number;
|
|
9
|
+
decodedExcerpt: string;
|
|
10
|
+
matchesRemoteShell: boolean;
|
|
11
|
+
matchesOverridePhrase: boolean;
|
|
12
|
+
matchesSensitiveExfil: boolean;
|
|
13
|
+
matchesCommandExecution: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Scan text for encoded blobs whose decoded content matches threat patterns. */
|
|
16
|
+
export declare function scanEncodedPayloads(text: string): EncodedPayloadMatch[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { normalizeForMatching } from "./normalize.js";
|
|
2
|
+
import { COMMAND_EXECUTION_PATTERN, EXFIL_PATTERN, REMOTE_SHELL_PATTERN, SENSITIVE_FILE_PATTERN, findOverridePhrase, } from "./threat-patterns.js";
|
|
3
|
+
/**
|
|
4
|
+
* Bounded decode-and-rescan for base64/hex blobs embedded in instruction
|
|
5
|
+
* files and tool descriptions. Hard limits keep this pass cheap and
|
|
6
|
+
* non-explosive: one nesting level, capped blob count and decoded size.
|
|
7
|
+
*/
|
|
8
|
+
const BASE64_RUN_PATTERN = /[A-Za-z0-9+/_-]{40,}={0,2}/gu;
|
|
9
|
+
const HEX_RUN_PATTERN = /(?:[0-9a-fA-F]{2}){30,}/gu;
|
|
10
|
+
const MAX_BLOBS_PER_TEXT = 20;
|
|
11
|
+
const MAX_DECODED_BYTES = 64 * 1024;
|
|
12
|
+
const MIN_PRINTABLE_RATIO = 0.3;
|
|
13
|
+
const DECODED_EXCERPT_LENGTH = 160;
|
|
14
|
+
export const ENCODED_PAYLOAD_KIND = {
|
|
15
|
+
Base64: "base64",
|
|
16
|
+
Hex: "hex",
|
|
17
|
+
};
|
|
18
|
+
function printableRatio(text) {
|
|
19
|
+
if (text.length === 0) {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
let printable = 0;
|
|
23
|
+
for (const char of text) {
|
|
24
|
+
const code = char.codePointAt(0) ?? 0;
|
|
25
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code < 0x7f)) {
|
|
26
|
+
printable += 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return printable / text.length;
|
|
30
|
+
}
|
|
31
|
+
function decodeBase64(blob) {
|
|
32
|
+
// Normalize base64url variants before decoding.
|
|
33
|
+
const normalized = blob.replace(/-/gu, "+").replace(/_/gu, "/");
|
|
34
|
+
try {
|
|
35
|
+
const decoded = Buffer.from(normalized, "base64");
|
|
36
|
+
if (decoded.length === 0 || decoded.length > MAX_DECODED_BYTES) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return decoded.toString("utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function decodeHex(blob) {
|
|
46
|
+
try {
|
|
47
|
+
const decoded = Buffer.from(blob, "hex");
|
|
48
|
+
if (decoded.length === 0 || decoded.length > MAX_DECODED_BYTES) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return decoded.toString("utf8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function lineNumberAt(text, index) {
|
|
58
|
+
return text.slice(0, index).split(/\r?\n/u).length;
|
|
59
|
+
}
|
|
60
|
+
function isDataImageUri(text, blobIndex) {
|
|
61
|
+
const prefixStart = Math.max(0, blobIndex - 40);
|
|
62
|
+
return /data:image\/[a-z+.-]+;base64,?$/iu.test(text.slice(prefixStart, blobIndex));
|
|
63
|
+
}
|
|
64
|
+
function analyzeDecoded(kind, line, decoded) {
|
|
65
|
+
if (printableRatio(decoded) < MIN_PRINTABLE_RATIO) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const normalized = normalizeForMatching(decoded);
|
|
69
|
+
const matchesRemoteShell = REMOTE_SHELL_PATTERN.test(normalized);
|
|
70
|
+
const matchesOverridePhrase = findOverridePhrase(normalized) !== null;
|
|
71
|
+
const matchesSensitiveExfil = SENSITIVE_FILE_PATTERN.test(normalized) && EXFIL_PATTERN.test(normalized);
|
|
72
|
+
const matchesCommandExecution = COMMAND_EXECUTION_PATTERN.test(normalized);
|
|
73
|
+
if (!matchesRemoteShell &&
|
|
74
|
+
!matchesOverridePhrase &&
|
|
75
|
+
!matchesSensitiveExfil &&
|
|
76
|
+
!matchesCommandExecution) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind,
|
|
81
|
+
line,
|
|
82
|
+
decodedExcerpt: decoded.slice(0, DECODED_EXCERPT_LENGTH),
|
|
83
|
+
matchesRemoteShell,
|
|
84
|
+
matchesOverridePhrase,
|
|
85
|
+
matchesSensitiveExfil,
|
|
86
|
+
matchesCommandExecution,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Scan text for encoded blobs whose decoded content matches threat patterns. */
|
|
90
|
+
export function scanEncodedPayloads(text) {
|
|
91
|
+
const matches = [];
|
|
92
|
+
let blobsSeen = 0;
|
|
93
|
+
const scanRuns = (pattern, kind, decode) => {
|
|
94
|
+
pattern.lastIndex = 0;
|
|
95
|
+
let match = pattern.exec(text);
|
|
96
|
+
while (match && blobsSeen < MAX_BLOBS_PER_TEXT) {
|
|
97
|
+
blobsSeen += 1;
|
|
98
|
+
const blob = match[0];
|
|
99
|
+
if (kind === ENCODED_PAYLOAD_KIND.Base64 && isDataImageUri(text, match.index)) {
|
|
100
|
+
match = pattern.exec(text);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const decoded = decode(blob);
|
|
104
|
+
if (decoded) {
|
|
105
|
+
const analyzed = analyzeDecoded(kind, lineNumberAt(text, match.index), decoded);
|
|
106
|
+
if (analyzed) {
|
|
107
|
+
matches.push(analyzed);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
match = pattern.exec(text);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
scanRuns(BASE64_RUN_PATTERN, ENCODED_PAYLOAD_KIND.Base64, decodeBase64);
|
|
114
|
+
scanRuns(HEX_RUN_PATTERN, ENCODED_PAYLOAD_KIND.Hex, decodeHex);
|
|
115
|
+
return matches;
|
|
116
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize text before threat-pattern matching so trivial obfuscation
|
|
3
|
+
* (zero-width splits, tag characters, homoglyphs, compatibility forms)
|
|
4
|
+
* cannot dodge literal patterns:
|
|
5
|
+
*
|
|
6
|
+
* NFKC -> strip hidden characters -> fold confusables -> lowercase
|
|
7
|
+
*
|
|
8
|
+
* Normalize per line when line numbers matter: evidence must quote the
|
|
9
|
+
* original line while matching runs against the normalized one.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeForMatching(text: string): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CONFUSABLE_TO_LATIN } from "./confusables.js";
|
|
2
|
+
import { stripHiddenCharacters } from "./unicode.js";
|
|
3
|
+
const confusablePattern = new RegExp(`[${Object.keys(CONFUSABLE_TO_LATIN).join("")}]`, "gu");
|
|
4
|
+
function foldConfusables(text) {
|
|
5
|
+
return text.replace(confusablePattern, (char) => CONFUSABLE_TO_LATIN[char] ?? char);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Normalize text before threat-pattern matching so trivial obfuscation
|
|
9
|
+
* (zero-width splits, tag characters, homoglyphs, compatibility forms)
|
|
10
|
+
* cannot dodge literal patterns:
|
|
11
|
+
*
|
|
12
|
+
* NFKC -> strip hidden characters -> fold confusables -> lowercase
|
|
13
|
+
*
|
|
14
|
+
* Normalize per line when line numbers matter: evidence must quote the
|
|
15
|
+
* original line while matching runs against the normalized one.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeForMatching(text) {
|
|
18
|
+
return foldConfusables(stripHiddenCharacters(text.normalize("NFKC"))).toLowerCase();
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instruction-override phrases matched against normalized (NFKC, hidden
|
|
3
|
+
* characters stripped, confusable-folded, lowercased) text. This is the
|
|
4
|
+
* high-signal literal tier; semantic analysis belongs to the Layer 3
|
|
5
|
+
* meta-agent. Data-driven so the content feed can extend it.
|
|
6
|
+
*/
|
|
7
|
+
export interface OverridePhrase {
|
|
8
|
+
phrase: string;
|
|
9
|
+
language: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const OVERRIDE_PHRASES: readonly OverridePhrase[];
|
|
12
|
+
export declare function resetActiveOverridePhrasesCache(): void;
|
|
13
|
+
/** Bundled phrases plus any extras delivered by the verified content feed. */
|
|
14
|
+
export declare function activeOverridePhrases(): readonly OverridePhrase[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadActiveContentBundle } from "../../content/content-store.js";
|
|
2
|
+
export const OVERRIDE_PHRASES = [
|
|
3
|
+
{ phrase: "ignore previous instructions", language: "en" },
|
|
4
|
+
{ phrase: "ignore all previous instructions", language: "en" },
|
|
5
|
+
{ phrase: "disregard previous instructions", language: "en" },
|
|
6
|
+
{ phrase: "disregard prior instructions", language: "en" },
|
|
7
|
+
{ phrase: "skip permissions", language: "en" },
|
|
8
|
+
{ phrase: "bypass permissions", language: "en" },
|
|
9
|
+
{ phrase: "bypass safety", language: "en" },
|
|
10
|
+
{ phrase: "disable guardrails", language: "en" },
|
|
11
|
+
{ phrase: "ignora las instrucciones anteriores", language: "es" },
|
|
12
|
+
{ phrase: "ignore las instrucciones anteriores", language: "es" },
|
|
13
|
+
{ phrase: "ignore as instrucoes anteriores", language: "pt" },
|
|
14
|
+
{ phrase: "ignore as instruções anteriores", language: "pt" },
|
|
15
|
+
{ phrase: "ignore les instructions precedentes", language: "fr" },
|
|
16
|
+
{ phrase: "ignore les instructions précédentes", language: "fr" },
|
|
17
|
+
{ phrase: "ignorez les instructions précédentes", language: "fr" },
|
|
18
|
+
{ phrase: "ignoriere alle vorherigen anweisungen", language: "de" },
|
|
19
|
+
{ phrase: "ignoriere die vorherigen anweisungen", language: "de" },
|
|
20
|
+
{ phrase: "игнорируй предыдущие инструкции", language: "ru" },
|
|
21
|
+
{ phrase: "игнорируйте предыдущие инструкции", language: "ru" },
|
|
22
|
+
{ phrase: "忽略之前的指令", language: "zh" },
|
|
23
|
+
{ phrase: "忽略以上指令", language: "zh" },
|
|
24
|
+
{ phrase: "忽略先前的指示", language: "zh" },
|
|
25
|
+
{ phrase: "以前の指示を無視して", language: "ja" },
|
|
26
|
+
{ phrase: "これまでの指示を無視", language: "ja" },
|
|
27
|
+
];
|
|
28
|
+
let cachedActivePhrases = null;
|
|
29
|
+
export function resetActiveOverridePhrasesCache() {
|
|
30
|
+
cachedActivePhrases = null;
|
|
31
|
+
}
|
|
32
|
+
/** Bundled phrases plus any extras delivered by the verified content feed. */
|
|
33
|
+
export function activeOverridePhrases() {
|
|
34
|
+
if (cachedActivePhrases) {
|
|
35
|
+
return cachedActivePhrases;
|
|
36
|
+
}
|
|
37
|
+
let feedPhrases;
|
|
38
|
+
try {
|
|
39
|
+
const bundle = loadActiveContentBundle();
|
|
40
|
+
feedPhrases = (bundle?.override_phrases ?? []).filter((entry) => typeof entry?.phrase === "string" &&
|
|
41
|
+
entry.phrase.length > 0 &&
|
|
42
|
+
typeof entry?.language === "string");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
feedPhrases = [];
|
|
46
|
+
}
|
|
47
|
+
cachedActivePhrases = [...OVERRIDE_PHRASES, ...feedPhrases];
|
|
48
|
+
return cachedActivePhrases;
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared threat patterns for instruction/rule-file text and MCP tool
|
|
3
|
+
* descriptions. Callers are expected to match against text run through
|
|
4
|
+
* normalizeForMatching() so hidden-character and homoglyph obfuscation
|
|
5
|
+
* cannot dodge these literals.
|
|
6
|
+
*/
|
|
7
|
+
export declare const NEGATION_PATTERN: RegExp;
|
|
8
|
+
export declare const SENSITIVE_READ_PATTERN: RegExp;
|
|
9
|
+
export declare const SENSITIVE_FILE_PATTERN: RegExp;
|
|
10
|
+
export declare const OUTBOUND_TRANSFER_PATTERN: RegExp;
|
|
11
|
+
export declare const EXFIL_PATTERN: RegExp;
|
|
12
|
+
export declare const COMMAND_EXECUTION_PATTERN: RegExp;
|
|
13
|
+
export declare const REMOTE_SHELL_PATTERN: RegExp;
|
|
14
|
+
export declare const SUSPICIOUS_LONG_LINE_PATTERN: RegExp;
|
|
15
|
+
export declare const HTML_COMMENT_PATTERN: RegExp;
|
|
16
|
+
export declare const COMMENT_PAYLOAD_PATTERN: RegExp;
|
|
17
|
+
export declare const COOKIE_EXPORT_PATTERN: RegExp;
|
|
18
|
+
export declare const SESSION_SHARE_PATTERN: RegExp;
|
|
19
|
+
export declare const PROFILE_SYNC_PATTERN: RegExp;
|
|
20
|
+
export declare const BOOTSTRAP_INSTALL_PATTERN: RegExp;
|
|
21
|
+
export declare const AGENT_CONTROL_POINT_PATTERN: RegExp;
|
|
22
|
+
export declare const RESTART_LOAD_PATTERN: RegExp;
|
|
23
|
+
export declare const REMOTE_INSTRUCTION_INDIRECTION_PATTERN: RegExp;
|
|
24
|
+
export interface OverridePhraseMatch {
|
|
25
|
+
phrase: string;
|
|
26
|
+
index: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Find the first instruction-override phrase in a normalized line, skipping
|
|
30
|
+
* matches preceded by nearby negation ("never bypass permissions").
|
|
31
|
+
*/
|
|
32
|
+
export declare function findOverridePhrase(normalizedLine: string): OverridePhraseMatch | null;
|
|
33
|
+
export declare function hasNegationBefore(normalizedLine: string, matchIndex: number): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { activeOverridePhrases } from "./override-phrases.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shared threat patterns for instruction/rule-file text and MCP tool
|
|
4
|
+
* descriptions. Callers are expected to match against text run through
|
|
5
|
+
* normalizeForMatching() so hidden-character and homoglyph obfuscation
|
|
6
|
+
* cannot dodge these literals.
|
|
7
|
+
*/
|
|
8
|
+
export const NEGATION_PATTERN = /\b(?:must not|should not|do not|don't|never)\b/iu;
|
|
9
|
+
export const SENSITIVE_READ_PATTERN = /\b(?:read|cat)\s+(?:~\/\.ssh(?:\/[^\s]+)?|\.env\b|~\/\.[a-z0-9._-]+(?:\/[^\s]+)*)/iu;
|
|
10
|
+
export const SENSITIVE_FILE_PATTERN = /(~\/\.ssh|~\/\.aws|id_rsa|\.env|credentials|\.git-credentials)/iu;
|
|
11
|
+
export const OUTBOUND_TRANSFER_PATTERN = /\b(?:upload externally|send to (?:an |a )?(?:external )?(?:webhook|endpoint|server)|curl\b|wget\b|invoke-webrequest\b|post to\b|https?:\/\/|exfiltrat(?:e|ion|ing))\b/iu;
|
|
12
|
+
export const EXFIL_PATTERN = /(send .*https?:\/\/|upload|webhook|post to|exfiltrat)/iu;
|
|
13
|
+
export const COMMAND_EXECUTION_PATTERN = /(run command|execute shell|bash -c|sh -c|powershell)/iu;
|
|
14
|
+
export const REMOTE_SHELL_PATTERN = /\b(?:curl|wget)\b[^\n|]{0,240}\|\s*(?:bash|sh)\b|\b(?:invoke-webrequest|iwr)\b[^\n|]{0,240}\|\s*(?:iex|invoke-expression)\b/iu;
|
|
15
|
+
export const SUSPICIOUS_LONG_LINE_PATTERN = /\b(?:ignore previous instructions|skip permissions|bypass permissions|upload externally|curl\b|wget\b|https?:\/\/|bash\s+-lc|sh\s+-c|powershell\b|base64\b|~\/\.ssh|\.env\b)\b/iu;
|
|
16
|
+
export const HTML_COMMENT_PATTERN = /<!--([\s\S]*?)-->/gu;
|
|
17
|
+
export const COMMENT_PAYLOAD_PATTERN = /\b(?:secret instructions|ignore previous instructions|curl\b|wget\b|invoke-webrequest\b|bash\b|powershell\b|session share\b|profile sync\b)\b/iu;
|
|
18
|
+
export const COOKIE_EXPORT_PATTERN = /\bcookies?\s+(?:export|import|get)\b/iu;
|
|
19
|
+
export const SESSION_SHARE_PATTERN = /\bsession\s+share\b|\blive url\b/iu;
|
|
20
|
+
export const PROFILE_SYNC_PATTERN = /\bprofile\s+sync\b|\breal chrome\b|\blogin sessions\b|\bsession tokens?\b|--profile\b/iu;
|
|
21
|
+
export const BOOTSTRAP_INSTALL_PATTERN = /\b(?:npm|pnpm|yarn|bun)\s+install\s+-g\b|\bbrew\s+install\b|\bpipx\s+install\b|\bgo\s+install\b|\b(?:npx|pnpx|uvx)\b[^\n`]{0,160}@latest\b/iu;
|
|
22
|
+
export const AGENT_CONTROL_POINT_PATTERN = /\.claude\/hooks\/|\.claude\/settings\.json|\.claude\/agents\/|\bclaude\.md\b|\bagents\.md\b|\bmcp configuration\b/iu;
|
|
23
|
+
export const RESTART_LOAD_PATTERN = /\brestart\b.*\b(?:load|take effect|activate|reload|work)\b|\bonly load after restart\b|\bafter restarting\b/iu;
|
|
24
|
+
export const REMOTE_INSTRUCTION_INDIRECTION_PATTERN = /\b(?:follow|read|fetch|apply|obey|execute)\b[^\n]{0,40}\b(?:instructions|steps|guide|rules|directives)\b[^\n]{0,40}https?:\/\//iu;
|
|
25
|
+
function hasNearbyNegation(normalizedLine, matchIndex) {
|
|
26
|
+
const prefix = normalizedLine.slice(Math.max(0, matchIndex - 24), matchIndex);
|
|
27
|
+
return NEGATION_PATTERN.test(prefix);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Find the first instruction-override phrase in a normalized line, skipping
|
|
31
|
+
* matches preceded by nearby negation ("never bypass permissions").
|
|
32
|
+
*/
|
|
33
|
+
export function findOverridePhrase(normalizedLine) {
|
|
34
|
+
for (const { phrase } of activeOverridePhrases()) {
|
|
35
|
+
const matchIndex = normalizedLine.indexOf(phrase);
|
|
36
|
+
if (matchIndex < 0 || hasNearbyNegation(normalizedLine, matchIndex)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return { phrase, index: matchIndex };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export function hasNegationBefore(normalizedLine, matchIndex) {
|
|
44
|
+
return hasNearbyNegation(normalizedLine, matchIndex);
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for hidden/invisible Unicode classes used across
|
|
3
|
+
* detectors. Tag characters (U+E0000-U+E007F) are the primary real-world
|
|
4
|
+
* "ASCII smuggling" vector: they encode invisible ASCII that survives
|
|
5
|
+
* copy/paste and reaches models while remaining unseen by human reviewers.
|
|
6
|
+
*
|
|
7
|
+
* All patterns use escape sequences on purpose: this file must never
|
|
8
|
+
* contain literal hidden characters.
|
|
9
|
+
*/
|
|
10
|
+
export declare const HIDDEN_UNICODE_CLASS: {
|
|
11
|
+
readonly ZeroWidth: "zero-width";
|
|
12
|
+
readonly Bidi: "bidi";
|
|
13
|
+
readonly Tags: "tags";
|
|
14
|
+
readonly VariationSelector: "variation-selector";
|
|
15
|
+
};
|
|
16
|
+
export type HiddenUnicodeClass = (typeof HIDDEN_UNICODE_CLASS)[keyof typeof HIDDEN_UNICODE_CLASS];
|
|
17
|
+
export declare const ZERO_WIDTH_PATTERN: RegExp;
|
|
18
|
+
export declare const BIDI_CONTROL_PATTERN: RegExp;
|
|
19
|
+
export declare const TAG_CHARACTER_PATTERN: RegExp;
|
|
20
|
+
export declare const VARIATION_SELECTOR_PATTERN: RegExp;
|
|
21
|
+
export interface HiddenUnicodeMatch {
|
|
22
|
+
index: number;
|
|
23
|
+
codePoint: number;
|
|
24
|
+
class: HiddenUnicodeClass;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Find hidden Unicode characters worth flagging. Variation selectors are
|
|
28
|
+
* reported only when clustered (two or more in a row).
|
|
29
|
+
*/
|
|
30
|
+
export declare function findHiddenUnicode(text: string): HiddenUnicodeMatch[];
|
|
31
|
+
/** Remove every hidden character class so pattern matching sees the visible text. */
|
|
32
|
+
export declare function stripHiddenCharacters(text: string): string;
|
|
33
|
+
export declare function hasHiddenUnicodeClass(text: string, cls: HiddenUnicodeClass): boolean;
|