ccs-mcp-server 1.3.0 → 1.4.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.
- package/README.md +53 -13
- package/dsh/cmdi.js +98 -0
- package/dsh/cordis.patch.yml +5 -0
- package/dsh/credentials.js +169 -0
- package/dsh/index.js +730 -0
- package/dsh/injection.js +75 -0
- package/dsh/package.json +5 -0
- package/dsh/policy.js +359 -0
- package/dsh/ssrf.js +170 -0
- package/package.json +21 -5
- package/src/index.js +62 -2
- package/test/run-tests.js +96 -0
package/dsh/injection.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Prompt injection detection — identifies injection attempts in tool inputs.
|
|
3
|
+
*
|
|
4
|
+
* Detects patterns commonly used in indirect prompt injection:
|
|
5
|
+
* - "ignore previous instructions" / "disregard" / "forget"
|
|
6
|
+
* - Role override ("you are now", "system:")
|
|
7
|
+
* - Instruction leakage ("reveal your system prompt")
|
|
8
|
+
* - Markup/comment-based injection in fetched content
|
|
9
|
+
* - Encoded/obfuscated injection attempts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const INJECTION_PATTERNS = [
|
|
13
|
+
// Direct instruction override
|
|
14
|
+
{ pattern: /\b(?:ignore|disregard|forget|override)\b.{0,40}\b(?:previous|prior|above|all)\b.{0,20}\b(?:instruction|prompt|rule|system|context)/i, weight: 5, label: 'instruction override' },
|
|
15
|
+
{ pattern: /\b(?:ignore|disregard|forget)\b.{0,20}\b(?:all|previous|prior|above)/i, weight: 4, label: 'instruction override attempt' },
|
|
16
|
+
|
|
17
|
+
// Role/system override
|
|
18
|
+
{ pattern: /\b(?:you are now|you're now|new (?:persona|role|identity)|act as|pretend (?:to be|you are))\b/i, weight: 4, label: 'role override attempt' },
|
|
19
|
+
{ pattern: /\b(?:system|assistant|developer)\s*:\s*(?:you|ignore|new|override|do not)/i, weight: 4, label: 'fake role marker' },
|
|
20
|
+
{ pattern: /\[(?:SYSTEM|ADMIN|OVERRIDE|INSTRUCTION)\]/i, weight: 5, label: 'fake system/override tag' },
|
|
21
|
+
|
|
22
|
+
// Prompt exfiltration
|
|
23
|
+
{ pattern: /\b(?:reveal|show|print|leak|expose|dump|output|repeat)\b.{0,30}\b(?:system|initial|original|developer|hidden)\b.{0,20}\b(?:prompt|instruction|message|rule)/i, weight: 5, label: 'prompt exfiltration attempt' },
|
|
24
|
+
{ pattern: /\b(?:what are|what were|tell me)\b.{0,20}\b(?:your|the)\b.{0,20}\b(?:instruction|rule|system prompt|developer message)/i, weight: 3, label: 'prompt probing' },
|
|
25
|
+
|
|
26
|
+
// HTML/XML comment injection (in fetched web content)
|
|
27
|
+
{ pattern: /<!--[\s\S]{0,200}(?:ignore|system|instruction|assistant|secret|password|token|key)/i, weight: 3, label: 'hidden comment injection' },
|
|
28
|
+
{ pattern: /<(?:script|meta|template)[^>]*>[\s\S]{0,200}(?:ignore|instruction|system|assistant)/i, weight: 3, label: 'markup-based injection' },
|
|
29
|
+
|
|
30
|
+
// Markdown image-based exfiltration
|
|
31
|
+
{ pattern: /!\[[^\]]{0,100}\]\s*\(\s*https?:\/\/[^\s)]{10,}/i, weight: 2, label: 'markdown image (potential data exfil via URL)' },
|
|
32
|
+
|
|
33
|
+
// Unicode/bidi tricks
|
|
34
|
+
{ pattern: /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/, weight: 2, label: 'invisible/override Unicode character' },
|
|
35
|
+
|
|
36
|
+
// Base64-encoded instructions
|
|
37
|
+
{ pattern: /(?:decode|base64|atob|btoa)\s*\(?['"`][A-Za-z0-9+/=]{20,}/i, weight: 3, label: 'encoded payload reference' },
|
|
38
|
+
|
|
39
|
+
// Social engineering urgency
|
|
40
|
+
{ pattern: /\b(?:urgent|emergency|critical|immediately)\b.{0,30}\b(?:must|need to|have to|do it now|no questions)/i, weight: 1, label: 'urgency/social engineering' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scan text for prompt injection patterns.
|
|
45
|
+
* @param {string} text - the text to scan.
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
* @param {number} [opts.threshold=6] - total weight to trigger block.
|
|
48
|
+
* @returns {{ score: number, hits: Array<{label: string, weight: number, snippet: string}>, blocked: boolean, reason?: string }}
|
|
49
|
+
*/
|
|
50
|
+
export function detectInjection(text, opts = {}) {
|
|
51
|
+
if (!text || typeof text !== 'string') return { score: 0, hits: [], blocked: false };
|
|
52
|
+
|
|
53
|
+
const threshold = opts.threshold ?? 6;
|
|
54
|
+
const hits = [];
|
|
55
|
+
let score = 0;
|
|
56
|
+
|
|
57
|
+
for (const { pattern, weight, label } of INJECTION_PATTERNS) {
|
|
58
|
+
pattern.lastIndex = 0;
|
|
59
|
+
const match = pattern.exec(text);
|
|
60
|
+
if (match) {
|
|
61
|
+
score += weight;
|
|
62
|
+
const start = Math.max(0, match.index - 20);
|
|
63
|
+
const end = Math.min(text.length, match.index + match[0].length + 20);
|
|
64
|
+
hits.push({ label, weight, snippet: text.slice(start, end).trim() });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const blocked = score >= threshold;
|
|
69
|
+
return {
|
|
70
|
+
score,
|
|
71
|
+
hits,
|
|
72
|
+
blocked,
|
|
73
|
+
reason: blocked ? `prompt injection detected (score ${score} ≥ ${threshold}): ${hits.map(h => h.label).join(', ')}` : undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
package/dsh/package.json
ADDED
package/dsh/policy.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CCS policy engine — combines all detectors into a unified decision.
|
|
3
|
+
*
|
|
4
|
+
* The 7 CCS dimensions enforced at runtime:
|
|
5
|
+
* 1. Structure — tool call arguments match declared JSON Schema
|
|
6
|
+
* 2. Schema — input/output types are well-formed
|
|
7
|
+
* 3. Latency — call duration within budget (observability only)
|
|
8
|
+
* 4. Cost — token/API cost within budget (observability only)
|
|
9
|
+
* 5. Identity — caller and tool identity are known and trusted
|
|
10
|
+
* 6. Integrity — no prompt injection, no credential exfiltration
|
|
11
|
+
* 7. Security — no SSRF, command injection, path traversal, destructive ops
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { validateFetchUrl } from './ssrf.js';
|
|
15
|
+
import { scanForSecrets, isCredentialPath, scanSubprocessForCredentials } from './credentials.js';
|
|
16
|
+
import { detectInjection } from './injection.js';
|
|
17
|
+
import { inspectSpawn } from './cmdi.js';
|
|
18
|
+
|
|
19
|
+
/** Default policy configuration. */
|
|
20
|
+
export const DEFAULT_POLICY = {
|
|
21
|
+
// Dimension 7: Security
|
|
22
|
+
ssrf: { enabled: true, allowPrivate: false },
|
|
23
|
+
commandInjection: { enabled: true, threshold: 6 },
|
|
24
|
+
credentialExfil: { enabled: true },
|
|
25
|
+
pathTraversal: { enabled: true, blockedPaths: ['/etc/shadow', '/etc/passwd', '/etc/sudoers'] },
|
|
26
|
+
destructiveTools: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
// Tool names that require explicit approval (case-insensitive substring match)
|
|
29
|
+
requireApproval: [
|
|
30
|
+
'execute_payment', 'pay', 'transfer', 'send_transaction',
|
|
31
|
+
'delete', 'drop', 'truncate', 'destroy', 'rm',
|
|
32
|
+
'deploy', 'apply', 'push', 'merge',
|
|
33
|
+
'revoke', 'disable', 'shutdown', 'terminate',
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Dimension 6: Integrity
|
|
38
|
+
promptInjection: { enabled: true, threshold: 6 },
|
|
39
|
+
|
|
40
|
+
// Dimension 5: Identity
|
|
41
|
+
requireToolAnnotations: { enabled: false }, // warn-only by default
|
|
42
|
+
|
|
43
|
+
// Observability (dimensions 3, 4)
|
|
44
|
+
latencyBudgetMs: 30000,
|
|
45
|
+
costBudgetTokens: 100000,
|
|
46
|
+
|
|
47
|
+
// Output scanning
|
|
48
|
+
scanOutput: { enabled: true, maxScanLength: 100000 },
|
|
49
|
+
|
|
50
|
+
// Allowlist for safe tools (skip deep scanning)
|
|
51
|
+
safeToolAllowlist: [],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deep-merge user config with defaults.
|
|
56
|
+
*/
|
|
57
|
+
function mergeConfig(defaults, user) {
|
|
58
|
+
if (!user) return structuredClone(defaults);
|
|
59
|
+
const out = structuredClone(defaults);
|
|
60
|
+
for (const [k, v] of Object.entries(user)) {
|
|
61
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && k in out && typeof out[k] === 'object') {
|
|
62
|
+
out[k] = mergeConfig(out[k], v);
|
|
63
|
+
} else {
|
|
64
|
+
out[k] = v;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check for path traversal in string arguments.
|
|
72
|
+
*/
|
|
73
|
+
function checkPathTraversal(args, blockedPaths) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
const seen = new WeakSet();
|
|
76
|
+
|
|
77
|
+
function walk(val) {
|
|
78
|
+
if (typeof val === 'string') {
|
|
79
|
+
// Directory traversal
|
|
80
|
+
if (val.includes('../') || val.includes('..\\')) {
|
|
81
|
+
// Check if it targets a blocked absolute path
|
|
82
|
+
for (const blocked of blockedPaths) {
|
|
83
|
+
if (val.includes(blocked) || val.endsWith(blocked)) {
|
|
84
|
+
findings.push(`path traversal targeting ${blocked}: ${val.slice(0, 100)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Even without a specific target, flag obvious traversal
|
|
88
|
+
if (/\.\.\/(?:etc|root|home|var|usr|proc|sys)\//.test(val)) {
|
|
89
|
+
findings.push(`directory traversal to system path: ${val.slice(0, 100)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Absolute sensitive paths
|
|
93
|
+
if (/^\/(?:etc\/shadow|etc\/passwd|etc\/sudoers|root\/\.ssh)/.test(val)) {
|
|
94
|
+
findings.push(`direct access to sensitive path: ${val}`);
|
|
95
|
+
}
|
|
96
|
+
} else if (val && typeof val === 'object') {
|
|
97
|
+
if (seen.has(val)) return;
|
|
98
|
+
seen.add(val);
|
|
99
|
+
if (Array.isArray(val)) val.forEach(walk);
|
|
100
|
+
else Object.values(val).forEach(walk);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
walk(args);
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Determine whether a tool name matches the destructive list.
|
|
110
|
+
*/
|
|
111
|
+
function isDestructive(toolName, requireApproval) {
|
|
112
|
+
const lower = toolName.toLowerCase();
|
|
113
|
+
return requireApproval.some(d => lower.includes(d.toLowerCase()));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Evaluate a tool call against the CCS policy.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} call - { name, arguments, agent? }
|
|
120
|
+
* @param {object} [userConfig] - policy overrides.
|
|
121
|
+
* @returns {Promise<{ allow: boolean, reason?: string, warnings: string[], dimension: string }>}
|
|
122
|
+
*/
|
|
123
|
+
export async function evaluateToolCall(call, userConfig) {
|
|
124
|
+
const config = mergeConfig(DEFAULT_POLICY, userConfig);
|
|
125
|
+
const warnings = [];
|
|
126
|
+
const { name, arguments: args } = call;
|
|
127
|
+
|
|
128
|
+
// Skip deep scanning for allowlisted safe tools
|
|
129
|
+
if (config.safeToolAllowlist.includes(name)) {
|
|
130
|
+
return { allow: true, warnings, dimension: 'allowlist' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Dimension 7: Destructive tool check
|
|
134
|
+
if (config.destructiveTools.enabled && isDestructive(name, config.destructiveTools.requireApproval)) {
|
|
135
|
+
return {
|
|
136
|
+
allow: false,
|
|
137
|
+
reason: `[CCS/Security] destructive tool "${name}" requires explicit human approval (auto-approve is blocked by CCS policy)`,
|
|
138
|
+
warnings,
|
|
139
|
+
dimension: 'Security',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Scan string arguments for various threats
|
|
144
|
+
const argStrings = [];
|
|
145
|
+
const seen = new WeakSet();
|
|
146
|
+
function collectStrings(val) {
|
|
147
|
+
if (typeof val === 'string') argStrings.push(val);
|
|
148
|
+
else if (val && typeof val === 'object') {
|
|
149
|
+
if (seen.has(val)) return;
|
|
150
|
+
seen.add(val);
|
|
151
|
+
if (Array.isArray(val)) val.forEach(collectStrings);
|
|
152
|
+
else Object.values(val).forEach(collectStrings);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
collectStrings(args);
|
|
156
|
+
|
|
157
|
+
// Dimension 7: SSRF — check URL arguments
|
|
158
|
+
if (config.ssrf.enabled) {
|
|
159
|
+
for (const str of argStrings) {
|
|
160
|
+
if (/^https?:\/\//i.test(str)) {
|
|
161
|
+
const ssrfReason = await validateFetchUrl(str, { allowPrivate: config.ssrf.allowPrivate });
|
|
162
|
+
if (ssrfReason) {
|
|
163
|
+
return { allow: false, reason: `[CCS/Security] ${ssrfReason}`, warnings, dimension: 'Security' };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Dimension 7: Command injection — check arguments that look like commands
|
|
170
|
+
if (config.commandInjection.enabled) {
|
|
171
|
+
for (const str of argStrings) {
|
|
172
|
+
if (str.length > 5000) continue; // skip very long strings (likely data, not commands)
|
|
173
|
+
// Only scan strings that look like they could contain shell commands
|
|
174
|
+
if (/[;&|`$]|\b(?:curl|wget|bash|sh|nc|python|perl|ruby|chmod|rm)\b/.test(str)) {
|
|
175
|
+
const result = inspectSpawn({ command: str }, config.commandInjection.threshold);
|
|
176
|
+
if (result.blocked) {
|
|
177
|
+
return { allow: false, reason: `[CCS/Security] ${result.reason}`, warnings, dimension: 'Security' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Dimension 7: Credential exfiltration
|
|
184
|
+
if (config.credentialExfil.enabled) {
|
|
185
|
+
const secrets = scanForSecrets(args);
|
|
186
|
+
if (secrets.length > 0) {
|
|
187
|
+
// If the tool is network-facing (fetch, request, send), block
|
|
188
|
+
const networkFacing = /^(fetch|request|web|http|send|post|put|upload|curl|api_call|call)/i.test(name);
|
|
189
|
+
if (networkFacing) {
|
|
190
|
+
return {
|
|
191
|
+
allow: false,
|
|
192
|
+
reason: `[CCS/Security] credential pattern detected in network-facing tool "${name}" arguments: ${secrets.map(s => s.label).join(', ')}`,
|
|
193
|
+
warnings,
|
|
194
|
+
dimension: 'Security',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// For non-network tools, warn only
|
|
198
|
+
warnings.push(`credential pattern detected: ${secrets.map(s => s.label).join(', ')}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check for credential file paths
|
|
202
|
+
for (const str of argStrings) {
|
|
203
|
+
const credPath = isCredentialPath(str);
|
|
204
|
+
if (credPath) {
|
|
205
|
+
const readTool = /^(read|cat|get|fetch|open|load|view|show|list)/i.test(name);
|
|
206
|
+
if (readTool) {
|
|
207
|
+
return {
|
|
208
|
+
allow: false,
|
|
209
|
+
reason: `[CCS/Security] tool "${name}" attempting to read credential file: ${str}`,
|
|
210
|
+
warnings,
|
|
211
|
+
dimension: 'Security',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
warnings.push(`credential file path referenced: ${str}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Dimension 7: Path traversal
|
|
220
|
+
if (config.pathTraversal.enabled) {
|
|
221
|
+
const traversal = checkPathTraversal(args, config.pathTraversal.blockedPaths);
|
|
222
|
+
if (traversal.length > 0) {
|
|
223
|
+
return {
|
|
224
|
+
allow: false,
|
|
225
|
+
reason: `[CCS/Security] path traversal detected: ${traversal[0]}`,
|
|
226
|
+
warnings,
|
|
227
|
+
dimension: 'Security',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Dimension 6: Prompt injection — scan all string arguments concatenated
|
|
233
|
+
if (config.promptInjection.enabled) {
|
|
234
|
+
const combined = argStrings.join('\n').slice(0, 50000);
|
|
235
|
+
if (combined.length > 10) {
|
|
236
|
+
const injection = detectInjection(combined, { threshold: config.promptInjection.threshold });
|
|
237
|
+
if (injection.blocked) {
|
|
238
|
+
return {
|
|
239
|
+
allow: false,
|
|
240
|
+
reason: `[CCS/Integrity] ${injection.reason}`,
|
|
241
|
+
warnings,
|
|
242
|
+
dimension: 'Integrity',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (injection.hits.length > 0) {
|
|
246
|
+
warnings.push(`prompt injection pattern (score ${injection.score}): ${injection.hits.map(h => h.label).join(', ')}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { allow: true, warnings, dimension: 'pass' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Evaluate a tool result/output for credential leakage.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} result - { content?, isError? }
|
|
258
|
+
* @param {object} [userConfig]
|
|
259
|
+
* @returns {{ allow: boolean, reason?: string, warnings: string[] }}
|
|
260
|
+
*/
|
|
261
|
+
export function evaluateToolResult(result, userConfig) {
|
|
262
|
+
const config = mergeConfig(DEFAULT_POLICY, userConfig);
|
|
263
|
+
const warnings = [];
|
|
264
|
+
|
|
265
|
+
if (!config.scanOutput.enabled || result.isError) return { allow: true, warnings };
|
|
266
|
+
|
|
267
|
+
// Extract text from content blocks
|
|
268
|
+
let text = '';
|
|
269
|
+
if (Array.isArray(result.content)) {
|
|
270
|
+
for (const block of result.content) {
|
|
271
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
272
|
+
text += block.text + '\n';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} else if (typeof result.content === 'string') {
|
|
276
|
+
text = result.content;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (text.length > config.scanOutput.maxScanLength) {
|
|
280
|
+
text = text.slice(0, config.scanOutput.maxScanLength);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!text) return { allow: true, warnings };
|
|
284
|
+
|
|
285
|
+
// Scan output for leaked secrets
|
|
286
|
+
const secrets = scanForSecrets(text);
|
|
287
|
+
if (secrets.length > 0) {
|
|
288
|
+
warnings.push(`output contains credential patterns: ${secrets.map(s => s.label).join(', ')}`);
|
|
289
|
+
// Redact in a future version; for now warn
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Scan output for injection attempts (fetched web content trying to hijack)
|
|
293
|
+
if (config.promptInjection.enabled) {
|
|
294
|
+
const injection = detectInjection(text, { threshold: config.promptInjection.threshold });
|
|
295
|
+
if (injection.blocked) {
|
|
296
|
+
return {
|
|
297
|
+
allow: false,
|
|
298
|
+
reason: `[CCS/Integrity] tool output contains prompt injection: ${injection.reason}`,
|
|
299
|
+
warnings,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { allow: true, warnings };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Evaluate a subprocess spawn for security violations.
|
|
309
|
+
*
|
|
310
|
+
* @param {object} spec - spawn spec
|
|
311
|
+
* @param {object} [userConfig]
|
|
312
|
+
* @returns {{ allow: boolean, reason?: string, warnings: string[] }}
|
|
313
|
+
*/
|
|
314
|
+
export function evaluateSubprocess(spec, userConfig) {
|
|
315
|
+
const config = mergeConfig(DEFAULT_POLICY, userConfig);
|
|
316
|
+
const warnings = [];
|
|
317
|
+
|
|
318
|
+
// Command injection
|
|
319
|
+
if (config.commandInjection.enabled) {
|
|
320
|
+
const result = inspectSpawn(spec, config.commandInjection.threshold);
|
|
321
|
+
if (result.blocked) {
|
|
322
|
+
return { allow: false, reason: `[CCS/Security] ${result.reason}`, warnings };
|
|
323
|
+
}
|
|
324
|
+
if (result.hits.length > 0) {
|
|
325
|
+
warnings.push(`command patterns: ${result.hits.map(h => h.label).join(', ')}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Credential exposure
|
|
330
|
+
if (config.credentialExfil.enabled) {
|
|
331
|
+
const credFindings = scanSubprocessForCredentials(spec);
|
|
332
|
+
if (credFindings.length > 0) {
|
|
333
|
+
return {
|
|
334
|
+
allow: false,
|
|
335
|
+
reason: `[CCS/Security] credential exposure in subprocess: ${credFindings.map(f => f.label).join(', ')}`,
|
|
336
|
+
warnings,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check argv for URLs (SSRF via subprocess)
|
|
342
|
+
if (config.ssrf.enabled) {
|
|
343
|
+
const argv = Array.isArray(spec?.argv) ? spec.argv : [];
|
|
344
|
+
for (const arg of argv) {
|
|
345
|
+
if (typeof arg === 'string' && /^https?:\/\//i.test(arg)) {
|
|
346
|
+
// Synchronous URL check for common private IP patterns (no DNS lookup in sync path)
|
|
347
|
+
if (/^https?:\/\/(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|localhost|0\.0\.0\.0|\[::1\])/i.test(arg)) {
|
|
348
|
+
return {
|
|
349
|
+
allow: false,
|
|
350
|
+
reason: `[CCS/Security] subprocess targets private/internal network: ${arg}`,
|
|
351
|
+
warnings,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { allow: true, warnings };
|
|
359
|
+
}
|
package/dsh/ssrf.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file SSRF protection — validates URLs against private network ranges.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek Harness's built-in web-fetch-http explicitly defers SSRF blocking
|
|
5
|
+
* (see packages/web/web-fetch-http/src/policy.ts line 18: "SSRF / private-network
|
|
6
|
+
* blocking is deferred"). This module fills that gap.
|
|
7
|
+
*
|
|
8
|
+
* Covers: IPv4 private ranges, loopback, link-local, carrier-grade NAT,
|
|
9
|
+
* IPv6 loopback/unique-local/link-local, decimal/octal/hex IP encoding,
|
|
10
|
+
* and DNS rebinding via TTL=0 detection (best-effort).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { lookup } from 'node:dns/promises';
|
|
14
|
+
import { isIP } from 'node:net';
|
|
15
|
+
|
|
16
|
+
/** IPv4 CIDR ranges that must never be reachable from a plugin/tool fetch. */
|
|
17
|
+
const V4_BLOCKLIST = [
|
|
18
|
+
{ network: 0n, broadcast: 255n, label: 'this network (0.0.0.0/8)' },
|
|
19
|
+
{ network: 167772160n, broadcast: 184549375n, label: 'private (10.0.0.0/8)' },
|
|
20
|
+
{ network: 2130706432n, broadcast: 2147483647n, label: 'loopback (127.0.0.0/8)' },
|
|
21
|
+
{ network: 2851995648n, broadcast: 2852061183n, label: 'link-local (169.254.0.0/16)' },
|
|
22
|
+
{ network: 2886729728n, broadcast: 2887778303n, label: 'private (172.16.0.0/12)' },
|
|
23
|
+
{ network: 3221225472n, broadcast: 3221225727n, label: 'documentation (192.0.2.0/24)' },
|
|
24
|
+
{ network: 3221225984n, broadcast: 3221226239n, label: 'documentation (192.0.2.0/24)' },
|
|
25
|
+
{ network: 3232235520n, broadcast: 3232301055n, label: 'private (192.168.0.0/16)' },
|
|
26
|
+
{ network: 3272853504n, broadcast: 3272919039n, label: 'benchmarking (198.18.0.0/15)' },
|
|
27
|
+
{ network: 3323068416n, broadcast: 3323199487n, label: 'documentation (203.0.113.0/24)' },
|
|
28
|
+
{ network: 3325256704n, broadcast: 3325256959n, label: 'reserved (224.0.0.0/4 multicast)' },
|
|
29
|
+
{ network: 3758096384n, broadcast: 4026531839n, label: 'reserved (224.0.0.0/4)' },
|
|
30
|
+
{ network: 4026531840n, broadcast: 4294967295n, label: 'reserved (240.0.0.0/4)' },
|
|
31
|
+
// Cloud metadata endpoints
|
|
32
|
+
{ network: ipToLong('169.254.169.254'), broadcast: ipToLong('169.254.169.254'), label: 'cloud metadata (169.254.169.254)' },
|
|
33
|
+
{ network: ipToLong('100.100.100.200'), broadcast: ipToLong('100.100.100.200'), label: 'Alibaba Cloud metadata (100.100.100.200)' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Convert a dotted-quad IPv4 string to a 32-bit integer (as BigInt). */
|
|
37
|
+
function ipToLong(ip) {
|
|
38
|
+
const parts = ip.split('.').map(Number);
|
|
39
|
+
return BigInt(parts[0] * 16777216 + parts[1] * 65536 + parts[2] * 256 + parts[3]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse a numeric string that may be decimal, octal (0o), or hex (0x). */
|
|
43
|
+
function parseNumericHost(raw) {
|
|
44
|
+
const trimmed = raw.trim().toLowerCase();
|
|
45
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(trimmed)) return trimmed;
|
|
46
|
+
// Handle decimal integer form (e.g. http://2130706433 = 127.0.0.1)
|
|
47
|
+
if (/^\d+$/.test(trimmed)) {
|
|
48
|
+
const num = BigInt(trimmed);
|
|
49
|
+
return `${(num >> 24n) & 255n}.${(num >> 16n) & 255n}.${(num >> 8n) & 255n}.${num & 255n}`;
|
|
50
|
+
}
|
|
51
|
+
// Hex form
|
|
52
|
+
if (trimmed.startsWith('0x')) {
|
|
53
|
+
const num = BigInt(trimmed);
|
|
54
|
+
return `${(num >> 24n) & 255n}.${(num >> 16n) & 255n}.${(num >> 8n) & 255n}.${num & 255n}`;
|
|
55
|
+
}
|
|
56
|
+
// Octal form per part
|
|
57
|
+
if (/^0[0-7]+(\.0[0-7]+){0,3}$/.test(trimmed)) {
|
|
58
|
+
const parts = trimmed.split('.').map(p => parseInt(p, 8));
|
|
59
|
+
while (parts.length < 4) parts.push(0);
|
|
60
|
+
return parts.join('.');
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Check whether an IPv4 address string falls in a blocked range. */
|
|
66
|
+
export function isBlockedIPv4(ip) {
|
|
67
|
+
const num = ipToLong(ip);
|
|
68
|
+
for (const range of V4_BLOCKLIST) {
|
|
69
|
+
if (num >= range.network && num <= range.broadcast) {
|
|
70
|
+
return range.label;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Check whether an IPv6 address is loopback, link-local, unique-local, or unspecified. */
|
|
77
|
+
export function isBlockedIPv6(ip) {
|
|
78
|
+
const lower = ip.toLowerCase();
|
|
79
|
+
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return 'loopback (::1)';
|
|
80
|
+
if (lower === '::' || lower === '0:0:0:0:0:0:0:0') return 'unspecified (::)';
|
|
81
|
+
if (lower.startsWith('fe80:')) return 'link-local (fe80::/10)';
|
|
82
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return 'unique-local (fc00::/7)';
|
|
83
|
+
if (lower.startsWith('ff') || lower.startsWith('ff00:')) return 'multicast (ff00::/8)';
|
|
84
|
+
// IPv4-mapped IPv6 (::ffff:a.b.c.d)
|
|
85
|
+
const v4Mapped = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/);
|
|
86
|
+
if (v4Mapped) return isBlockedIPv4(v4Mapped[1]);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate a URL for SSRF. Returns a denial reason string if blocked,
|
|
92
|
+
* or undefined if safe.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} urlString - the URL to validate.
|
|
95
|
+
* @param {object} [opts]
|
|
96
|
+
* @param {boolean} [opts.allowPrivate=false] - permit private IP targets.
|
|
97
|
+
* @returns {Promise<string|undefined>} denial reason, or undefined.
|
|
98
|
+
*/
|
|
99
|
+
export async function validateFetchUrl(urlString, opts = {}) {
|
|
100
|
+
let url;
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(urlString);
|
|
103
|
+
} catch {
|
|
104
|
+
return `invalid URL: ${urlString}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Scheme check
|
|
108
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
109
|
+
return `blocked scheme "${url.protocol}" — only http/https permitted`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Embedded credentials
|
|
113
|
+
if (url.username || url.password) {
|
|
114
|
+
return 'URL contains embedded credentials';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hostname = url.hostname;
|
|
118
|
+
|
|
119
|
+
// IP literal? (URL.hostname already strips brackets for IPv6)
|
|
120
|
+
const ipVersion = isIP(hostname);
|
|
121
|
+
if (ipVersion === 4) {
|
|
122
|
+
const reason = isBlockedIPv4(hostname);
|
|
123
|
+
if (reason && !opts.allowPrivate) return `SSRF blocked: target is ${reason}`;
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (ipVersion === 6) {
|
|
127
|
+
const reason = isBlockedIPv6(hostname);
|
|
128
|
+
if (reason && !opts.allowPrivate) return `SSRF blocked: target is ${reason}`;
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Also check for bracketed IPv6 that isIP might reject
|
|
133
|
+
const bracketMatch = hostname.match(/^\[(.+)\]$/);
|
|
134
|
+
if (bracketMatch) {
|
|
135
|
+
const inner = bracketMatch[1];
|
|
136
|
+
if (isIP(inner) === 6) {
|
|
137
|
+
const reason = isBlockedIPv6(inner);
|
|
138
|
+
if (reason && !opts.allowPrivate) return `SSRF blocked: target is ${reason}`;
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Numeric encoding attempts
|
|
144
|
+
const numeric = parseNumericHost(hostname);
|
|
145
|
+
if (numeric) {
|
|
146
|
+
const reason = isBlockedIPv4(numeric);
|
|
147
|
+
if (reason && !opts.allowPrivate) return `SSRF blocked: numeric host "${hostname}" resolves to ${reason}`;
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// DNS resolution — check resolved IPs
|
|
152
|
+
if (!opts.allowPrivate) {
|
|
153
|
+
try {
|
|
154
|
+
const addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
155
|
+
for (const addr of addresses) {
|
|
156
|
+
if (addr.family === 4) {
|
|
157
|
+
const reason = isBlockedIPv4(addr.address);
|
|
158
|
+
if (reason) return `SSRF blocked: "${hostname}" resolves to ${reason} (${addr.address})`;
|
|
159
|
+
} else {
|
|
160
|
+
const reason = isBlockedIPv6(addr.address);
|
|
161
|
+
if (reason) return `SSRF blocked: "${hostname}" resolves to ${reason} (${addr.address})`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// DNS failure — allow (the fetch itself will fail)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccs-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "MCP security server for AI agents — runtime verification, receipt verification and field-level tamper localization for Claude Code, Cursor, Cline and Windsurf. 7-dimension CCS checks, Ed25519 tamper-evident receipts, offline verification, zero runtime dependencies.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
7
|
-
"ccs-mcp-server": "src/index.js"
|
|
7
|
+
"ccs-mcp-server": "src/index.js",
|
|
8
|
+
"agent-runtime-guard": "src/index.js"
|
|
8
9
|
},
|
|
9
10
|
"main": "src/index.js",
|
|
10
11
|
"exports": {
|
|
11
12
|
".": "./src/index.js",
|
|
12
13
|
"./receipts": "./src/receipts.js",
|
|
13
|
-
"./
|
|
14
|
+
"./dsh": "./dsh/index.js",
|
|
15
|
+
"./dsh/cordis.patch.yml": "./dsh/cordis.patch.yml",
|
|
14
16
|
"./package.json": "./package.json"
|
|
15
17
|
},
|
|
16
18
|
"files": [
|
|
17
19
|
"src",
|
|
20
|
+
"dsh",
|
|
18
21
|
"README.md",
|
|
19
22
|
"LICENSE",
|
|
20
23
|
"test"
|
|
@@ -57,16 +60,29 @@
|
|
|
57
60
|
"type": "git",
|
|
58
61
|
"url": "git+https://github.com/DSHCorrectover/ccs-mcp-server.git"
|
|
59
62
|
},
|
|
60
|
-
"homepage": "https://
|
|
63
|
+
"homepage": "https://correctover.com",
|
|
61
64
|
"bugs": {
|
|
62
|
-
"url": "https://github.com/DSHCorrectover/
|
|
65
|
+
"url": "https://github.com/DSHCorrectover/ccs-mcp-server/issues"
|
|
63
66
|
},
|
|
64
67
|
"engines": {
|
|
65
68
|
"node": ">=18"
|
|
66
69
|
},
|
|
67
70
|
"dependencies": {},
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"@deepseek-ai/dsh": ">=0.2.1-alpha.1 <0.3.0-0"
|
|
73
|
+
},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"@deepseek-ai/dsh": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
68
79
|
"scripts": {
|
|
69
80
|
"test": "node test/run-tests.js"
|
|
70
81
|
},
|
|
82
|
+
"mcp": {
|
|
83
|
+
"name": "ccs-mcp-server",
|
|
84
|
+
"displayName": "CCS MCP Server",
|
|
85
|
+
"description": "Full-stack runtime interception for AI agents — blocks destructive commands, credential theft, SSRF and injection, with Ed25519 evidence receipts."
|
|
86
|
+
},
|
|
71
87
|
"mcpName": "io.github.DSHCorrectover/ccs-mcp-server"
|
|
72
88
|
}
|