codegate-ai 0.16.2 → 1.0.0

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.
Files changed (74) hide show
  1. package/dist/cli.d.ts +3 -1
  2. package/dist/cli.js +153 -44
  3. package/dist/commands/scan-command.d.ts +2 -1
  4. package/dist/commands/scan-command.js +6 -1
  5. package/dist/commands/trust.d.ts +28 -0
  6. package/dist/commands/trust.js +69 -0
  7. package/dist/config/inline-ignore.d.ts +10 -2
  8. package/dist/config/inline-ignore.js +5 -1
  9. package/dist/config/suppression-policy.d.ts +1 -1
  10. package/dist/config/suppression-policy.js +4 -1
  11. package/dist/config/trust.d.ts +2 -0
  12. package/dist/config/trust.js +19 -0
  13. package/dist/config.d.ts +14 -0
  14. package/dist/config.js +45 -1
  15. package/dist/content/content-bundle.d.ts +27 -0
  16. package/dist/content/content-bundle.js +73 -0
  17. package/dist/content/content-store.d.ts +27 -0
  18. package/dist/content/content-store.js +0 -0
  19. package/dist/content/content-updater.d.ts +32 -0
  20. package/dist/content/content-updater.js +111 -0
  21. package/dist/content/known-bad.d.ts +26 -0
  22. package/dist/content/known-bad.js +100 -0
  23. package/dist/content/publisher-key.d.ts +10 -0
  24. package/dist/content/publisher-key.js +10 -0
  25. package/dist/layer1-discovery/knowledge-base.js +33 -1
  26. package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
  27. package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
  28. package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
  29. package/dist/layer2-static/detectors/known-bad.js +116 -0
  30. package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
  31. package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
  32. package/dist/layer2-static/detectors/rule-file.js +128 -75
  33. package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
  34. package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
  35. package/dist/layer2-static/engine.d.ts +2 -0
  36. package/dist/layer2-static/engine.js +0 -0
  37. package/dist/layer2-static/rule-pack-loader.js +24 -1
  38. package/dist/layer2-static/state/scan-state.d.ts +7 -2
  39. package/dist/layer2-static/state/scan-state.js +74 -30
  40. package/dist/layer2-static/text/confusables.d.ts +7 -0
  41. package/dist/layer2-static/text/confusables.js +70 -0
  42. package/dist/layer2-static/text/edit-distance.d.ts +6 -0
  43. package/dist/layer2-static/text/edit-distance.js +33 -0
  44. package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
  45. package/dist/layer2-static/text/encoded-payloads.js +116 -0
  46. package/dist/layer2-static/text/normalize.d.ts +11 -0
  47. package/dist/layer2-static/text/normalize.js +19 -0
  48. package/dist/layer2-static/text/override-phrases.d.ts +14 -0
  49. package/dist/layer2-static/text/override-phrases.js +49 -0
  50. package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
  51. package/dist/layer2-static/text/threat-patterns.js +45 -0
  52. package/dist/layer2-static/text/unicode.d.ts +33 -0
  53. package/dist/layer2-static/text/unicode.js +83 -0
  54. package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
  55. package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
  56. package/dist/layer3-dynamic/meta-agent.js +2 -1
  57. package/dist/layer3-dynamic/registry-client.d.ts +26 -0
  58. package/dist/layer3-dynamic/registry-client.js +138 -0
  59. package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
  60. package/dist/layer3-dynamic/registry-findings.js +64 -0
  61. package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
  62. package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
  63. package/dist/layer3-dynamic/toxic-flow.js +41 -8
  64. package/dist/pipeline.d.ts +5 -2
  65. package/dist/pipeline.js +53 -16
  66. package/dist/report-summary.d.ts +1 -1
  67. package/dist/report-summary.js +9 -1
  68. package/dist/scan.d.ts +13 -0
  69. package/dist/scan.js +291 -17
  70. package/dist/types/finding.d.ts +14 -0
  71. package/dist/types/finding.js +14 -0
  72. package/dist/types/report.d.ts +2 -0
  73. package/dist/wrapper.js +2 -19
  74. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ import { load as parseYaml } from "js-yaml";
