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,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { hasKnownBadIndicators } from "../../content/known-bad.js";
3
+ import { buildFindingFingerprint } from "../../report/finding-fingerprint.js";
4
+ import { collectMcpPackageLaunches, parsePackageLocator } from "./mcp-package-hygiene.js";
5
+ export const KNOWN_BAD_MATCH_KIND = {
6
+ FileHash: "file-hash",
7
+ PackageName: "package-name",
8
+ UrlPattern: "url-pattern",
9
+ };
10
+ export const KNOWN_BAD_RULE_ID = "known-malicious-content";
11
+ const URL_PATTERN = /https?:\/\/[^\s"'`<>)\]}]+/giu;
12
+ const MATCH_KIND_CATEGORY = {
13
+ [KNOWN_BAD_MATCH_KIND.FileHash]: "CONFIG_PRESENT",
14
+ [KNOWN_BAD_MATCH_KIND.PackageName]: "COMMAND_EXEC",
15
+ [KNOWN_BAD_MATCH_KIND.UrlPattern]: "CONFIG_PRESENT",
16
+ };
17
+ function makeKnownBadFinding(input, kind, field, description, evidence) {
18
+ return {
19
+ rule_id: KNOWN_BAD_RULE_ID,
20
+ finding_id: `KNOWN_MALICIOUS_CONTENT-${kind}-${input.filePath}-${field}-${evidence}`,
21
+ severity: "CRITICAL",
22
+ category: MATCH_KIND_CATEGORY[kind],
23
+ layer: "L2",
24
+ file_path: input.filePath,
25
+ location: { field },
26
+ description,
27
+ affected_tools: ["claude-code", "codex-cli", "opencode", "cursor", "windsurf"],
28
+ cve: null,
29
+ owasp: ["ASI05"],
30
+ cwe: "CWE-506",
31
+ confidence: "HIGH",
32
+ fixable: true,
33
+ remediation_actions: ["quarantine_file", "remove_field"],
34
+ metadata: {
35
+ sources: [input.filePath, field],
36
+ risk_tags: ["known-bad", kind],
37
+ origin: "known-bad",
38
+ },
39
+ evidence,
40
+ suppressed: false,
41
+ };
42
+ }
43
+ function detectFileHashMatch(input) {
44
+ const hash = createHash("sha256").update(input.textContent, "utf8").digest("hex");
45
+ if (!input.indicators.fileSha256.has(hash)) {
46
+ return [];
47
+ }
48
+ return [
49
+ makeKnownBadFinding(input, KNOWN_BAD_MATCH_KIND.FileHash, "content", `File content matches a known-malicious SHA-256 indicator (${hash}). ` +
50
+ "Quarantine this file and audit how it got here.", `sha256:${hash}`),
51
+ ];
52
+ }
53
+ function detectPackageNameMatches(input) {
54
+ const findings = [];
55
+ for (const launch of collectMcpPackageLaunches(input.parsed)) {
56
+ const { name } = parsePackageLocator(launch.registry, launch.locator);
57
+ if (!input.indicators.packageNames.has(name.toLowerCase())) {
58
+ continue;
59
+ }
60
+ findings.push(makeKnownBadFinding(input, KNOWN_BAD_MATCH_KIND.PackageName, launch.serverPath, `MCP server runs ${launch.registry} package "${name}", which is a known-malicious package. ` +
61
+ "Remove this server configuration immediately.", `${launch.serverPath}: ${launch.locator}`));
62
+ }
63
+ return findings;
64
+ }
65
+ function detectUrlPatternMatches(input) {
66
+ const findings = [];
67
+ const reported = new Set();
68
+ for (const match of input.textContent.matchAll(URL_PATTERN)) {
69
+ const url = match[0];
70
+ const normalized = url.toLowerCase();
71
+ const pattern = input.indicators.urlPatterns.find((entry) => normalized.includes(entry));
72
+ if (!pattern || reported.has(normalized)) {
73
+ continue;
74
+ }
75
+ reported.add(normalized);
76
+ findings.push(makeKnownBadFinding(input, KNOWN_BAD_MATCH_KIND.UrlPattern, "content", `File references "${url}", which matches the known-malicious URL indicator "${pattern}". ` +
77
+ "Remove the reference and audit anything fetched from it.", url));
78
+ }
79
+ return findings;
80
+ }
81
+ export function detectKnownBadContent(input) {
82
+ if (!hasKnownBadIndicators(input.indicators)) {
83
+ return [];
84
+ }
85
+ return [
86
+ ...detectFileHashMatch(input),
87
+ ...detectPackageNameMatches(input),
88
+ ...detectUrlPatternMatches(input),
89
+ ];
90
+ }
91
+ /**
92
+ * Escalate findings whose stable fingerprint appears in the known-bad
93
+ * indicator set to CRITICAL. Runs on the assembled finding list so it also
94
+ * covers findings produced outside the static engine.
95
+ */
96
+ export function escalateKnownBadFindings(findings, indicators) {
97
+ if (indicators.findingFingerprints.size === 0) {
98
+ return findings;
99
+ }
100
+ return findings.map((finding) => {
101
+ const fingerprint = finding.fingerprint ?? buildFindingFingerprint(finding);
102
+ if (!indicators.findingFingerprints.has(fingerprint)) {
103
+ return finding;
104
+ }
105
+ return {
106
+ ...finding,
107
+ severity: "CRITICAL",
108
+ description: `${finding.description} This finding matches a known-malicious indicator ` +
109
+ "and has been escalated to CRITICAL.",
110
+ metadata: {
111
+ ...finding.metadata,
112
+ risk_tags: [...(finding.metadata?.risk_tags ?? []), "known-bad"],
113
+ },
114
+ };
115
+ });
116
+ }
@@ -0,0 +1,28 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface McpPackageHygieneInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ knownSafeMcpServers: string[];
6
+ }
7
+ export declare const PACKAGE_REGISTRY: {
8
+ readonly Npm: "npm";
9
+ readonly Pypi: "pypi";
10
+ };
11
+ export type PackageRegistry = (typeof PACKAGE_REGISTRY)[keyof typeof PACKAGE_REGISTRY];
12
+ export interface PackageLaunch {
13
+ registry: PackageRegistry;
14
+ locator: string;
15
+ serverPath: string;
16
+ autoConfirm: boolean;
17
+ }
18
+ interface ParsedLocator {
19
+ name: string;
20
+ version: string | null;
21
+ }
22
+ export declare function parsePackageLocator(registry: PackageRegistry, locator: string): ParsedLocator;
23
+ export declare function isPinnedLocator(registry: PackageRegistry, locator: string): boolean;
24
+ export declare function findLikelyTyposquatTarget(registry: PackageRegistry, name: string): string | null;
25
+ /** MCP server package launches (npx/uvx/pipx style) found in a parsed config. */
26
+ export declare function collectMcpPackageLaunches(parsed: unknown): PackageLaunch[];
27
+ export declare function detectMcpPackageHygiene(input: McpPackageHygieneInput): Finding[];
28
+ export {};
@@ -0,0 +1,191 @@
1
+ import { activePopularMcpPackages } from "../data/popular-mcp-packages.js";
2
+ import { damerauLevenshtein } from "../text/edit-distance.js";
3
+ const MCP_SERVER_CONTAINER_KEYS = ["mcpServers", "mcp_servers", "context_servers"];
4
+ export const PACKAGE_REGISTRY = {
5
+ Npm: "npm",
6
+ Pypi: "pypi",
7
+ };
8
+ const NPM_LAUNCHERS = new Set(["npx", "pnpx", "bunx"]);
9
+ const PYPI_LAUNCHERS = new Set(["uvx", "pipx"]);
10
+ // Exact versions pin the executed code; tags and ranges resolve at launch time.
11
+ const EXACT_NPM_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/u;
12
+ const EXACT_PYPI_VERSION_PATTERN = /^==\s*\d+(?:\.\d+)*(?:[a-z0-9.]*)$/iu;
13
+ const MIN_TYPOSQUAT_NAME_LENGTH = 4;
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function parseNpmLocator(locator) {
18
+ const versionIndex = locator.lastIndexOf("@");
19
+ if (versionIndex > 0) {
20
+ return { name: locator.slice(0, versionIndex), version: locator.slice(versionIndex + 1) };
21
+ }
22
+ return { name: locator, version: null };
23
+ }
24
+ function parsePypiLocator(locator) {
25
+ const specIndex = locator.search(/[=<>!~]/u);
26
+ if (specIndex > 0) {
27
+ return { name: locator.slice(0, specIndex), version: locator.slice(specIndex) };
28
+ }
29
+ return { name: locator, version: null };
30
+ }
31
+ export function parsePackageLocator(registry, locator) {
32
+ return registry === PACKAGE_REGISTRY.Npm ? parseNpmLocator(locator) : parsePypiLocator(locator);
33
+ }
34
+ export function isPinnedLocator(registry, locator) {
35
+ const { version } = parsePackageLocator(registry, locator);
36
+ if (version === null) {
37
+ return false;
38
+ }
39
+ return registry === PACKAGE_REGISTRY.Npm
40
+ ? EXACT_NPM_VERSION_PATTERN.test(version)
41
+ : EXACT_PYPI_VERSION_PATTERN.test(version);
42
+ }
43
+ function typosquatThreshold(name) {
44
+ return name.length >= 10 ? 2 : 1;
45
+ }
46
+ export function findLikelyTyposquatTarget(registry, name) {
47
+ const popular = activePopularMcpPackages()[registry];
48
+ const normalized = name.toLowerCase();
49
+ if (normalized.length < MIN_TYPOSQUAT_NAME_LENGTH || popular.includes(normalized)) {
50
+ return null;
51
+ }
52
+ let best = null;
53
+ for (const candidate of popular) {
54
+ const distance = damerauLevenshtein(normalized, candidate);
55
+ if (distance > 0 && distance <= typosquatThreshold(normalized)) {
56
+ if (!best || distance < best.distance) {
57
+ best = { target: candidate, distance };
58
+ }
59
+ }
60
+ }
61
+ return best?.target ?? null;
62
+ }
63
+ function commandTokens(config) {
64
+ if (Array.isArray(config.command) && config.command.every((entry) => typeof entry === "string")) {
65
+ return config.command;
66
+ }
67
+ if (typeof config.command === "string") {
68
+ const args = Array.isArray(config.args)
69
+ ? config.args.filter((entry) => typeof entry === "string")
70
+ : [];
71
+ return [config.command, ...args];
72
+ }
73
+ return [];
74
+ }
75
+ function firstNonFlag(tokens, startIndex) {
76
+ for (let index = startIndex; index < tokens.length; index += 1) {
77
+ const token = tokens[index];
78
+ if (typeof token !== "string" || token.length === 0 || token.startsWith("-")) {
79
+ continue;
80
+ }
81
+ return token;
82
+ }
83
+ return null;
84
+ }
85
+ function hasAutoConfirmFlag(tokens) {
86
+ return tokens.some((token) => token === "-y" || token === "--yes");
87
+ }
88
+ function collectPackageLaunches(value, path, launches) {
89
+ if (!isRecord(value)) {
90
+ return;
91
+ }
92
+ for (const containerKey of MCP_SERVER_CONTAINER_KEYS) {
93
+ const container = value[containerKey];
94
+ if (!isRecord(container)) {
95
+ continue;
96
+ }
97
+ for (const [serverName, serverConfig] of Object.entries(container)) {
98
+ if (!isRecord(serverConfig)) {
99
+ continue;
100
+ }
101
+ const tokens = commandTokens(serverConfig);
102
+ if (tokens.length === 0) {
103
+ continue;
104
+ }
105
+ const launcher = (tokens[0] ?? "").toLowerCase();
106
+ const registry = NPM_LAUNCHERS.has(launcher)
107
+ ? PACKAGE_REGISTRY.Npm
108
+ : PYPI_LAUNCHERS.has(launcher)
109
+ ? PACKAGE_REGISTRY.Pypi
110
+ : null;
111
+ if (!registry) {
112
+ continue;
113
+ }
114
+ const locator = firstNonFlag(tokens, 1);
115
+ if (!locator) {
116
+ continue;
117
+ }
118
+ launches.push({
119
+ registry,
120
+ locator,
121
+ serverPath: `${path}${containerKey}.${serverName}`,
122
+ autoConfirm: hasAutoConfirmFlag(tokens),
123
+ });
124
+ }
125
+ }
126
+ for (const [key, nested] of Object.entries(value)) {
127
+ if (MCP_SERVER_CONTAINER_KEYS.includes(key)) {
128
+ continue;
129
+ }
130
+ collectPackageLaunches(nested, `${path}${key}.`, launches);
131
+ }
132
+ }
133
+ function makeFinding(input, launch, ruleId, severity, description) {
134
+ return {
135
+ rule_id: ruleId,
136
+ finding_id: `${ruleId.toUpperCase().replaceAll("-", "_")}-${input.filePath}-${launch.serverPath}`,
137
+ severity,
138
+ category: "COMMAND_EXEC",
139
+ layer: "L2",
140
+ file_path: input.filePath,
141
+ location: { field: launch.serverPath },
142
+ description,
143
+ affected_tools: ["claude-code", "codex-cli", "opencode", "cursor", "windsurf"],
144
+ cve: null,
145
+ owasp: ["ASI05"],
146
+ cwe: "CWE-829",
147
+ confidence: "HIGH",
148
+ fixable: true,
149
+ remediation_actions: ["remove_field", "replace_with_default"],
150
+ metadata: {
151
+ sources: [input.filePath, launch.serverPath],
152
+ risk_tags: ["mcp", "supply-chain"],
153
+ origin: "mcp-package-hygiene",
154
+ },
155
+ evidence: `${launch.serverPath}: ${launch.locator}`,
156
+ suppressed: false,
157
+ };
158
+ }
159
+ /** MCP server package launches (npx/uvx/pipx style) found in a parsed config. */
160
+ export function collectMcpPackageLaunches(parsed) {
161
+ const launches = [];
162
+ collectPackageLaunches(parsed, "", launches);
163
+ return launches;
164
+ }
165
+ export function detectMcpPackageHygiene(input) {
166
+ const launches = collectMcpPackageLaunches(input.parsed);
167
+ if (launches.length === 0) {
168
+ return [];
169
+ }
170
+ const knownSafe = new Set(input.knownSafeMcpServers.map((entry) => entry.toLowerCase()));
171
+ const findings = [];
172
+ for (const launch of launches) {
173
+ const { name } = parsePackageLocator(launch.registry, launch.locator);
174
+ if (knownSafe.has(name.toLowerCase())) {
175
+ continue;
176
+ }
177
+ if (!isPinnedLocator(launch.registry, launch.locator)) {
178
+ const autoConfirmNote = launch.autoConfirm
179
+ ? " The launcher auto-confirms installation (-y), so new versions run without any prompt."
180
+ : "";
181
+ findings.push(makeFinding(input, launch, "mcp-unpinned-package", "MEDIUM", `MCP server runs ${launch.registry} package "${launch.locator}" without an exact version pin, ` +
182
+ `so it executes whatever the registry serves at launch time.${autoConfirmNote}`));
183
+ }
184
+ const typosquatTarget = findLikelyTyposquatTarget(launch.registry, name);
185
+ if (typosquatTarget) {
186
+ findings.push(makeFinding(input, launch, "mcp-possible-typosquat", "HIGH", `MCP server package "${name}" is one edit away from the well-known package ` +
187
+ `"${typosquatTarget}". Verify this is the intended package and not a typosquat.`));
188
+ }
189
+ }
190
+ return findings;
191
+ }
@@ -1,22 +1,8 @@
1
1
  import { buildFindingEvidence } from "../evidence.js";
