codegate-ai 0.16.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +153 -44
- package/dist/commands/scan-command.d.ts +2 -1
- package/dist/commands/scan-command.js +6 -1
- package/dist/commands/trust.d.ts +28 -0
- package/dist/commands/trust.js +69 -0
- package/dist/config/inline-ignore.d.ts +10 -2
- package/dist/config/inline-ignore.js +5 -1
- package/dist/config/suppression-policy.d.ts +1 -1
- package/dist/config/suppression-policy.js +4 -1
- package/dist/config/trust.d.ts +2 -0
- package/dist/config/trust.js +19 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +45 -1
- package/dist/content/content-bundle.d.ts +27 -0
- package/dist/content/content-bundle.js +73 -0
- package/dist/content/content-store.d.ts +27 -0
- package/dist/content/content-store.js +0 -0
- package/dist/content/content-updater.d.ts +32 -0
- package/dist/content/content-updater.js +111 -0
- package/dist/content/known-bad.d.ts +26 -0
- package/dist/content/known-bad.js +100 -0
- package/dist/content/publisher-key.d.ts +10 -0
- package/dist/content/publisher-key.js +10 -0
- package/dist/layer1-discovery/knowledge-base.js +33 -1
- package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
- package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
- package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
- package/dist/layer2-static/detectors/known-bad.js +116 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
- package/dist/layer2-static/detectors/rule-file.js +128 -75
- package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
- package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
- package/dist/layer2-static/engine.d.ts +2 -0
- package/dist/layer2-static/engine.js +0 -0
- package/dist/layer2-static/rule-pack-loader.js +24 -1
- package/dist/layer2-static/state/scan-state.d.ts +7 -2
- package/dist/layer2-static/state/scan-state.js +74 -30
- package/dist/layer2-static/text/confusables.d.ts +7 -0
- package/dist/layer2-static/text/confusables.js +70 -0
- package/dist/layer2-static/text/edit-distance.d.ts +6 -0
- package/dist/layer2-static/text/edit-distance.js +33 -0
- package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
- package/dist/layer2-static/text/encoded-payloads.js +116 -0
- package/dist/layer2-static/text/normalize.d.ts +11 -0
- package/dist/layer2-static/text/normalize.js +19 -0
- package/dist/layer2-static/text/override-phrases.d.ts +14 -0
- package/dist/layer2-static/text/override-phrases.js +49 -0
- package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
- package/dist/layer2-static/text/threat-patterns.js +45 -0
- package/dist/layer2-static/text/unicode.d.ts +33 -0
- package/dist/layer2-static/text/unicode.js +83 -0
- package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
- package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
- package/dist/layer3-dynamic/meta-agent.js +2 -1
- package/dist/layer3-dynamic/registry-client.d.ts +26 -0
- package/dist/layer3-dynamic/registry-client.js +138 -0
- package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
- package/dist/layer3-dynamic/registry-findings.js +64 -0
- package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
- package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
- package/dist/layer3-dynamic/toxic-flow.js +41 -8
- package/dist/pipeline.d.ts +5 -2
- package/dist/pipeline.js +53 -16
- package/dist/report-summary.d.ts +1 -1
- package/dist/report-summary.js +9 -1
- package/dist/scan.d.ts +13 -0
- package/dist/scan.js +291 -17
- package/dist/types/finding.d.ts +14 -0
- package/dist/types/finding.js +14 -0
- package/dist/types/report.d.ts +2 -0
- package/dist/wrapper.js +2 -19
- package/package.json +1 -1
|
@@ -1,22 +1,8 @@
|
|
|
1
1
|
import { buildFindingEvidence } from "../evidence.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
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(
|
|
75
|
+
phrase: overrideMatch.phrase,
|
|
76
|
+
evidence: buildLineEvidence(original, index + 1, overrideMatch.index + 1),
|
|
98
77
|
};
|
|
99
78
|
}
|
|
100
|
-
const sensitiveReadMatch =
|
|
101
|
-
const outboundMatch =
|
|
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 ??
|
|
106
|
-
if (
|
|
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(
|
|
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
|
|
120
|
-
const match =
|
|
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 (
|
|
103
|
+
if (hasNegationBefore(normalized, matchIndex)) {
|
|
126
104
|
continue;
|
|
127
105
|
}
|
|
128
|
-
return buildLineEvidence(
|
|
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.
|
|
147
|
-
let match =
|
|
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 =
|
|
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
|
|
166
|
-
|
|
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(
|
|
148
|
+
if (COOKIE_EXPORT_PATTERN.test(normalized)) {
|
|
172
149
|
categories.add("cookies");
|
|
173
150
|
matched = true;
|
|
174
151
|
}
|
|
175
|
-
if (SESSION_SHARE_PATTERN.test(
|
|
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(
|
|
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
|
|
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(
|
|
176
|
+
if (BOOTSTRAP_INSTALL_PATTERN.test(normalized)) {
|
|
200
177
|
installLines.push(lineNumber);
|
|
201
178
|
}
|
|
202
|
-
if (AGENT_CONTROL_POINT_PATTERN.test(
|
|
179
|
+
if (AGENT_CONTROL_POINT_PATTERN.test(normalized)) {
|
|
203
180
|
controlPointLines.push(lineNumber);
|
|
204
181
|
}
|
|
205
|
-
if (RESTART_LOAD_PATTERN.test(
|
|
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
|
-
|
|
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
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
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: [
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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[];
|