2
+ import { normalizeForMatching } from "../text/normalize.js";
3
+ import { REMOTE_INSTRUCTION_INDIRECTION_PATTERN, REMOTE_SHELL_PATTERN, findOverridePhrase, } from "../text/threat-patterns.js";
4
+ const SKILL_FILE_NAME = "skill.md";
5
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u;
6
+ // Tool grants that hand a skill unrestricted execution or wildcard access.
7
+ const BROAD_TOOL_BASES = new Set(["bash", "shell", "terminal", "exec", "execute"]);
8
+ const WILDCARD_GRANTS = new Set(["*", "all"]);
9
+ function isSkillFile(filePath) {
10
+ const segments = filePath.replaceAll("\\", "/").split("/");
11
+ return (segments[segments.length - 1] ?? "").toLowerCase() === SKILL_FILE_NAME;
12
+ }
13
+ function skillDirectoryName(filePath) {
14
+ const segments = filePath.replaceAll("\\", "/").split("/");
15
+ return segments.length >= 2 ? (segments[segments.length - 2] ?? null) : null;
16
+ }
17
+ function makeFinding(input, ruleId, field, severity, category, description, cwe) {
18
+ return {
19
+ rule_id: ruleId,
20
+ finding_id: `SKILL_FRONTMATTER-${input.filePath}-${field}`,
21
+ severity,
22
+ category,
23
+ layer: "L2",
24
+ file_path: input.filePath,
25
+ location: { field: `frontmatter.${field}` },
26
+ description,
27
+ affected_tools: ["claude-code", "codex-cli", "opencode", "cursor"],
28
+ cve: null,
29
+ owasp: ["ASI02"],
30
+ cwe,
31
+ confidence: "HIGH",
32
+ fixable: true,
33
+ remediation_actions: ["remove_field", "quarantine_file"],
34
+ metadata: {
35
+ sources: [input.filePath, field],
36
+ risk_tags: ["skill", "frontmatter"],
37
+ origin: "skill-frontmatter",
38
+ },
39
+ suppressed: false,
40
+ };
41
+ }
42
+ function parseFrontmatter(textContent) {
43
+ const match = textContent.match(FRONTMATTER_PATTERN);
44
+ if (!match?.[1]) {
45
+ return null;
46
+ }
47
+ try {
48
+ const parsed = parseYaml(match[1]);
49
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
50
+ return null;
51
+ }
52
+ return parsed;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function collectToolGrants(value) {
59
+ if (typeof value === "string") {
60
+ return value
61
+ .split(",")
62
+ .map((entry) => entry.trim())
63
+ .filter((entry) => entry.length > 0);
64
+ }
65
+ if (Array.isArray(value)) {
66
+ return value
67
+ .filter((entry) => typeof entry === "string")
68
+ .map((entry) => entry.trim())
69
+ .filter((entry) => entry.length > 0);
70
+ }
71
+ return [];
72
+ }
73
+ function parseToolGrant(raw) {
74
+ const match = raw.match(/^([^()]+)(?:\(([^)]*)\))?$/u);
75
+ const base = (match?.[1] ?? raw).trim().toLowerCase();
76
+ const qualifier = match?.[2] !== undefined ? match[2].trim() : null;
77
+ return { raw, base, qualifier };
78
+ }
79
+ function isBroadGrant(grant) {
80
+ if (WILDCARD_GRANTS.has(grant.base)) {
81
+ return true;
82
+ }
83
+ if (!BROAD_TOOL_BASES.has(grant.base)) {
84
+ return false;
85
+ }
86
+ return grant.qualifier === null || grant.qualifier === "" || grant.qualifier === "*";
87
+ }
88
+ function frontmatterTextFields(frontmatter) {
89
+ const texts = [];
90
+ for (const value of Object.values(frontmatter)) {
91
+ if (typeof value === "string") {
92
+ texts.push(value);
93
+ }
94
+ }
95
+ return texts;
96
+ }
97
+ export function detectSkillFrontmatterIssues(input) {
98
+ if (!isSkillFile(input.filePath)) {
99
+ return [];
100
+ }
101
+ const frontmatter = parseFrontmatter(input.textContent);
102
+ if (!frontmatter) {
103
+ return [];
104
+ }
105
+ const findings = [];
106
+ const grantsRaw = frontmatter["allowed-tools"] ?? frontmatter.allowed_tools;
107
+ const broadGrants = collectToolGrants(grantsRaw).map(parseToolGrant).filter(isBroadGrant);
108
+ if (broadGrants.length > 0) {
109
+ findings.push(makeFinding(input, "skill-allowed-tools-broad", "allowed-tools", "HIGH", "CONSENT_BYPASS", `Skill frontmatter requests unrestricted tool access: ${broadGrants
110
+ .map((grant) => grant.raw)
111
+ .join(", ")}. Unqualified shell or wildcard grants let the skill run arbitrary commands.`, "CWE-250"));
112
+ }
113
+ const hiddenInstructionTexts = frontmatterTextFields(frontmatter).filter((text) => {
114
+ const normalized = normalizeForMatching(text);
115
+ return (findOverridePhrase(normalized) !== null ||
116
+ REMOTE_SHELL_PATTERN.test(normalized) ||
117
+ REMOTE_INSTRUCTION_INDIRECTION_PATTERN.test(normalized));
118
+ });
119
+ if (hiddenInstructionTexts.length > 0) {
120
+ findings.push(makeFinding(input, "skill-frontmatter-hidden-instructions", "metadata", "HIGH", "RULE_INJECTION", "Skill frontmatter metadata contains override, remote-shell, or remote-instruction language " +
121
+ "outside the visible skill body.", "CWE-116"));
122
+ }
123
+ const declaredName = typeof frontmatter.name === "string" ? frontmatter.name.trim() : null;
124
+ const directoryName = skillDirectoryName(input.filePath);
125
+ if (declaredName && directoryName && declaredName.toLowerCase() !== directoryName.toLowerCase()) {
126
+ findings.push(makeFinding(input, "skill-frontmatter-mismatch", "name", "INFO", "CONFIG_PRESENT", `Skill frontmatter name "${declaredName}" does not match its directory "${directoryName}". ` +
127
+ "Name confusion is a common registry-squatting signal.", "CWE-1021"));
128
+ }
129
+ return findings;
130
+ }
@@ -3,6 +3,7 @@ import { type SymlinkEscapeEntry } from "./detectors/symlink.js";
3
3
  import type { AuditPersona, RuntimeMode } from "../config.js";