2
- const DIRECT_OVERRIDE_PHRASES = [
3
- "ignore previous instructions",
4
- "skip permissions",
5
- "bypass permissions",
6
- ];
7
- const NEGATION_PATTERN = /\b(?:must not|should not|do not|don't|never)\b/iu;
8
- const SENSITIVE_READ_PATTERN = /\b(?:read|cat)\s+(?:~\/\.ssh(?:\/[^\s]+)?|\.env\b|~\/\.[a-z0-9._-]+(?:\/[^\s]+)*)/iu;
9
- 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;
10
- 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;
11
- 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;
12
- const HTML_COMMENT_PATTERN = /<!--([\s\S]*?)-->/gu;
13
- 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;
14
- const COOKIE_EXPORT_PATTERN = /\bcookies?\s+(?:export|import|get)\b/iu;
15
- const SESSION_SHARE_PATTERN = /\bsession\s+share\b|\blive url\b/iu;
16
- const PROFILE_SYNC_PATTERN = /\bprofile\s+sync\b|\breal chrome\b|\blogin sessions\b|\bsession tokens?\b|--profile\b/iu;
17
- 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;
18
- const AGENT_CONTROL_POINT_PATTERN = /\.claude\/hooks\/|\.claude\/settings\.json|\.claude\/agents\/|\bclaude\.md\b|\bagents\.md\b|\bmcp configuration\b/iu;
19
- const RESTART_LOAD_PATTERN = /\brestart\b.*\b(?:load|take effect|activate|reload|work)\b|\bonly load after restart\b|\bafter restarting\b/iu;
2
+ import { scanEncodedPayloads } from "../text/encoded-payloads.js";
3
+ import { normalizeForMatching } from "../text/normalize.js";
4
+ import { AGENT_CONTROL_POINT_PATTERN, BOOTSTRAP_INSTALL_PATTERN, COMMENT_PAYLOAD_PATTERN, COOKIE_EXPORT_PATTERN, HTML_COMMENT_PATTERN, NEGATION_PATTERN, OUTBOUND_TRANSFER_PATTERN, PROFILE_SYNC_PATTERN, REMOTE_INSTRUCTION_INDIRECTION_PATTERN, REMOTE_SHELL_PATTERN, RESTART_LOAD_PATTERN, SENSITIVE_READ_PATTERN, SESSION_SHARE_PATTERN, SUSPICIOUS_LONG_LINE_PATTERN, findOverridePhrase, hasNegationBefore, } from "../text/threat-patterns.js";
5
+ import { HIDDEN_UNICODE_CLASS, findHiddenUnicode } from "../text/unicode.js";
20
6
  function makeFinding(filePath, field, ruleId, description, evidence, severity = "HIGH", narrative = {}) {
21
7
  const location = { field };
22
8
  if (typeof evidence?.line === "number") {
@@ -79,53 +65,45 @@ function buildMultilineEvidence(lines, lineNumbers) {
79
65
  column: 1,
80
66
  };
81
67
  }
82
- function hasNearbyNegation(line, matchIndex) {
83
- const prefix = line.slice(Math.max(0, matchIndex - 24), matchIndex);
84
- return NEGATION_PATTERN.test(prefix);
85
- }
86
68
  function detectSuspiciousInstruction(lines) {
87
- for (let index = 0; index < lines.length; index += 1) {
88
- const line = lines[index] ?? "";
89
- const lower = line.toLowerCase();
90
- for (const phrase of DIRECT_OVERRIDE_PHRASES) {
91
- const matchIndex = lower.indexOf(phrase);
92
- if (matchIndex < 0 || hasNearbyNegation(lower, matchIndex)) {
93
- continue;
94
- }
69
+ for (let index = 0; index < lines.normalized.length; index += 1) {
70
+ const normalized = lines.normalized[index] ?? "";
71
+ const original = lines.original[index] ?? "";
72
+ const overrideMatch = findOverridePhrase(normalized);
73
+ if (overrideMatch) {
95
74
  return {
96
- phrase,
97
- evidence: buildLineEvidence(line, index + 1, matchIndex + 1),
75
+ phrase: overrideMatch.phrase,
76
+ evidence: buildLineEvidence(original, index + 1, overrideMatch.index + 1),
98
77
  };
99
78
  }
100
- const sensitiveReadMatch = line.match(SENSITIVE_READ_PATTERN);
101
- const outboundMatch = line.match(OUTBOUND_TRANSFER_PATTERN);
79
+ const sensitiveReadMatch = normalized.match(SENSITIVE_READ_PATTERN);
80
+ const outboundMatch = normalized.match(OUTBOUND_TRANSFER_PATTERN);
102
81
  if (!sensitiveReadMatch || !outboundMatch) {
103
82
  continue;
104
83
  }
105
- const outboundIndex = outboundMatch.index ?? line.length;
106
- if (hasNearbyNegation(lower, outboundIndex)) {
84
+ const outboundIndex = outboundMatch.index ?? normalized.length;
85
+ if (hasNegationBefore(normalized, outboundIndex)) {
107
86
  continue;
108
87
  }
109
- const phrase = outboundMatch[0].toLowerCase();
110
88
  return {
111
- phrase,
112
- evidence: buildLineEvidence(line, index + 1, outboundIndex + 1),
89
+ phrase: outboundMatch[0].toLowerCase(),
90
+ evidence: buildLineEvidence(original, index + 1, outboundIndex + 1),
113
91
  };
114
92
  }
115
93
  return null;
116
94
  }
117
95
  function detectRemoteShell(lines) {
118
- for (let index = 0; index < lines.length; index += 1) {
119
- const line = lines[index] ?? "";
120
- const match = line.match(REMOTE_SHELL_PATTERN);
96
+ for (let index = 0; index < lines.normalized.length; index += 1) {
97
+ const normalized = lines.normalized[index] ?? "";
98
+ const match = normalized.match(REMOTE_SHELL_PATTERN);
121
99
  if (!match) {
122
100
  continue;
123
101
  }
124
102
  const matchIndex = match.index ?? 0;
125
- if (hasNearbyNegation(line.toLowerCase(), matchIndex)) {
103
+ if (hasNegationBefore(normalized, matchIndex)) {
126
104
  continue;
127
105
  }
128
- return buildLineEvidence(line, index + 1, matchIndex + 1);
106
+ return buildLineEvidence(lines.original[index] ?? "", index + 1, matchIndex + 1);
129
107
  }
130
108
  return null;
131
109
  }
@@ -143,12 +121,12 @@ function shellLabelFromEvidence(evidence) {
143
121
  return "a shell";
144
122
  }
145
123
  function detectHiddenCommentPayload(input, lines) {
146
- HTML_COMMENT_PATTERN.lastIndex = 0;
147
- let match = HTML_COMMENT_PATTERN.exec(input.textContent);
124
+ const commentRegex = new RegExp(HTML_COMMENT_PATTERN.source, "gu");
125
+ let match = commentRegex.exec(input.textContent);
148
126
  while (match) {
149
- const commentBody = match[1] ?? "";
127
+ const commentBody = normalizeForMatching(match[1] ?? "");
150
128
  if (!COMMENT_PAYLOAD_PATTERN.test(commentBody)) {
151
- match = HTML_COMMENT_PATTERN.exec(input.textContent);
129
+ match = commentRegex.exec(input.textContent);
152
130
  continue;
153
131
  }
154
132
  const startLine = input.textContent.slice(0, match.index ?? 0).split(/\r?\n/u).length;
@@ -161,22 +139,21 @@ function detectHiddenCommentPayload(input, lines) {
161
139
  function detectSessionTransfer(lines) {
162
140
  const matchedLines = [];
163
141
  const categories = new Set();
164
- for (let index = 0; index < lines.length; index += 1) {
165
- const line = lines[index] ?? "";
166
- const lower = line.toLowerCase();
167
- if (NEGATION_PATTERN.test(lower)) {
142
+ for (let index = 0; index < lines.normalized.length; index += 1) {
143
+ const normalized = lines.normalized[index] ?? "";
144
+ if (NEGATION_PATTERN.test(normalized)) {
168
145
  continue;
169
146
  }
170
147
  let matched = false;
171
- if (COOKIE_EXPORT_PATTERN.test(line)) {
148
+ if (COOKIE_EXPORT_PATTERN.test(normalized)) {
172
149
  categories.add("cookies");
173
150
  matched = true;
174
151
  }
175
- if (SESSION_SHARE_PATTERN.test(line)) {
152
+ if (SESSION_SHARE_PATTERN.test(normalized)) {
176
153
  categories.add("session_share");
177
154
  matched = true;
178
155
  }
179
- if (PROFILE_SYNC_PATTERN.test(line)) {
156
+ if (PROFILE_SYNC_PATTERN.test(normalized)) {
180
157
  categories.add("profile");
181
158
  matched = true;
182
159
  }
@@ -187,52 +164,110 @@ function detectSessionTransfer(lines) {
187
164
  if (categories.size < 2 || matchedLines.length < 2) {
188
165
  return null;
189
166
  }
190
- return buildMultilineEvidence(lines, matchedLines.slice(0, 4));
167
+ return buildMultilineEvidence(lines.original, matchedLines.slice(0, 4));
191
168
  }
192
169
  function detectBootstrapControlPoints(lines) {
193
170
  const installLines = [];
194
171
  const controlPointLines = [];
195
172
  const restartLines = [];
196
- for (let index = 0; index < lines.length; index += 1) {
197
- const line = lines[index] ?? "";
173
+ for (let index = 0; index < lines.normalized.length; index += 1) {
174
+ const normalized = lines.normalized[index] ?? "";
198
175
  const lineNumber = index + 1;
199
- if (BOOTSTRAP_INSTALL_PATTERN.test(line)) {
176
+ if (BOOTSTRAP_INSTALL_PATTERN.test(normalized)) {
200
177
  installLines.push(lineNumber);
201
178
  }
202
- if (AGENT_CONTROL_POINT_PATTERN.test(line)) {
179
+ if (AGENT_CONTROL_POINT_PATTERN.test(normalized)) {
203
180
  controlPointLines.push(lineNumber);
204
181
  }
205
- if (RESTART_LOAD_PATTERN.test(line)) {
182
+ if (RESTART_LOAD_PATTERN.test(normalized)) {
206
183
  restartLines.push(lineNumber);
207
184
  }
208
185
  }
209
186
  if (installLines.length === 0 || controlPointLines.length === 0 || restartLines.length === 0) {
210
187
  return null;
211
188
  }
212
- return buildMultilineEvidence(lines, [
189
+ return buildMultilineEvidence(lines.original, [
213
190
  ...installLines.slice(0, 2),
214
191
  ...controlPointLines.slice(0, 2),
215
192
  restartLines[0],
216
193
  ]);
217
194
  }
218
- export function detectRuleFileIssues(input) {
195
+ function hiddenUnicodeFindings(input) {
196
+ if (input.unicodeAnalysis === false) {
197
+ return [];
198
+ }
199
+ const matches = findHiddenUnicode(input.textContent);
200
+ if (matches.length === 0) {
201
+ return [];
202
+ }
219
203
  const findings = [];
220
- const hiddenUnicodeRegex = /(?:\u200B|\u200C|\u200D|\u2060|\uFEFF|[\u202A-\u202E])/u;
221
- const hiddenRemoteShellIncident = {
222
- incidentId: "hidden-remote-shell-payload",
223
- incidentTitle: "Hidden remote shell payload in skill file",
224
- };
225
- const hiddenMatch = input.unicodeAnalysis === false ? null : input.textContent.match(hiddenUnicodeRegex);
226
- if (hiddenMatch?.[0]) {
204
+ const tagMatches = matches.filter((match) => match.class === HIDDEN_UNICODE_CLASS.Tags);
205
+ const otherMatches = matches.filter((match) => match.class !== HIDDEN_UNICODE_CLASS.Tags);
206
+ if (otherMatches.length > 0) {
207
+ const firstChar = String.fromCodePoint(otherMatches[0]?.codePoint ?? 0x200b);
227
208
  const evidence = buildFindingEvidence({
228
209
  textContent: input.textContent,
229
- searchTerms: [hiddenMatch[0]],
210
+ searchTerms: [firstChar],
230
211
  fallbackValue: "hidden Unicode character detected",
231
212
  });
232
213
  findings.push(makeFinding(input.filePath, "hidden_unicode", "rule-file-hidden-unicode", "Rule file contains hidden Unicode characters", evidence));
233
214
  }
234
- const lines = input.textContent.split(/\r?\n/u);
235
- const hiddenCommentPayload = detectHiddenCommentPayload(input, lines);
215
+ if (tagMatches.length > 0) {
216
+ const lineNumber = input.textContent.slice(0, tagMatches[0]?.index ?? 0).split(/\r?\n/u).length;
217
+ findings.push(makeFinding(input.filePath, "hidden_unicode_tags", "rule-file-hidden-unicode-tags", `Rule file contains ${tagMatches.length} Unicode tag character(s) (U+E0000-U+E007F), ` +
218
+ "an ASCII-smuggling channel that hides instructions from human reviewers", { evidence: `line ${lineNumber}: ${tagMatches.length} tag character(s)`, line: lineNumber }, "HIGH", {
219
+ observed: [
220
+ "The file embeds Unicode tag characters that render as nothing in editors and diffs.",
221
+ "Tag characters encode invisible ASCII that still reaches models that read the file.",
222
+ ],
223
+ inference: "Invisible tag-character content is a known prompt-injection smuggling technique.",
224
+ notVerified: ["CodeGate did not decode or follow any smuggled instruction."],
225
+ }));
226
+ }
227
+ return findings;
228
+ }
229
+ function encodedPayloadFindings(input, lines) {
230
+ return scanEncodedPayloads(input.textContent).map((match) => {
231
+ const severity = match.matchesRemoteShell ? "CRITICAL" : "HIGH";
232
+ const behaviors = [
233
+ match.matchesRemoteShell ? "remote shell execution" : null,
234
+ match.matchesOverridePhrase ? "instruction override" : null,
235
+ match.matchesSensitiveExfil ? "sensitive-data exfiltration" : null,
236
+ match.matchesCommandExecution ? "command execution" : null,
237
+ ].filter((entry) => entry !== null);
238
+ const sourceLine = lines[match.line - 1] ?? "";
239
+ const evidence = {
240
+ evidence: `line ${match.line}\n${match.line} | ${sourceLine.slice(0, 200)}\n` +
241
+ `decoded (${match.kind}): ${match.decodedExcerpt}`,
242
+ line: match.line,
243
+ column: 1,
244
+ };
245
+ return makeFinding(input.filePath, `encoded_payload:${match.line}`, "rule-file-encoded-payload", `Rule file contains a ${match.kind}-encoded payload that decodes to ${behaviors.join(", ")}`, evidence, severity, {
246
+ observed: [
247
+ `An encoded ${match.kind} blob decodes to readable instructions.`,
248
+ `The decoded content matches: ${behaviors.join(", ")}.`,
249
+ ],
250
+ inference: "Encoding hides the payload from human review while keeping it machine-usable.",
251
+ notVerified: [
252
+ "CodeGate did not execute any decoded instruction.",
253
+ "CodeGate did not fetch any URL referenced by the decoded content.",
254
+ ],
255
+ });
256
+ });
257
+ }
258
+ export function detectRuleFileIssues(input) {
259
+ const findings = [];
260
+ const hiddenRemoteShellIncident = {
261
+ incidentId: "hidden-remote-shell-payload",
262
+ incidentTitle: "Hidden remote shell payload in skill file",
263
+ };
264
+ findings.push(...hiddenUnicodeFindings(input));
265
+ const originalLines = input.textContent.split(/\r?\n/u);
266
+ const lines = {
267
+ original: originalLines,
268
+ normalized: originalLines.map((line) => normalizeForMatching(line)),
269
+ };
270
+ const hiddenCommentPayload = detectHiddenCommentPayload(input, lines.original);
236
271
  if (hiddenCommentPayload) {
237
272
  findings.push(makeFinding(input.filePath, "hidden_comment_payload", "rule-file-hidden-comment-payload", "Rule file contains a hidden comment payload with executable or override instructions", hiddenCommentPayload, "CRITICAL", {
238
273
  ...hiddenRemoteShellIncident,
@@ -290,11 +325,29 @@ export function detectRuleFileIssues(input) {
290
325
  ],
291
326
  }));
292
327
  }
293
- const longLineIndex = lines.findIndex((line) => line.length > 300 && SUSPICIOUS_LONG_LINE_PATTERN.test(line) && !NEGATION_PATTERN.test(line));
328
+ for (let index = 0; index < lines.normalized.length; index += 1) {
329
+ const normalized = lines.normalized[index] ?? "";
330
+ const match = normalized.match(REMOTE_INSTRUCTION_INDIRECTION_PATTERN);
331
+ if (!match || hasNegationBefore(normalized, match.index ?? 0)) {
332
+ continue;
333
+ }
334
+ findings.push(makeFinding(input.filePath, "remote_instruction_indirection", "rule-file-remote-instruction-indirection", "Rule file directs the agent to fetch and follow instructions from a remote URL", buildLineEvidence(lines.original[index] ?? "", index + 1, (match.index ?? 0) + 1), "HIGH", {
335
+ observed: [
336
+ "The file tells the agent to read or follow instructions hosted at an external URL.",
337
+ ],
338
+ inference: "Remote instructions can change at any time after review, so the effective behavior is not what was audited.",
339
+ notVerified: ["CodeGate did not fetch the referenced URL."],
340
+ }));
341
+ break;
342
+ }
343
+ findings.push(...encodedPayloadFindings(input, lines.original));
344
+ const longLineIndex = lines.normalized.findIndex((normalized, index) => (lines.original[index] ?? "").length > 300 &&
345
+ SUSPICIOUS_LONG_LINE_PATTERN.test(normalized) &&
346
+ !NEGATION_PATTERN.test(normalized));
294
347
  if (longLineIndex >= 0) {
295
348
  const lineNumber = longLineIndex + 1;
296
349
  const evidence = {
297
- evidence: `line ${lineNumber}\n${lineNumber} | ${lines[longLineIndex]}`,
350
+ evidence: `line ${lineNumber}\n${lineNumber} | ${lines.original[longLineIndex]}`,
298
351
  line: lineNumber,
299
352
  column: 1,
300
353
  };
@@ -0,0 +1,6 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface SkillFrontmatterInput {
3
+ filePath: string;
4
+ textContent: string;
5
+ }
6
+ export declare function detectSkillFrontmatterIssues(input: SkillFrontmatterInput): Finding[];