clawmoat 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +9 -0
- package/CHANGELOG.md +18 -0
- package/DEMO.md +87 -0
- package/Dockerfile +5 -18
- package/README.md +232 -8
- package/THREAT_MODEL.md +129 -0
- package/agent/README.md +131 -0
- package/agent/index.js +471 -0
- package/agent/install-service.sh +94 -0
- package/agent/openclaw-hook.js +453 -0
- package/agent/provider-setup.js +649 -0
- package/agent/setup.js +274 -0
- package/assets/BADGE-USAGE.md +20 -0
- package/assets/clawmoat-badge.svg +21 -0
- package/bin/clawmoat.js +468 -111
- package/docs/affiliates/dashboard.html +124 -0
- package/docs/affiliates/index.html +236 -0
- package/docs/agent-install.html +183 -0
- package/docs/ai-agent-security-scanner.html +10 -6
- package/docs/badge/index.html +149 -0
- package/docs/badge/scanning.svg +23 -0
- package/docs/blog/386-malicious-skills.html +11 -4
- package/docs/blog/40000-exposed-openclaw-instances.html +11 -4
- package/docs/blog/agent-trust-protocol.html +5 -4
- package/docs/blog/ai-agent-earns-commissions.html +230 -0
- package/docs/blog/bugmageddon-agent-firewall.html +174 -0
- package/docs/blog/calculator-math.html +180 -0
- package/docs/blog/clawmoat-vs-llamafirewall-nemo-guardrails.html +10 -4
- package/docs/blog/host-guardian-launch.html +18 -8
- package/docs/blog/ibm-experts-agent-runtime-protection.html +15 -6
- package/docs/blog/index.html +67 -9
- package/docs/blog/langchain-security-tutorial.html +18 -8
- package/docs/blog/mcp-30-cves-security-crisis.html +11 -4
- package/docs/blog/meta-researcher-rogue-agent.html +201 -0
- package/docs/blog/microsoft-openclaw-workstation-security.html +5 -4
- package/docs/blog/nist-ai-agent-standards-clawmoat.html +16 -8
- package/docs/blog/oasis-websocket-hijack.html +11 -4
- package/docs/blog/ollama-openclaw-security.html +10 -4
- package/docs/blog/openclaw-enterprise-readiness-claw10.html +5 -4
- package/docs/blog/openclaw-security-reckoning-2026.html +11 -4
- package/docs/blog/owasp-agentic-ai-top10.html +18 -8
- package/docs/blog/securing-ai-agents.html +18 -8
- package/docs/blog/supply-chain-agents.html +18 -8
- package/docs/business/index.html +11 -16
- package/docs/business/install.html +21 -7
- package/docs/checklist.html +10 -4
- package/docs/compare/index.html +122 -0
- package/docs/compare/lakera/index.html +62 -0
- package/docs/compare/llm-guard/index.html +49 -0
- package/docs/compare/snyk-agent-scan/index.html +63 -0
- package/docs/compare.html +10 -6
- package/docs/dashboard/index.html +520 -0
- package/docs/finance/index.html +9 -6
- package/docs/guides/business-deployment.html +770 -0
- package/docs/hall-of-fame.html +11 -5
- package/docs/index.html +266 -137
- package/docs/integrations/langchain.html +14 -6
- package/docs/integrations/openai.html +14 -6
- package/docs/integrations/openclaw.html +55 -7
- package/docs/plans/2026-03-26-threat-intel-api.md +255 -0
- package/docs/plans/2026-04-14-bugmageddon-marketing-pack.md +329 -0
- package/docs/plans/2026-04-14-clawmoat-v1-bugmageddon.md +248 -0
- package/docs/plans/2026-04-14-v1-release-update.md +91 -0
- package/docs/plans/2026-04-19-supabase-audit.md +68 -0
- package/docs/plans/2026-05-12-sales-push.md +303 -0
- package/docs/playground/index.html +893 -0
- package/docs/playground.html +4 -7
- package/docs/rfcs/defense-in-depth.md +467 -0
- package/docs/scan/index.html +156 -12
- package/docs/services/case-study.html +255 -0
- package/docs/services/downloads/install-openclaw.bat +45 -0
- package/docs/services/downloads/install-openclaw.command +38 -0
- package/docs/services/downloads/install-openclaw.sh +38 -0
- package/docs/services/get-started.html +165 -0
- package/docs/services/index.html +598 -0
- package/docs/services/multi-agent-security.html +284 -0
- package/docs/services/one-pager.html +99 -0
- package/docs/services/pitch-deck.html +229 -0
- package/docs/services/roi-calculator.html +258 -0
- package/docs/sitemap.xml +62 -2
- package/docs/support/index.html +12 -1
- package/docs/templates/customer-service/HEARTBEAT.md +61 -0
- package/docs/templates/customer-service/MEMORY.md +89 -0
- package/docs/templates/customer-service/SOUL.md +41 -0
- package/docs/templates/customer-service/USER.md +56 -0
- package/docs/templates/executive/HEARTBEAT.md +86 -0
- package/docs/templates/executive/MEMORY.md +92 -0
- package/docs/templates/executive/SOUL.md +44 -0
- package/docs/templates/executive/USER.md +62 -0
- package/docs/templates/finance/HEARTBEAT.md +58 -0
- package/docs/templates/finance/MEMORY.md +87 -0
- package/docs/templates/finance/SOUL.md +38 -0
- package/docs/templates/finance/USER.md +53 -0
- package/docs/templates/index.html +115 -0
- package/docs/templates/operations/HEARTBEAT.md +63 -0
- package/docs/templates/operations/MEMORY.md +68 -0
- package/docs/templates/operations/SOUL.md +38 -0
- package/docs/templates/operations/USER.md +49 -0
- package/docs/templates/sales/HEARTBEAT.md +55 -0
- package/docs/templates/sales/MEMORY.md +89 -0
- package/docs/templates/sales/SOUL.md +34 -0
- package/docs/templates/sales/USER.md +54 -0
- package/eslint.config.js +32 -0
- package/evals/README.md +29 -0
- package/evals/cases.json +390 -0
- package/evals/results.md +68 -0
- package/evals/run.js +180 -0
- package/examples/demo-attack/demo.js +186 -0
- package/examples/python-quickstart/README.md +54 -0
- package/examples/python-quickstart/clawmoat_client.py +167 -0
- package/examples/video-demo/README.md +14 -0
- package/examples/video-demo/scene-a-normal.js +29 -0
- package/examples/video-demo/scene-b-attack-arrives.js +31 -0
- package/examples/video-demo/scene-c-hijack.js +44 -0
- package/examples/video-demo/scene-d-clawmoat.js +46 -0
- package/integrations/crewai/README.md +32 -0
- package/integrations/crewai/clawmoat_crewai/__init__.py +17 -0
- package/integrations/crewai/clawmoat_crewai/guard.py +103 -0
- package/integrations/crewai/pyproject.toml +21 -0
- package/integrations/langchain/README.md +91 -0
- package/integrations/langchain/clawmoat_langchain/__init__.py +17 -0
- package/integrations/langchain/clawmoat_langchain/callback.py +489 -0
- package/integrations/langchain/pyproject.toml +32 -0
- package/integrations/litellm/README.md +324 -0
- package/integrations/litellm/clawmoat_litellm/__init__.py +21 -0
- package/integrations/litellm/clawmoat_litellm/callback.py +329 -0
- package/integrations/litellm/clawmoat_litellm/proxy_middleware.py +224 -0
- package/integrations/litellm/pyproject.toml +74 -0
- package/integrations/openai-agents/README.md +392 -0
- package/integrations/openai-agents/clawmoat_openai_agents/__init__.py +20 -0
- package/integrations/openai-agents/clawmoat_openai_agents/guardrail.py +431 -0
- package/integrations/openai-agents/clawmoat_openai_agents/middleware.py +311 -0
- package/integrations/openai-agents/pyproject.toml +76 -0
- package/package.json +6 -5
- package/plugins/openclaw-adapter/PHASE1.md +439 -0
- package/plugins/openclaw-adapter/README.md +103 -0
- package/plugins/openclaw-adapter/SPEC.md +1644 -0
- package/plugins/openclaw-adapter/package.json +31 -0
- package/plugins/openclaw-adapter/src/index.test.ts +226 -0
- package/plugins/openclaw-adapter/src/index.ts +140 -0
- package/plugins/openclaw-adapter/tsconfig.json +14 -0
- package/server/data/threats.json +290 -0
- package/server/index.js +142 -7
- package/src/adapters/express.js +161 -0
- package/src/adapters/index.js +92 -0
- package/src/adapters/langchain.js +185 -0
- package/src/approval/index.js +456 -0
- package/src/ban-scanner.js +200 -0
- package/src/boundary-scanner.js +296 -0
- package/src/ci-scanner.js +279 -0
- package/src/code-scanner.js +245 -0
- package/src/enforce.js +166 -0
- package/src/formatters/json.js +80 -0
- package/src/formatters/sarif.js +388 -0
- package/src/guardian/alerts.js +34 -3
- package/src/guardian/index.js +41 -2
- package/src/index.js +102 -0
- package/src/integrations/agentmesh.js +501 -0
- package/src/language-detector.js +201 -0
- package/src/mcp-scanner.js +253 -0
- package/src/multimodal/index.js +579 -0
- package/src/obfuscation-scanner.js +457 -0
- package/src/policy-engine.js +402 -0
- package/src/scanners/dependency-attacks.js +128 -0
- package/src/scanners/prompt-injection.js +18 -0
- package/src/scanners/supply-chain.js +14 -0
- package/src/templates/default-config.yml +90 -0
- package/src/vuln-ops/exploitability.js +46 -0
- package/src/watch/live-monitor.js +720 -0
- package/clawmoat-0.8.0.tgz +0 -0
- package/server/index.js.patch +0 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Boundary Scanner
|
|
3
|
+
*
|
|
4
|
+
* Formalizes scanning at every agent boundary:
|
|
5
|
+
* - pre-input: User/external → Agent (prompt injection, obfuscation, jailbreak)
|
|
6
|
+
* - pre-model: Agent → LLM (prompt leakage, system prompt exposure)
|
|
7
|
+
* - pre-tool-call: LLM → Tool (dangerous commands, exfil, excessive agency)
|
|
8
|
+
* - post-tool-result: Tool → LLM (poisoned results, injected instructions)
|
|
9
|
+
* - pre-output: Agent → User/external (secret leakage, PII, data exfil)
|
|
10
|
+
*
|
|
11
|
+
* This is the pipeline that makes ClawMoat agent-native, not chat-native.
|
|
12
|
+
*
|
|
13
|
+
* @module boundary-scanner
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const STAGES = ['pre-input', 'pre-model', 'pre-tool-call', 'post-tool-result', 'pre-output'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a boundary scanner pipeline
|
|
22
|
+
* @param {Object} [config] - Configuration
|
|
23
|
+
* @param {string} [config.mode='enforce'] - 'enforce' (block), 'monitor' (log only), 'off'
|
|
24
|
+
* @param {Function} [config.onViolation] - Callback on violations: (stage, finding, context) => void
|
|
25
|
+
* @param {Function} [config.onDecision] - Callback on every decision: (stage, result, context) => void
|
|
26
|
+
* @param {Object} [config.stageConfig] - Per-stage overrides
|
|
27
|
+
* @returns {Object} Pipeline instance
|
|
28
|
+
*/
|
|
29
|
+
function createPipeline(config = {}) {
|
|
30
|
+
const {
|
|
31
|
+
mode = 'enforce',
|
|
32
|
+
onViolation = null,
|
|
33
|
+
onDecision = null,
|
|
34
|
+
stageConfig = {},
|
|
35
|
+
} = config;
|
|
36
|
+
|
|
37
|
+
// Registered scanners per stage
|
|
38
|
+
const scanners = {};
|
|
39
|
+
for (const stage of STAGES) {
|
|
40
|
+
scanners[stage] = [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Execution trace
|
|
44
|
+
const trace = [];
|
|
45
|
+
const stats = { scanned: 0, blocked: 0, warned: 0, allowed: 0 };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register a scanner for a stage
|
|
49
|
+
* @param {string} stage - Pipeline stage
|
|
50
|
+
* @param {string} name - Scanner name
|
|
51
|
+
* @param {Function} fn - Scanner function: (text, context) => { safe, findings }
|
|
52
|
+
* @param {Object} [opts] - Options
|
|
53
|
+
* @param {number} [opts.priority=50] - Lower runs first
|
|
54
|
+
* @param {boolean} [opts.required=false] - Pipeline fails if scanner throws
|
|
55
|
+
*/
|
|
56
|
+
function register(stage, name, fn, opts = {}) {
|
|
57
|
+
if (!STAGES.includes(stage)) {
|
|
58
|
+
throw new Error(`Invalid stage: ${stage}. Must be one of: ${STAGES.join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
const { priority = 50, required = false } = opts;
|
|
61
|
+
scanners[stage].push({ name, fn, priority, required });
|
|
62
|
+
scanners[stage].sort((a, b) => a.priority - b.priority);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run all scanners for a pipeline stage
|
|
67
|
+
* @param {string} stage - Pipeline stage
|
|
68
|
+
* @param {string|Object} input - Text or structured input to scan
|
|
69
|
+
* @param {Object} [context] - Additional context (trust level, tool name, etc.)
|
|
70
|
+
* @returns {Object} { allowed, findings, blocked, actions }
|
|
71
|
+
*/
|
|
72
|
+
function scan(stage, input, context = {}) {
|
|
73
|
+
if (!STAGES.includes(stage)) {
|
|
74
|
+
throw new Error(`Invalid stage: ${stage}. Must be one of: ${STAGES.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stageMode = stageConfig[stage]?.mode || mode;
|
|
78
|
+
if (stageMode === 'off') {
|
|
79
|
+
return { allowed: true, findings: [], blocked: false, actions: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = typeof input === 'string' ? input : JSON.stringify(input);
|
|
83
|
+
const allFindings = [];
|
|
84
|
+
const actions = [];
|
|
85
|
+
let blocked = false;
|
|
86
|
+
|
|
87
|
+
stats.scanned++;
|
|
88
|
+
|
|
89
|
+
for (const scanner of scanners[stage]) {
|
|
90
|
+
try {
|
|
91
|
+
const result = scanner.fn(text, { ...context, stage });
|
|
92
|
+
if (result && !result.safe && result.findings) {
|
|
93
|
+
for (const finding of result.findings) {
|
|
94
|
+
const enriched = {
|
|
95
|
+
...finding,
|
|
96
|
+
scanner: scanner.name,
|
|
97
|
+
stage,
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
};
|
|
100
|
+
allFindings.push(enriched);
|
|
101
|
+
|
|
102
|
+
const action = resolveAction(enriched, stageMode);
|
|
103
|
+
actions.push({ finding: enriched, action });
|
|
104
|
+
|
|
105
|
+
if (action === 'block') blocked = true;
|
|
106
|
+
if (onViolation) onViolation(stage, enriched, context);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (scanner.required) {
|
|
111
|
+
blocked = true;
|
|
112
|
+
allFindings.push({
|
|
113
|
+
type: 'scanner_error',
|
|
114
|
+
subtype: 'required_scanner_failed',
|
|
115
|
+
severity: 'critical',
|
|
116
|
+
scanner: scanner.name,
|
|
117
|
+
stage,
|
|
118
|
+
evidence: err.message,
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const allowed = stageMode === 'monitor' ? true : !blocked;
|
|
126
|
+
if (blocked) stats.blocked++;
|
|
127
|
+
else if (allFindings.length > 0) stats.warned++;
|
|
128
|
+
else stats.allowed++;
|
|
129
|
+
|
|
130
|
+
const decision = {
|
|
131
|
+
stage,
|
|
132
|
+
allowed,
|
|
133
|
+
blocked,
|
|
134
|
+
findings: allFindings,
|
|
135
|
+
actions,
|
|
136
|
+
mode: stageMode,
|
|
137
|
+
scannersRun: scanners[stage].length,
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
trace.push(decision);
|
|
142
|
+
if (onDecision) onDecision(stage, decision, context);
|
|
143
|
+
|
|
144
|
+
return decision;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run a complete agent turn through the pipeline
|
|
149
|
+
* @param {Object} turn - Agent turn data
|
|
150
|
+
* @param {string} [turn.input] - User/external input
|
|
151
|
+
* @param {string} [turn.modelPrompt] - Full prompt sent to model
|
|
152
|
+
* @param {Array} [turn.toolCalls] - Array of { tool, args } objects
|
|
153
|
+
* @param {Array} [turn.toolResults] - Array of tool result strings
|
|
154
|
+
* @param {string} [turn.output] - Final agent output
|
|
155
|
+
* @param {Object} [context] - Context passed to all stages
|
|
156
|
+
* @returns {Object} { allowed, stages, findings, trace }
|
|
157
|
+
*/
|
|
158
|
+
function scanTurn(turn, context = {}) {
|
|
159
|
+
const stages = {};
|
|
160
|
+
const allFindings = [];
|
|
161
|
+
let turnAllowed = true;
|
|
162
|
+
|
|
163
|
+
if (turn.input !== undefined) {
|
|
164
|
+
stages['pre-input'] = scan('pre-input', turn.input, context);
|
|
165
|
+
if (!stages['pre-input'].allowed) turnAllowed = false;
|
|
166
|
+
allFindings.push(...stages['pre-input'].findings);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (turn.modelPrompt !== undefined && turnAllowed) {
|
|
170
|
+
stages['pre-model'] = scan('pre-model', turn.modelPrompt, context);
|
|
171
|
+
if (!stages['pre-model'].allowed) turnAllowed = false;
|
|
172
|
+
allFindings.push(...stages['pre-model'].findings);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (turn.toolCalls && turnAllowed) {
|
|
176
|
+
for (let i = 0; i < turn.toolCalls.length; i++) {
|
|
177
|
+
const tc = turn.toolCalls[i];
|
|
178
|
+
const key = `pre-tool-call[${i}]`;
|
|
179
|
+
const tcContext = { ...context, tool: tc.tool, toolIndex: i };
|
|
180
|
+
stages[key] = scan('pre-tool-call', tc, tcContext);
|
|
181
|
+
if (!stages[key].allowed) turnAllowed = false;
|
|
182
|
+
allFindings.push(...stages[key].findings);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (turn.toolResults && turnAllowed) {
|
|
187
|
+
for (let i = 0; i < turn.toolResults.length; i++) {
|
|
188
|
+
const key = `post-tool-result[${i}]`;
|
|
189
|
+
stages[key] = scan('post-tool-result', turn.toolResults[i], { ...context, toolIndex: i });
|
|
190
|
+
if (!stages[key].allowed) turnAllowed = false;
|
|
191
|
+
allFindings.push(...stages[key].findings);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (turn.output !== undefined && turnAllowed) {
|
|
196
|
+
stages['pre-output'] = scan('pre-output', turn.output, context);
|
|
197
|
+
if (!stages['pre-output'].allowed) turnAllowed = false;
|
|
198
|
+
allFindings.push(...stages['pre-output'].findings);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
allowed: turnAllowed,
|
|
203
|
+
stages,
|
|
204
|
+
findings: allFindings,
|
|
205
|
+
turnBlocked: !turnAllowed,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveAction(finding, stageMode) {
|
|
210
|
+
if (stageMode === 'monitor') return 'log';
|
|
211
|
+
const recommended = finding.recommended_action || 'block';
|
|
212
|
+
const severityMap = {
|
|
213
|
+
critical: 'block',
|
|
214
|
+
high: 'block',
|
|
215
|
+
medium: recommended === 'block' ? 'block' : 'warn',
|
|
216
|
+
low: 'warn',
|
|
217
|
+
};
|
|
218
|
+
return severityMap[finding.severity] || 'warn';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getTrace() { return [...trace]; }
|
|
222
|
+
function getStats() { return { ...stats }; }
|
|
223
|
+
function resetTrace() { trace.length = 0; }
|
|
224
|
+
function resetStats() { stats.scanned = 0; stats.blocked = 0; stats.warned = 0; stats.allowed = 0; }
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
register,
|
|
228
|
+
scan,
|
|
229
|
+
scanTurn,
|
|
230
|
+
getTrace,
|
|
231
|
+
getStats,
|
|
232
|
+
resetTrace,
|
|
233
|
+
resetStats,
|
|
234
|
+
STAGES,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Create a pre-configured pipeline with all ClawMoat scanners registered
|
|
240
|
+
* @param {Object} [config] - Pipeline config
|
|
241
|
+
* @returns {Object} Ready-to-use pipeline
|
|
242
|
+
*/
|
|
243
|
+
function createDefaultPipeline(config = {}) {
|
|
244
|
+
const pipeline = createPipeline(config);
|
|
245
|
+
|
|
246
|
+
// Lazy-load to avoid circular deps
|
|
247
|
+
const loadScanner = (name) => {
|
|
248
|
+
try { return require(`./${name}`); } catch (_) { return null; }
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Pre-input: prompt injection, jailbreak, obfuscation
|
|
252
|
+
const index = loadScanner('index');
|
|
253
|
+
const obfuscation = loadScanner('obfuscation-scanner');
|
|
254
|
+
const codeScanner = loadScanner('code-scanner');
|
|
255
|
+
|
|
256
|
+
if (index) {
|
|
257
|
+
const moat = new index();
|
|
258
|
+
pipeline.register('pre-input', 'prompt-injection', (text) => {
|
|
259
|
+
const r = moat.scanInbound(text);
|
|
260
|
+
return r;
|
|
261
|
+
}, { priority: 10 });
|
|
262
|
+
|
|
263
|
+
pipeline.register('pre-output', 'secret-pii-leak', (text) => {
|
|
264
|
+
const r = moat.scanOutbound(text);
|
|
265
|
+
return r;
|
|
266
|
+
}, { priority: 10 });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (obfuscation) {
|
|
270
|
+
pipeline.register('pre-input', 'obfuscation', (text) => {
|
|
271
|
+
return obfuscation.scanObfuscation(text);
|
|
272
|
+
}, { priority: 5 }); // Run before injection detection (strip first)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (codeScanner) {
|
|
276
|
+
pipeline.register('pre-tool-call', 'dangerous-code', (text, ctx) => {
|
|
277
|
+
return codeScanner.scanCode(text, { tool: ctx.tool });
|
|
278
|
+
}, { priority: 20 });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Post-tool-result: check for injected instructions in tool output
|
|
282
|
+
if (index) {
|
|
283
|
+
const moat = new index();
|
|
284
|
+
pipeline.register('post-tool-result', 'tool-result-injection', (text) => {
|
|
285
|
+
return moat.scanInbound(text);
|
|
286
|
+
}, { priority: 10 });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return pipeline;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
createPipeline,
|
|
294
|
+
createDefaultPipeline,
|
|
295
|
+
STAGES,
|
|
296
|
+
};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawMoat CI Scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans a repo for agent security issues before deployment:
|
|
5
|
+
* - Leaked secrets in tracked files
|
|
6
|
+
* - Dangerous MCP server configs
|
|
7
|
+
* - Unsafe CI/CD workflow patterns
|
|
8
|
+
* - Known compromised dependency versions
|
|
9
|
+
* - Agent config files with excessive permissions
|
|
10
|
+
*
|
|
11
|
+
* @module ci-scanner
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const KNOWN_COMPROMISED = [
|
|
20
|
+
{ pkg: 'telnyx', versions: ['4.87.1', '4.87.2'], cve: 'TeamPCP supply chain (Mar 2026)', severity: 'critical' },
|
|
21
|
+
{ pkg: 'event-stream', versions: ['3.3.6'], cve: 'CVE-2018-21270', severity: 'critical' },
|
|
22
|
+
{ pkg: 'ua-parser-js', versions: ['0.7.29', '0.7.30', '1.0.0', '1.0.1'], cve: 'CVE-2021-41265', severity: 'critical' },
|
|
23
|
+
{ pkg: 'coa', versions: ['2.0.3', '2.0.4'], cve: 'CVE-2021-43789', severity: 'critical' },
|
|
24
|
+
{ pkg: 'rc', versions: ['1.2.9'], cve: 'CVE-2021-43790', severity: 'critical' },
|
|
25
|
+
{ pkg: 'node-ipc', versions: ['10.1.1', '10.1.2', '11.1.0'], cve: 'CVE-2022-23812', severity: 'critical' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Patterns that indicate secrets in source files
|
|
29
|
+
const SECRET_PATTERNS = [
|
|
30
|
+
{ re: /(?:^|[\s'"=])(sk-[a-zA-Z0-9]{20,})/, name: 'OpenAI API key', severity: 'critical' },
|
|
31
|
+
{ re: /(?:^|[\s'"=])(sk-proj-[a-zA-Z0-9]{40,})/, name: 'OpenAI project key', severity: 'critical' },
|
|
32
|
+
{ re: /(?:^|[\s'"=])(ghp_[a-zA-Z0-9]{36})/, name: 'GitHub Personal Access Token', severity: 'critical' },
|
|
33
|
+
{ re: /(?:^|[\s'"=])(github_pat_[a-zA-Z0-9_]{82})/, name: 'GitHub fine-grained token', severity: 'critical' },
|
|
34
|
+
{ re: /(?:^|[\s'"=])(AKIA[0-9A-Z]{16})/, name: 'AWS Access Key ID', severity: 'critical' },
|
|
35
|
+
{ re: /AWS_SECRET_ACCESS_KEY\s*[=:]\s*([A-Za-z0-9/+=]{40})/, name: 'AWS Secret Access Key', severity: 'critical' },
|
|
36
|
+
{ re: /(?:^|[\s'"=])(xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+)/, name: 'Slack bot token', severity: 'critical' },
|
|
37
|
+
{ re: /(?:^|[\s'"=])(xoxp-[0-9]+-[0-9]+-[a-zA-Z0-9]+)/, name: 'Slack user token', severity: 'critical' },
|
|
38
|
+
{ re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, name: 'Private key', severity: 'critical' },
|
|
39
|
+
{ re: /(?:password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]/, name: 'Hardcoded password', severity: 'high' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// CI/CD workflow risk patterns
|
|
43
|
+
const CI_RISK_PATTERNS = [
|
|
44
|
+
{ re: /\$\{\{\s*github\.event\.(?:issue|pull_request|comment)\.(?:title|body)\s*\}\}/, name: 'Untrusted PR/issue data in workflow', severity: 'critical' },
|
|
45
|
+
{ re: /\$\{\{\s*github\.event\.head_commit\.message\s*\}\}/, name: 'Untrusted commit message in workflow', severity: 'high' },
|
|
46
|
+
{ re: /uses:\s*[a-z0-9-]+\/[a-z0-9-]+@(?:main|master|latest|HEAD)/, name: 'Unpinned GitHub Action (use @SHA)', severity: 'medium' },
|
|
47
|
+
{ re: /curl\s+.*\|\s*(?:bash|sh)/, name: 'Curl pipe to shell in workflow', severity: 'critical' },
|
|
48
|
+
{ re: /run:\s*echo\s+\$\{\{/, name: 'Unsanitized expression in echo', severity: 'high' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Extensions to scan for secrets
|
|
52
|
+
const SCANNABLE_EXTENSIONS = new Set([
|
|
53
|
+
'.js', '.ts', '.py', '.rb', '.go', '.java', '.cs', '.php',
|
|
54
|
+
'.env', '.yaml', '.yml', '.json', '.toml', '.ini', '.cfg',
|
|
55
|
+
'.sh', '.bash', '.zsh', '.fish',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Files/dirs to always skip
|
|
59
|
+
const SKIP_DIRS = new Set([
|
|
60
|
+
'node_modules', '.git', '.svn', '__pycache__', '.pytest_cache',
|
|
61
|
+
'dist', 'build', 'coverage', '.nyc_output', 'vendor',
|
|
62
|
+
'.venv', 'venv', 'env',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scan a repository for security issues
|
|
67
|
+
* @param {Object} [opts] - Options
|
|
68
|
+
* @param {string} [opts.rootDir='.'] - Root directory to scan
|
|
69
|
+
* @param {boolean} [opts.checkDeps=true] - Check package.json for known-bad deps
|
|
70
|
+
* @param {boolean} [opts.checkSecrets=true] - Scan files for leaked secrets
|
|
71
|
+
* @param {boolean} [opts.checkCI=true] - Scan CI/CD workflows
|
|
72
|
+
* @param {boolean} [opts.checkMCP=true] - Scan MCP configs
|
|
73
|
+
* @param {string} [opts.failOn='high'] - Severity to fail CI on
|
|
74
|
+
* @returns {Object} { findings, summary, passed }
|
|
75
|
+
*/
|
|
76
|
+
function scanRepo(opts = {}) {
|
|
77
|
+
const {
|
|
78
|
+
rootDir = process.cwd(),
|
|
79
|
+
checkDeps = true,
|
|
80
|
+
checkSecrets = true,
|
|
81
|
+
checkCI = true,
|
|
82
|
+
checkMCP = true,
|
|
83
|
+
failOn = 'high',
|
|
84
|
+
} = opts;
|
|
85
|
+
|
|
86
|
+
const findings = [];
|
|
87
|
+
|
|
88
|
+
// 1. Check package.json for compromised deps
|
|
89
|
+
if (checkDeps) {
|
|
90
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
91
|
+
if (fs.existsSync(pkgPath)) {
|
|
92
|
+
try {
|
|
93
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
94
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
95
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
96
|
+
const cleanVer = version.replace(/^[^0-9]*/, '');
|
|
97
|
+
const compromised = KNOWN_COMPROMISED.find(c =>
|
|
98
|
+
c.pkg === name && c.versions.includes(cleanVer)
|
|
99
|
+
);
|
|
100
|
+
if (compromised) {
|
|
101
|
+
findings.push({
|
|
102
|
+
type: 'compromised_dependency',
|
|
103
|
+
severity: compromised.severity,
|
|
104
|
+
file: 'package.json',
|
|
105
|
+
evidence: `${name}@${cleanVer} — ${compromised.cve}`,
|
|
106
|
+
fix: `Update ${name} to latest safe version`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check requirements.txt
|
|
114
|
+
const reqPath = path.join(rootDir, 'requirements.txt');
|
|
115
|
+
if (fs.existsSync(reqPath)) {
|
|
116
|
+
const content = fs.readFileSync(reqPath, 'utf8');
|
|
117
|
+
for (const line of content.split('\n')) {
|
|
118
|
+
const match = line.match(/^([a-zA-Z0-9_-]+)==([0-9.]+)/);
|
|
119
|
+
if (match) {
|
|
120
|
+
const [, name, version] = match;
|
|
121
|
+
const compromised = KNOWN_COMPROMISED.find(c =>
|
|
122
|
+
c.pkg === name.toLowerCase() && c.versions.includes(version)
|
|
123
|
+
);
|
|
124
|
+
if (compromised) {
|
|
125
|
+
findings.push({
|
|
126
|
+
type: 'compromised_dependency',
|
|
127
|
+
severity: compromised.severity,
|
|
128
|
+
file: 'requirements.txt',
|
|
129
|
+
evidence: `${name}==${version} — ${compromised.cve}`,
|
|
130
|
+
fix: `Update ${name} to latest safe version`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Scan source files for secrets
|
|
139
|
+
if (checkSecrets) {
|
|
140
|
+
const secretFindings = scanDirForSecrets(rootDir);
|
|
141
|
+
findings.push(...secretFindings);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 3. Scan CI/CD workflows
|
|
145
|
+
if (checkCI) {
|
|
146
|
+
const workflowDir = path.join(rootDir, '.github', 'workflows');
|
|
147
|
+
if (fs.existsSync(workflowDir)) {
|
|
148
|
+
const files = fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
const content = fs.readFileSync(path.join(workflowDir, file), 'utf8');
|
|
151
|
+
for (const pattern of CI_RISK_PATTERNS) {
|
|
152
|
+
if (pattern.re.test(content)) {
|
|
153
|
+
findings.push({
|
|
154
|
+
type: 'ci_risk',
|
|
155
|
+
severity: pattern.severity,
|
|
156
|
+
file: `.github/workflows/${file}`,
|
|
157
|
+
evidence: pattern.name,
|
|
158
|
+
fix: getCI_Fix(pattern.name),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Scan MCP configs
|
|
167
|
+
if (checkMCP) {
|
|
168
|
+
try {
|
|
169
|
+
const { scanMCP } = require('./mcp-scanner');
|
|
170
|
+
const mcpReport = scanMCP({ quiet: true });
|
|
171
|
+
for (const f of mcpReport.findings) {
|
|
172
|
+
findings.push({
|
|
173
|
+
type: 'mcp_config',
|
|
174
|
+
severity: f.severity,
|
|
175
|
+
file: f.configName || 'mcp-config',
|
|
176
|
+
evidence: `${f.label} (${f.server})`,
|
|
177
|
+
fix: f.fix,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} catch (_) {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Compute summary
|
|
184
|
+
const summary = {
|
|
185
|
+
total: findings.length,
|
|
186
|
+
critical: findings.filter(f => f.severity === 'critical').length,
|
|
187
|
+
high: findings.filter(f => f.severity === 'high').length,
|
|
188
|
+
medium: findings.filter(f => f.severity === 'medium').length,
|
|
189
|
+
low: findings.filter(f => f.severity === 'low').length,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const severityRank = { critical: 4, high: 3, medium: 2, low: 1, none: 0 };
|
|
193
|
+
const failRank = severityRank[failOn] || 3;
|
|
194
|
+
const passed = !findings.some(f => (severityRank[f.severity] || 0) >= failRank);
|
|
195
|
+
|
|
196
|
+
return { findings, summary, passed, rootDir };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function scanDirForSecrets(dir, depth = 0) {
|
|
200
|
+
if (depth > 8) return [];
|
|
201
|
+
const findings = [];
|
|
202
|
+
|
|
203
|
+
let entries;
|
|
204
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
205
|
+
catch (_) { return []; }
|
|
206
|
+
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
if (entry.name.startsWith('.') && entry.name !== '.env') continue;
|
|
209
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
210
|
+
|
|
211
|
+
const fullPath = path.join(dir, entry.name);
|
|
212
|
+
|
|
213
|
+
if (entry.isDirectory()) {
|
|
214
|
+
findings.push(...scanDirForSecrets(fullPath, depth + 1));
|
|
215
|
+
} else if (entry.isFile()) {
|
|
216
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
217
|
+
const isEnvFile = entry.name === '.env' || entry.name.startsWith('.env.');
|
|
218
|
+
|
|
219
|
+
if (isEnvFile) {
|
|
220
|
+
// Flag .env files committed to repo
|
|
221
|
+
findings.push({
|
|
222
|
+
type: 'committed_env_file',
|
|
223
|
+
severity: 'high',
|
|
224
|
+
file: path.relative(process.cwd(), fullPath),
|
|
225
|
+
evidence: '.env file committed to repository',
|
|
226
|
+
fix: 'Add .env to .gitignore and rotate any exposed secrets',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!SCANNABLE_EXTENSIONS.has(ext) && !isEnvFile) continue;
|
|
231
|
+
|
|
232
|
+
let content;
|
|
233
|
+
try { content = fs.readFileSync(fullPath, 'utf8'); }
|
|
234
|
+
catch (_) { continue; }
|
|
235
|
+
|
|
236
|
+
// Skip if file is too large (> 500KB)
|
|
237
|
+
if (content.length > 512000) continue;
|
|
238
|
+
|
|
239
|
+
const relPath = path.relative(process.cwd(), fullPath);
|
|
240
|
+
|
|
241
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
242
|
+
const match = pattern.re.exec(content);
|
|
243
|
+
if (match) {
|
|
244
|
+
const secret = match[1] || match[0];
|
|
245
|
+
const redacted = secret.length > 8
|
|
246
|
+
? secret.substring(0, 4) + '*'.repeat(Math.min(secret.length - 8, 20)) + secret.substring(secret.length - 4)
|
|
247
|
+
: '****';
|
|
248
|
+
findings.push({
|
|
249
|
+
type: 'leaked_secret',
|
|
250
|
+
severity: pattern.severity,
|
|
251
|
+
file: relPath,
|
|
252
|
+
evidence: `${pattern.name}: ${redacted}`,
|
|
253
|
+
fix: 'Remove secret from file, add to .gitignore, rotate the credential',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return findings;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getCI_Fix(patternName) {
|
|
264
|
+
const fixes = {
|
|
265
|
+
'Untrusted PR/issue data in workflow': 'Use an intermediate env var with sanitization, or use github.event_name check',
|
|
266
|
+
'Unpinned GitHub Action (use @SHA)': 'Pin to a full SHA: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683',
|
|
267
|
+
'Curl pipe to shell in workflow': 'Download script, verify checksum, then execute separately',
|
|
268
|
+
'Unsanitized expression in echo': 'Use $GITHUB_ENV or intermediate env var to prevent log injection',
|
|
269
|
+
};
|
|
270
|
+
return fixes[patternName] || 'Review and remediate the risk';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
scanRepo,
|
|
275
|
+
scanDirForSecrets,
|
|
276
|
+
KNOWN_COMPROMISED,
|
|
277
|
+
SECRET_PATTERNS,
|
|
278
|
+
CI_RISK_PATTERNS,
|
|
279
|
+
};
|