4
4
  import { type Finding } from "../types/finding.js";
5
5
  import type { DiscoveryFormat } from "../types/discovery.js";
6
+ import type { ResolvedKnownBadIndicators } from "../content/known-bad.js";
6
7
  export interface StaticFileInput {
7
8
  filePath: string;
8
9
  format: DiscoveryFormat;
@@ -28,6 +29,7 @@ export interface StaticEngineConfig {
28
29
  disable?: boolean;
29
30
  config?: Record<string, unknown>;
30
31
  }>;
32
+ knownBadIndicators?: ResolvedKnownBadIndicators;
31
33
  }
32
34
  export interface StaticEngineInput {
33
35
  projectRoot: string;
Binary file
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, extname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { loadActiveContentBundle } from "../content/content-store.js";
5
6
  const defaultRulesDir = resolve(dirname(fileURLToPath(import.meta.url)), "rules");
6
7
  const require = createRequire(import.meta.url);
7
8
  const Ajv = require("ajv");
@@ -179,9 +180,31 @@ function normalizeOptions(arg) {
179
180
  skipRules: normalizeRuleIds(options.skip_rules),
180
181
  };
181
182
  }
183
+ function loadContentFeedRules() {
184
+ try {
185
+ const bundle = loadActiveContentBundle();
186
+ const rules = bundle?.rules ?? [];
187
+ const validated = [];
188
+ for (const candidate of rules) {
189
+ if (!ruleValidator(candidate)) {
190
+ // One invalid feed rule disqualifies the feed's rule set; bundled
191
+ // rules remain the safe baseline.
192
+ return [];
193
+ }
194
+ validated.push(candidate);
195
+ }
196
+ return validated;
197
+ }
198
+ catch {
199
+ return [];
200
+ }
201
+ }
182
202
  export function loadRulePacks(arg) {
183
203
  const options = normalizeOptions(arg);
184
204
  const bundledRules = collectRulesFromPaths([options.baseDir]);
205
+ const feedRules = loadContentFeedRules();
185
206
  const externalRules = collectRulesFromPaths(options.rulePackPaths);
186
- return filterRules(dedupeByRuleId([...bundledRules, ...externalRules]), options.allowedRules, options.skipRules);
207
+ // Later entries win in dedupe: user packs override feed rules, which
208
+ // override bundled rules.
209
+ return filterRules(dedupeByRuleId([...bundledRules, ...feedRules, ...externalRules]), options.allowedRules, options.skipRules);
187
210
  }
