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.
Files changed (75) hide show
  1. package/README.md +13 -12
  2. package/dist/cli.d.ts +3 -1
  3. package/dist/cli.js +153 -44
  4. package/dist/commands/scan-command.d.ts +2 -1
  5. package/dist/commands/scan-command.js +6 -1
  6. package/dist/commands/trust.d.ts +28 -0
  7. package/dist/commands/trust.js +69 -0
  8. package/dist/config/inline-ignore.d.ts +10 -2
  9. package/dist/config/inline-ignore.js +5 -1
  10. package/dist/config/suppression-policy.d.ts +1 -1
  11. package/dist/config/suppression-policy.js +4 -1
  12. package/dist/config/trust.d.ts +2 -0
  13. package/dist/config/trust.js +19 -0
  14. package/dist/config.d.ts +14 -0
  15. package/dist/config.js +45 -1
  16. package/dist/content/content-bundle.d.ts +27 -0
  17. package/dist/content/content-bundle.js +73 -0
  18. package/dist/content/content-store.d.ts +27 -0
  19. package/dist/content/content-store.js +0 -0
  20. package/dist/content/content-updater.d.ts +32 -0
  21. package/dist/content/content-updater.js +111 -0
  22. package/dist/content/known-bad.d.ts +26 -0
  23. package/dist/content/known-bad.js +100 -0
  24. package/dist/content/publisher-key.d.ts +10 -0
  25. package/dist/content/publisher-key.js +10 -0
  26. package/dist/layer1-discovery/knowledge-base.js +33 -1
  27. package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
  28. package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
  29. package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
  30. package/dist/layer2-static/detectors/known-bad.js +116 -0
  31. package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
  32. package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
  33. package/dist/layer2-static/detectors/rule-file.js +128 -75
  34. package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
  35. package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
  36. package/dist/layer2-static/engine.d.ts +2 -0
  37. package/dist/layer2-static/engine.js +0 -0
  38. package/dist/layer2-static/rule-pack-loader.js +24 -1
  39. package/dist/layer2-static/state/scan-state.d.ts +7 -2
  40. package/dist/layer2-static/state/scan-state.js +74 -30
  41. package/dist/layer2-static/text/confusables.d.ts +7 -0
  42. package/dist/layer2-static/text/confusables.js +70 -0
  43. package/dist/layer2-static/text/edit-distance.d.ts +6 -0
  44. package/dist/layer2-static/text/edit-distance.js +33 -0
  45. package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
  46. package/dist/layer2-static/text/encoded-payloads.js +116 -0
  47. package/dist/layer2-static/text/normalize.d.ts +11 -0
  48. package/dist/layer2-static/text/normalize.js +19 -0
  49. package/dist/layer2-static/text/override-phrases.d.ts +14 -0
  50. package/dist/layer2-static/text/override-phrases.js +49 -0
  51. package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
  52. package/dist/layer2-static/text/threat-patterns.js +45 -0
  53. package/dist/layer2-static/text/unicode.d.ts +33 -0
  54. package/dist/layer2-static/text/unicode.js +83 -0
  55. package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
  56. package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
  57. package/dist/layer3-dynamic/meta-agent.js +2 -1
  58. package/dist/layer3-dynamic/registry-client.d.ts +26 -0
  59. package/dist/layer3-dynamic/registry-client.js +138 -0
  60. package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
  61. package/dist/layer3-dynamic/registry-findings.js +64 -0
  62. package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
  63. package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
  64. package/dist/layer3-dynamic/toxic-flow.js +41 -8
  65. package/dist/pipeline.d.ts +5 -2
  66. package/dist/pipeline.js +53 -16
  67. package/dist/report-summary.d.ts +1 -1
  68. package/dist/report-summary.js +9 -1
  69. package/dist/scan.d.ts +13 -0
  70. package/dist/scan.js +291 -17
  71. package/dist/types/finding.d.ts +14 -0
  72. package/dist/types/finding.js +14 -0
  73. package/dist/types/report.d.ts +2 -0
  74. package/dist/wrapper.js +2 -19
  75. package/package.json +1 -1
@@ -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[];
@@ -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[];