@@ -5,6 +5,7 @@ export interface ScanStateServerEntry {
5
5
  first_seen: string;
6
6
  last_seen: string;
7
7
  }
8
+ /** Scan state for a single project root (one slice of the state file). */
8
9
  export interface ScanState {
9
10
  servers: Record<string, ScanStateServerEntry>;
10
11
  }
@@ -19,14 +20,18 @@ export interface EvaluateScanStateSnapshotsInput {
19
20
  snapshots: McpServerSnapshot[];
20
21
  previousState: ScanState;
21
22
  nowIso?: string;
23
+ /** Trusted targets get INFO first-seen findings; untrusted get MEDIUM. */
24
+ trustedTarget?: boolean;
25
+ /** When false, first-seen findings are skipped (state is still recorded). */
26
+ firstScanReview?: boolean;
22
27
  }
23
28
  export interface EvaluateScanStateSnapshotsResult {
24
29
  findings: Finding[];
25
30
  nextState: ScanState;
26
31
  }
27
32
  export declare function getScanStatePath(customPath?: string): string;
28
- export declare function loadScanState(customPath?: string): ScanState;
29
- export declare function saveScanState(state: ScanState, customPath?: string): void;
33
+ export declare function loadScanState(customPath?: string, projectRoot?: string): ScanState;
34
+ export declare function saveScanState(state: ScanState, customPath?: string, projectRoot?: string): void;
30
35
  export declare function resetScanState(customPath?: string): void;
31
36
  export declare function evaluateScanStateSnapshots(input: EvaluateScanStateSnapshotsInput): EvaluateScanStateSnapshotsResult;
32
37
  export declare function extractMcpServerSnapshots(filePath: string, parsed: unknown): McpServerSnapshot[];
@@ -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
- function makeStateFinding(kind, snapshot, previousLastSeen) {
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 === "NEW_SERVER") {
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: "INFO",
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. Not previously scanned.`,
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
- export function loadScanState(customPath) {
154
- const path = getScanStatePath(customPath);
155
- if (!existsSync(path)) {
156
- return { servers: {} };
157
- }
158
- let parsed;
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, value] of Object.entries(parsed.servers)) {
171
- if (!isRecord(value)) {
175
+ for (const [key, entry] of Object.entries(value)) {
176
+ if (!isRecord(entry)) {
172
177
  continue;
173
178
  }
174
- const config_hash = typeof value.config_hash === "string" ? value.config_hash : "";
175
- const config_path = typeof value.config_path === "string" ? value.config_path : "";
176
- const first_seen = typeof value.first_seen === "string" ? value.first_seen : "";
177
- const last_seen = typeof value.last_seen === "string" ? value.last_seen : "";
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 { servers: entries };
193
+ return entries;
189
194
  }
190
- export function saveScanState(state, customPath) {
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(state, null, 2)}\n`, "utf8");
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
- findings.push(makeStateFinding("NEW_SERVER", snapshot, null));
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("CONFIG_CHANGE", snapshot, previous.last_seen));
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
+ }