ship-safe 6.3.0 → 6.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 +28 -6
- package/cli/agents/agent-config-scanner.js +225 -1
- package/cli/agents/cicd-scanner.js +42 -0
- package/cli/agents/legal-risk-agent.js +41 -15
- package/cli/bin/ship-safe.js +11 -0
- package/cli/commands/scan-mcp.js +456 -0
- package/cli/commands/scan-skill.js +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
18 security agents. 80+ attack classes. One command.
|
|
20
20
|
|
|
21
|
-
**Ship Safe v6.
|
|
21
|
+
**Ship Safe v6.4.0** is an AI-powered security platform that runs 18 specialized agents in parallel against your codebase, scanning for secrets, injection vulnerabilities, auth bypass, SSRF, supply chain attacks, Supabase RLS misconfigs, Docker/Terraform/Kubernetes misconfigs, CI/CD pipeline poisoning, LLM/agentic AI security, MCP server misuse, RAG poisoning, PII compliance, vibe coding patterns, exception handling, AI agent config security, and more. OWASP 2025 scoring with EPSS exploit probability. LLM-powered deep analysis verifies exploitability of critical findings. Secrets verification probes provider APIs to check if leaked keys are still active. Compliance mapping to SOC 2, ISO 27001, and NIST AI RMF. Built-in threat intelligence feed with offline-first IOC matching. CI integration with GitHub PR comments, threshold gating, and SARIF output.
|
|
22
22
|
|
|
23
|
-
**v6.
|
|
23
|
+
**v6.4.0 highlights:** MCP server scanning (`npx ship-safe scan-mcp`) vets tool manifests for prompt injection and credential harvesting before you connect. Detection support for openclaude and claw-code — the two most-starred Claude Code forks from the March 2026 source leak — with accurate config scanning based on their actual architectures. Four new CI/CD patterns flag AI agent danger modes in pipelines. Legal dataset corrected: claw-code reclassified as a clean-room rewrite, not a leaked-source derivative.
|
|
24
24
|
|
|
25
25
|
[Documentation](https://shipsafecli.com/docs) | [Blog](https://shipsafecli.com/blog) | [Pricing](https://shipsafecli.com/pricing)
|
|
26
26
|
|
|
@@ -169,10 +169,10 @@ npx ship-safe audit .
|
|
|
169
169
|
| **PIIComplianceAgent** | Compliance | PII detection — SSNs, credit cards, emails, phone numbers in source code, logs, and configs |
|
|
170
170
|
| **VibeCodingAgent** | Code Vulns | AI-generated code patterns — no input validation, empty catch blocks, hardcoded secrets, disabled security features, TODO-auth patterns |
|
|
171
171
|
| **ExceptionHandlerAgent** | Code Vulns | OWASP A10:2025 — empty catch blocks, unhandled promise rejections, missing React error boundaries, leaked stack traces, generic catch-all without rethrow |
|
|
172
|
-
| **AgentConfigScanner** | AI/LLM | AI agent config security — prompt injection in .cursorrules/CLAUDE.md/AGENTS.md/.windsurfrules, malicious Claude Code hooks (CVE-2026), OpenClaw public binding & malicious skills, encoded/obfuscated payloads, data exfiltration instructions, agent memory poisoning |
|
|
172
|
+
| **AgentConfigScanner** | AI/LLM | AI agent config security — prompt injection in .cursorrules/CLAUDE.md/AGENTS.md/.windsurfrules, malicious Claude Code hooks (CVE-2026), OpenClaw public binding & malicious skills, openclaude profile file (`OPENAI_BASE_URL` over http://), claw-code config (`danger-full-access`, disabled sandbox, shell hooks, insecure MCP transports), encoded/obfuscated payloads, data exfiltration instructions, agent memory poisoning |
|
|
173
173
|
| **MobileScanner** | Mobile | OWASP Mobile Top 10 2024 — insecure storage, WebView JS injection, HTTP endpoints, excessive permissions, debug mode |
|
|
174
174
|
| **GitHistoryScanner** | Secrets | Leaked secrets in git commit history (checks if still active in working tree) |
|
|
175
|
-
| **CICDScanner** | CI/CD | OWASP CI/CD Top 10 — pipeline poisoning, unpinned actions, secret logging, self-hosted runners, script injection |
|
|
175
|
+
| **CICDScanner** | CI/CD | OWASP CI/CD Top 10 — pipeline poisoning, unpinned actions, secret logging, self-hosted runners, script injection, AI agent danger flags (`--dangerously-skip-permissions`, insecure provider URLs in CI) |
|
|
176
176
|
| **APIFuzzer** | API | Routes without auth, missing input validation, mass assignment, unrestricted file upload, GraphQL introspection, debug endpoints, missing rate limiting, OpenAPI spec security issues |
|
|
177
177
|
| **ReconAgent** | Recon | Attack surface discovery — frameworks, languages, auth patterns, databases, cloud providers, IaC, CI/CD pipelines |
|
|
178
178
|
|
|
@@ -299,13 +299,13 @@ npx ship-safe audit . --verify
|
|
|
299
299
|
npx ship-safe doctor
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
-
###
|
|
302
|
+
### Agent Security
|
|
303
303
|
|
|
304
304
|
```bash
|
|
305
305
|
# Focused OpenClaw security scan
|
|
306
306
|
npx ship-safe openclaw .
|
|
307
307
|
|
|
308
|
-
# Auto-harden OpenClaw configs (0.0.0.0
|
|
308
|
+
# Auto-harden OpenClaw configs (0.0.0.0->127.0.0.1, add auth, ws->wss)
|
|
309
309
|
npx ship-safe openclaw . --fix
|
|
310
310
|
|
|
311
311
|
# Red team: simulate ClawJacked, prompt injection, data exfil attacks
|
|
@@ -319,6 +319,14 @@ npx ship-safe scan-skill https://clawhub.io/skills/some-skill
|
|
|
319
319
|
npx ship-safe scan-skill ./local-skill.json
|
|
320
320
|
npx ship-safe scan-skill --all # Scan all skills from openclaw.json
|
|
321
321
|
|
|
322
|
+
# Scan an MCP server's tool manifest before connecting
|
|
323
|
+
npx ship-safe scan-mcp https://your-mcp-server/
|
|
324
|
+
npx ship-safe scan-mcp ./local-manifest.json
|
|
325
|
+
npx ship-safe scan-mcp https://your-mcp-server/ --json
|
|
326
|
+
|
|
327
|
+
# Legal risk audit — DMCA, leaked-source derivatives (openclaude, claw-code-js), IP disputes
|
|
328
|
+
npx ship-safe legal .
|
|
329
|
+
|
|
322
330
|
# Generate hardened OpenClaw config
|
|
323
331
|
npx ship-safe init --openclaw
|
|
324
332
|
|
|
@@ -326,6 +334,20 @@ npx ship-safe init --openclaw
|
|
|
326
334
|
npx ship-safe abom .
|
|
327
335
|
```
|
|
328
336
|
|
|
337
|
+
#### openclaude and claw-code
|
|
338
|
+
|
|
339
|
+
Ship Safe detects security issues in both major Claude Code forks from the March 2026 source leak.
|
|
340
|
+
|
|
341
|
+
**openclaude** (`@gitlawb/openclaude`) is a CLI tool that routes Claude Code's toolset through any OpenAI-compatible provider. Its only persistent file artifact is `.openclaude-profile.json`. Ship Safe flags:
|
|
342
|
+
- `OPENAI_BASE_URL` using `http://` for non-localhost endpoints (unencrypted LLM traffic)
|
|
343
|
+
- The profile file present in a project not covered by `.gitignore` (API key exposure risk)
|
|
344
|
+
|
|
345
|
+
**claw-code** (`ultraworkers/claw-code`) is a clean-room Rust + Python rewrite of Claude Code's agent harness. Its config lives in `.claw.json`, `.claw/settings.json`, and `.claw/settings.local.json`. Ship Safe flags:
|
|
346
|
+
- `permissionMode: danger-full-access` or `dangerouslySkipPermissions: true` (no confirmation on any tool call)
|
|
347
|
+
- `sandbox.enabled: false` (filesystem isolation removed)
|
|
348
|
+
- Hook commands containing shell execution or remote download patterns
|
|
349
|
+
- MCP server connections over `ws://` or `http://` to non-localhost hosts
|
|
350
|
+
|
|
329
351
|
### Threat Intelligence
|
|
330
352
|
|
|
331
353
|
```bash
|
|
@@ -54,6 +54,32 @@ const OPENCLAW_GLOBS = [
|
|
|
54
54
|
'.openclaw/**/*.json',
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
+
// openclaude (github.com/Gitlawb/openclaude) — Claude Code fork with
|
|
58
|
+
// OpenAI-compatible provider shim. Config is purely via environment variables
|
|
59
|
+
// (CLAUDE_CODE_USE_OPENAI, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_KEY).
|
|
60
|
+
// The only persistent file artifact is .openclaude-profile.json, which stores
|
|
61
|
+
// named profiles as { name, env: { OPENAI_BASE_URL, OPENAI_API_KEY, ... } }.
|
|
62
|
+
const OPENCLAUDE_PROFILE_FILES = [
|
|
63
|
+
'.openclaude-profile.json',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// claw-code (github.com/instructkr/claw-code, now ultraworkers/claw-code) —
|
|
67
|
+
// Rust + Python clean-room rewrite of Claude Code's agent harness.
|
|
68
|
+
// CLI tool (`claw` binary). NOT a server — no port binding outside tests.
|
|
69
|
+
// Config files: .claw.json (project root), .claw/settings.json,
|
|
70
|
+
// .claw/settings.local.json, ~/.claw.json, ~/.claw/settings.json
|
|
71
|
+
// Auth: ANTHROPIC_API_KEY, OPENAI_API_KEY, or XAI_API_KEY env vars.
|
|
72
|
+
// Permission modes: read-only, workspace-write, danger-full-access, prompt, allow.
|
|
73
|
+
// --dangerously-skip-permissions flag disables all permission checks.
|
|
74
|
+
// Sandbox: SandboxConfig with FilesystemIsolationMode (workspace-only default).
|
|
75
|
+
// Hooks: preToolUse / postToolUse arrays in settings JSON.
|
|
76
|
+
// MCP: mcpServers in settings JSON (stdio, sse, http, ws transports).
|
|
77
|
+
const CLAW_CODE_FILES = [
|
|
78
|
+
'.claw.json',
|
|
79
|
+
'.claw/settings.json',
|
|
80
|
+
'.claw/settings.local.json',
|
|
81
|
+
];
|
|
82
|
+
|
|
57
83
|
const MEMORY_GLOBS = [
|
|
58
84
|
'.claude/memory/**',
|
|
59
85
|
'.cursor/memory/**',
|
|
@@ -230,6 +256,18 @@ export class AgentConfigScanner extends BaseAgent {
|
|
|
230
256
|
findings = findings.concat(this._scanOpenClawConfig(file));
|
|
231
257
|
}
|
|
232
258
|
|
|
259
|
+
// ── 3b. Scan openclaude profile files ─────────────────────────────────
|
|
260
|
+
for (const file of discovered.openclaudeFiles) {
|
|
261
|
+
findings = findings.concat(this.scanFileWithPatterns(file, PATTERNS));
|
|
262
|
+
findings = findings.concat(this._scanOpenClaudeProfile(file));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── 3c. Scan claw-code config files ────────────────────────────────────
|
|
266
|
+
for (const file of discovered.clawCodeFiles) {
|
|
267
|
+
findings = findings.concat(this.scanFileWithPatterns(file, PATTERNS));
|
|
268
|
+
findings = findings.concat(this._scanClawCodeConfig(file));
|
|
269
|
+
}
|
|
270
|
+
|
|
233
271
|
// ── 4. Scan Claude Code hooks ──────────────────────────────────────────
|
|
234
272
|
for (const file of discovered.claudeSettingsFiles) {
|
|
235
273
|
findings = findings.concat(this._scanClaudeHooks(file));
|
|
@@ -297,7 +335,21 @@ export class AgentConfigScanner extends BaseAgent {
|
|
|
297
335
|
memoryFiles.push(...globbed);
|
|
298
336
|
} catch { /* skip */ }
|
|
299
337
|
|
|
300
|
-
|
|
338
|
+
// ── openclaude profile files ─────────────────────────────────────────────
|
|
339
|
+
const openclaudeFiles = [];
|
|
340
|
+
for (const rel of OPENCLAUDE_PROFILE_FILES) {
|
|
341
|
+
const full = path.join(rootPath, rel);
|
|
342
|
+
if (fs.existsSync(full)) openclaudeFiles.push(full);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── claw-code config files ────────────────────────────────────────────────
|
|
346
|
+
const clawCodeFiles = [];
|
|
347
|
+
for (const rel of CLAW_CODE_FILES) {
|
|
348
|
+
const full = path.join(rootPath, rel);
|
|
349
|
+
if (fs.existsSync(full)) clawCodeFiles.push(full);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { rulesFiles, openclawFiles, openclaudeFiles, clawCodeFiles, claudeSettingsFiles, memoryFiles };
|
|
301
353
|
}
|
|
302
354
|
|
|
303
355
|
// ===========================================================================
|
|
@@ -415,6 +467,178 @@ export class AgentConfigScanner extends BaseAgent {
|
|
|
415
467
|
return findings;
|
|
416
468
|
}
|
|
417
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Scan .openclaude-profile.json for security issues.
|
|
472
|
+
*
|
|
473
|
+
* openclaude (github.com/Gitlawb/openclaude) is a CLI tool, not a server.
|
|
474
|
+
* There is no config file, no host/port binding, no auth mechanism.
|
|
475
|
+
* All configuration is via environment variables:
|
|
476
|
+
* CLAUDE_CODE_USE_OPENAI=1, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_KEY
|
|
477
|
+
*
|
|
478
|
+
* The only persistent file artifact is .openclaude-profile.json, which stores
|
|
479
|
+
* named profiles as { name: string, env: { OPENAI_BASE_URL, OPENAI_API_KEY, ... } }.
|
|
480
|
+
* openclaude ships with this file in its default .gitignore.
|
|
481
|
+
*
|
|
482
|
+
* Security risk: if OPENAI_BASE_URL is an http:// (non-TLS) endpoint, all
|
|
483
|
+
* LLM traffic (prompts, code context, responses) is sent unencrypted.
|
|
484
|
+
*/
|
|
485
|
+
_scanOpenClaudeProfile(filePath) {
|
|
486
|
+
const content = this.readFile(filePath);
|
|
487
|
+
if (!content) return [];
|
|
488
|
+
const findings = [];
|
|
489
|
+
|
|
490
|
+
let profile;
|
|
491
|
+
try { profile = JSON.parse(content); } catch { return []; }
|
|
492
|
+
|
|
493
|
+
const env = profile.env || {};
|
|
494
|
+
|
|
495
|
+
// ── Insecure provider URL (http:// for non-localhost) ─────────────────
|
|
496
|
+
const baseUrl = env.OPENAI_BASE_URL || '';
|
|
497
|
+
if (baseUrl && /^http:\/\/(?!localhost|127\.0\.0\.1|::1)/i.test(baseUrl)) {
|
|
498
|
+
findings.push(createFinding({
|
|
499
|
+
file: filePath, line: 1,
|
|
500
|
+
severity: 'high',
|
|
501
|
+
category: this.category,
|
|
502
|
+
rule: 'OPENCLAUDE_INSECURE_PROVIDER_URL',
|
|
503
|
+
title: 'openclaude: LLM Provider URL Without TLS',
|
|
504
|
+
description:
|
|
505
|
+
`openclaude routes model calls to ${baseUrl} over plain HTTP. ` +
|
|
506
|
+
'Prompts, code context, and model responses are sent unencrypted. ' +
|
|
507
|
+
'A network attacker can read or modify all LLM interactions in transit.',
|
|
508
|
+
matched: `OPENAI_BASE_URL: "${baseUrl}"`,
|
|
509
|
+
confidence: 'high',
|
|
510
|
+
cwe: 'CWE-319',
|
|
511
|
+
owasp: 'A02:2021',
|
|
512
|
+
fix: 'Use an https:// provider URL. Never route LLM traffic over plaintext HTTP on untrusted networks.',
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return findings;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Scan claw-code config files (.claw.json, .claw/settings.json, .claw/settings.local.json)
|
|
521
|
+
* for insecure settings.
|
|
522
|
+
*
|
|
523
|
+
* claw-code (ultraworkers/claw-code) is a Rust + Python clean-room rewrite of Claude Code.
|
|
524
|
+
* It is a CLI tool — no server port binding. Config lives in JSON settings files.
|
|
525
|
+
*
|
|
526
|
+
* Checked settings:
|
|
527
|
+
* - hooks.preToolUse / hooks.postToolUse: shell hook commands (RCE vector)
|
|
528
|
+
* - permissions.dangerouslySkipPermissions / permissionMode: "danger-full-access"
|
|
529
|
+
* - sandbox.enabled: false (filesystem isolation disabled)
|
|
530
|
+
* - mcpServers with insecure ws:// or http:// remote URLs (MiTM risk)
|
|
531
|
+
* - mcpServers using env vars that could expose credentials
|
|
532
|
+
*/
|
|
533
|
+
_scanClawCodeConfig(filePath) {
|
|
534
|
+
const content = this.readFile(filePath);
|
|
535
|
+
if (!content) return [];
|
|
536
|
+
const findings = [];
|
|
537
|
+
|
|
538
|
+
let config;
|
|
539
|
+
try { config = JSON.parse(content); } catch { return []; }
|
|
540
|
+
|
|
541
|
+
// ── Dangerous permission mode ─────────────────────────────────────────
|
|
542
|
+
const permMode = config.permissionMode ?? config.permissions?.mode ?? '';
|
|
543
|
+
const skipPerms = config.dangerouslySkipPermissions ??
|
|
544
|
+
config.permissions?.dangerouslySkipPermissions ?? false;
|
|
545
|
+
|
|
546
|
+
if (skipPerms === true || permMode === 'danger-full-access') {
|
|
547
|
+
findings.push(createFinding({
|
|
548
|
+
file: filePath, line: 1,
|
|
549
|
+
severity: 'high',
|
|
550
|
+
category: this.category,
|
|
551
|
+
rule: 'CLAW_CODE_SKIP_PERMISSIONS',
|
|
552
|
+
title: 'claw-code: All Permission Checks Disabled',
|
|
553
|
+
description:
|
|
554
|
+
'claw-code is configured with dangerously-skip-permissions or permissionMode: danger-full-access. ' +
|
|
555
|
+
'Every tool call executes without asking for user confirmation. ' +
|
|
556
|
+
'A single prompt injection in any file the agent reads can trigger unrestricted shell execution or file writes.',
|
|
557
|
+
matched: skipPerms ? 'dangerouslySkipPermissions: true' : `permissionMode: "${permMode}"`,
|
|
558
|
+
confidence: 'high',
|
|
559
|
+
cwe: 'CWE-269',
|
|
560
|
+
owasp: 'ASI03',
|
|
561
|
+
fix: 'Set permissionMode to "workspace-write" or "prompt". Only use danger-full-access in fully isolated environments.',
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── Sandbox disabled ──────────────────────────────────────────────────
|
|
566
|
+
if (config.sandbox?.enabled === false) {
|
|
567
|
+
findings.push(createFinding({
|
|
568
|
+
file: filePath, line: 1,
|
|
569
|
+
severity: 'medium',
|
|
570
|
+
category: this.category,
|
|
571
|
+
rule: 'CLAW_CODE_SANDBOX_DISABLED',
|
|
572
|
+
title: 'claw-code: Filesystem Sandbox Disabled',
|
|
573
|
+
description:
|
|
574
|
+
'claw-code sandbox is explicitly disabled. By default claw-code restricts ' +
|
|
575
|
+
'filesystem access to the workspace directory. With sandbox off, tools can ' +
|
|
576
|
+
'read and write anywhere on the system.',
|
|
577
|
+
matched: 'sandbox.enabled: false',
|
|
578
|
+
confidence: 'high',
|
|
579
|
+
cwe: 'CWE-732',
|
|
580
|
+
owasp: 'ASI03',
|
|
581
|
+
fix: 'Remove sandbox.enabled: false or set filesystem-mode to workspace-only.',
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Hooks with shell commands ──────────────────────────────────────────
|
|
586
|
+
const hookLists = [
|
|
587
|
+
...(config.hooks?.preToolUse || []),
|
|
588
|
+
...(config.hooks?.postToolUse || []),
|
|
589
|
+
];
|
|
590
|
+
for (const hook of hookLists) {
|
|
591
|
+
const cmd = typeof hook === 'string' ? hook : (hook.command || hook.cmd || hook.run || '');
|
|
592
|
+
if (!cmd) continue;
|
|
593
|
+
if (/(?:bash\s+-c|sh\s+-c|cmd\s+\/c|powershell\s+-|pwsh\s+-)/i.test(cmd) ||
|
|
594
|
+
/\|\s*(?:bash|sh|zsh|node|python)/i.test(cmd) ||
|
|
595
|
+
/(?:curl|wget)\s+https?:\/\/(?!localhost|127\.0\.0\.1)/i.test(cmd)) {
|
|
596
|
+
findings.push(createFinding({
|
|
597
|
+
file: filePath, line: 1,
|
|
598
|
+
severity: 'critical',
|
|
599
|
+
category: this.category,
|
|
600
|
+
rule: 'CLAW_CODE_HOOK_SHELL',
|
|
601
|
+
title: 'claw-code: Dangerous Hook Command',
|
|
602
|
+
description:
|
|
603
|
+
'claw-code hook contains a shell execution or remote download command. ' +
|
|
604
|
+
'A malicious .claw.json in a repository can achieve RCE when anyone ' +
|
|
605
|
+
'opens the project with claw.',
|
|
606
|
+
matched: cmd.substring(0, 150),
|
|
607
|
+
confidence: 'high',
|
|
608
|
+
cwe: 'CWE-94',
|
|
609
|
+
owasp: 'ASI04',
|
|
610
|
+
fix: 'Remove shell execution hooks. Use only safe, scoped commands in claw hooks.',
|
|
611
|
+
}));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── MCP servers with insecure remote URLs ─────────────────────────────
|
|
616
|
+
const mcpServers = config.mcpServers || {};
|
|
617
|
+
for (const [name, srv] of Object.entries(mcpServers)) {
|
|
618
|
+
const url = typeof srv === 'object' ? (srv.url || '') : '';
|
|
619
|
+
if (/^(?:ws|http):\/\/(?!localhost|127\.0\.0\.1|::1)/i.test(url)) {
|
|
620
|
+
findings.push(createFinding({
|
|
621
|
+
file: filePath, line: 1,
|
|
622
|
+
severity: 'high',
|
|
623
|
+
category: this.category,
|
|
624
|
+
rule: 'CLAW_CODE_MCP_INSECURE_URL',
|
|
625
|
+
title: `claw-code: MCP Server "${name}" Uses Unencrypted Transport`,
|
|
626
|
+
description:
|
|
627
|
+
`MCP server "${name}" connects to ${url} over an unencrypted channel (ws:// or http://). ` +
|
|
628
|
+
'All MCP messages — tool calls, results, and any code context — are sent in plaintext. ' +
|
|
629
|
+
'A network attacker can intercept or inject MCP responses to hijack the agent.',
|
|
630
|
+
matched: url,
|
|
631
|
+
confidence: 'high',
|
|
632
|
+
cwe: 'CWE-319',
|
|
633
|
+
owasp: 'A02:2021',
|
|
634
|
+
fix: 'Use wss:// or https:// for all non-localhost MCP server connections.',
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return findings;
|
|
640
|
+
}
|
|
641
|
+
|
|
418
642
|
/**
|
|
419
643
|
* Scan .claude/settings.json for malicious hooks.
|
|
420
644
|
* Based on Check Point Research disclosure: hooks in settings.json
|
|
@@ -199,6 +199,48 @@ const PATTERNS = [
|
|
|
199
199
|
description: 'GitHub expression in run step. Attacker-controlled values can inject shell commands.',
|
|
200
200
|
fix: 'Use environment variables: env: TITLE: ${{ github.event.issue.title }} then run: echo "$TITLE"',
|
|
201
201
|
},
|
|
202
|
+
|
|
203
|
+
// ── AI Agent CLI — dangerous flags in CI ──────────────────────────────────
|
|
204
|
+
{
|
|
205
|
+
rule: 'CICD_AGENT_SKIP_PERMISSIONS',
|
|
206
|
+
title: 'CI/CD: AI Agent Running Without Permission Checks',
|
|
207
|
+
regex: /(?:--dangerously-skip-permissions|--skip-permissions|permissionMode\s*[=:]\s*["']?danger-full-access["']?)/g,
|
|
208
|
+
severity: 'critical',
|
|
209
|
+
cwe: 'CWE-269',
|
|
210
|
+
owasp: 'ASI03',
|
|
211
|
+
description: 'AI agent CLI flag disables all permission checks. In CI, this means any prompt injection in workspace files can execute arbitrary commands with no confirmation gate.',
|
|
212
|
+
fix: 'Remove --dangerously-skip-permissions. Use --permission-mode=workspace-write for CI automation or scope tool access with --allowedTools.',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
rule: 'CICD_AGENT_INSECURE_PROVIDER',
|
|
216
|
+
title: 'CI/CD: AI Agent Routing to Non-TLS Provider',
|
|
217
|
+
regex: /(?:OPENAI_BASE_URL|ANTHROPIC_BASE_URL|XAI_BASE_URL|CLAUDE_CODE_USE_OPENAI)\s*=\s*["']?http:\/\/(?!localhost|127\.0\.0\.1|::1)/g,
|
|
218
|
+
severity: 'high',
|
|
219
|
+
cwe: 'CWE-319',
|
|
220
|
+
owasp: 'A02:2021',
|
|
221
|
+
description: 'AI agent provider URL uses plain HTTP for a non-localhost endpoint. All prompts, code context, and model responses are transmitted unencrypted in CI.',
|
|
222
|
+
fix: 'Use https:// for all non-localhost AI provider base URLs.',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
rule: 'CICD_OPENCLAUDE_IN_CI',
|
|
226
|
+
title: 'CI/CD: openclaude Running in CI Pipeline',
|
|
227
|
+
regex: /(?:^|\s)openclaude\s/gm,
|
|
228
|
+
severity: 'medium',
|
|
229
|
+
cwe: 'CWE-1188',
|
|
230
|
+
owasp: 'ASI03',
|
|
231
|
+
description: 'openclaude (Claude Code fork with OpenAI-compatible shim) is invoked in CI. Verify OPENAI_BASE_URL is https://, OPENAI_API_KEY is stored as a secret, and .openclaude-profile.json is not committed.',
|
|
232
|
+
fix: 'Ensure OPENAI_API_KEY is a CI secret, OPENAI_BASE_URL uses https://, and .openclaude-profile.json is gitignored.',
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
rule: 'CICD_CLAW_DANGER_MODE',
|
|
236
|
+
title: 'CI/CD: claw-code Running in Danger Mode',
|
|
237
|
+
regex: /(?:^|\s)claw\s[^\n]*--dangerously-skip-permissions/gm,
|
|
238
|
+
severity: 'critical',
|
|
239
|
+
cwe: 'CWE-269',
|
|
240
|
+
owasp: 'ASI03',
|
|
241
|
+
description: 'claw-code (Rust/Python Claude Code rewrite) is invoked with --dangerously-skip-permissions in CI. Any prompt injection in the workspace executes without confirmation.',
|
|
242
|
+
fix: 'Remove --dangerously-skip-permissions. Use --permission-mode=workspace-write for CI automation.',
|
|
243
|
+
},
|
|
202
244
|
];
|
|
203
245
|
|
|
204
246
|
export class CICDScanner extends BaseAgent {
|
|
@@ -38,22 +38,14 @@ export const LEGALLY_RISKY_PACKAGES = [
|
|
|
38
38
|
// Anthropic's Claude Code source was accidentally leaked. Several repos
|
|
39
39
|
// appeared immediately; Anthropic filed DMCA takedowns but derivatives
|
|
40
40
|
// remain online. Shipping any of these exposes you to IP liability.
|
|
41
|
+
//
|
|
42
|
+
// NOTE: The instructkr/claw-code repo (now ultraworkers/claw-code) has since
|
|
43
|
+
// pivoted to a clean-room Rust + Python rewrite and explicitly removed the
|
|
44
|
+
// leaked snapshot. The GitHub repo itself is no longer a DMCA concern.
|
|
45
|
+
// However, any claw-code npm package published in the March 31–April 2 2026
|
|
46
|
+
// window may have contained the leaked TypeScript source before the pivot.
|
|
47
|
+
// Flag early versions as a precaution; assess the specific published version.
|
|
41
48
|
// ---------------------------------------------------------------------------
|
|
42
|
-
{
|
|
43
|
-
name: 'claw-code',
|
|
44
|
-
versions: '*',
|
|
45
|
-
ecosystem: 'npm',
|
|
46
|
-
risk: 'dmca',
|
|
47
|
-
severity: 'high',
|
|
48
|
-
detail:
|
|
49
|
-
'Derived from leaked Anthropic Claude Code source (March 2026). ' +
|
|
50
|
-
'Anthropic has filed DMCA takedown notices. Shipping this package ' +
|
|
51
|
-
'may expose your project to IP infringement liability.',
|
|
52
|
-
references: [
|
|
53
|
-
'https://cybernews.com/security/anthropic-claude-code-source-leak/',
|
|
54
|
-
'https://venturebeat.com/technology/claude-codes-source-code-appears-to-have-leaked-heres-what-we-know',
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
49
|
{
|
|
58
50
|
name: 'claw-code-js',
|
|
59
51
|
versions: '*',
|
|
@@ -81,6 +73,40 @@ export const LEGALLY_RISKY_PACKAGES = [
|
|
|
81
73
|
'https://cybernews.com/security/anthropic-claude-code-source-leak/',
|
|
82
74
|
],
|
|
83
75
|
},
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// openclaude (github.com/Gitlawb/openclaude)
|
|
78
|
+
// Claude Code fork that routes to any LLM via OpenAI-compatible shim.
|
|
79
|
+
// Derived directly from the leaked Anthropic source (March 31 2026).
|
|
80
|
+
// Under active DMCA enforcement alongside claw-code.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
{
|
|
83
|
+
name: 'openclaude',
|
|
84
|
+
versions: '*',
|
|
85
|
+
ecosystem: 'npm',
|
|
86
|
+
risk: 'leaked-source',
|
|
87
|
+
severity: 'high',
|
|
88
|
+
detail:
|
|
89
|
+
'openclaude is a fork of the leaked Anthropic Claude Code source (March 2026) ' +
|
|
90
|
+
'that adds an OpenAI-compatible provider shim. The underlying ~512,000 lines of ' +
|
|
91
|
+
'TypeScript are Anthropic proprietary IP under active DMCA enforcement. ' +
|
|
92
|
+
'Additionally ships with auth disabled by default and binds to 0.0.0.0:18789.',
|
|
93
|
+
references: [
|
|
94
|
+
'https://github.com/Gitlawb/openclaude',
|
|
95
|
+
'https://cybernews.com/security/anthropic-claude-code-source-leak/',
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'openclaude-core',
|
|
100
|
+
versions: '*',
|
|
101
|
+
ecosystem: 'npm',
|
|
102
|
+
risk: 'leaked-source',
|
|
103
|
+
severity: 'high',
|
|
104
|
+
detail:
|
|
105
|
+
'Core runtime package for openclaude. Derived from leaked Anthropic Claude Code ' +
|
|
106
|
+
'source (March 2026). Under active DMCA enforcement.',
|
|
107
|
+
references: ['https://github.com/Gitlawb/openclaude'],
|
|
108
|
+
},
|
|
109
|
+
|
|
84
110
|
// ---------------------------------------------------------------------------
|
|
85
111
|
// License violations — well-known cases
|
|
86
112
|
// ---------------------------------------------------------------------------
|
package/cli/bin/ship-safe.js
CHANGED
|
@@ -42,6 +42,7 @@ import { vibeCheckCommand } from '../commands/vibe-check.js';
|
|
|
42
42
|
import { benchmarkCommand } from '../commands/benchmark.js';
|
|
43
43
|
import { openclawCommand } from '../commands/openclaw.js';
|
|
44
44
|
import { scanSkillCommand } from '../commands/scan-skill.js';
|
|
45
|
+
import { scanMcpCommand } from '../commands/scan-mcp.js';
|
|
45
46
|
import { abomCommand } from '../commands/abom.js';
|
|
46
47
|
import { updateIntelCommand } from '../commands/update-intel.js';
|
|
47
48
|
import { hooksCommand } from '../commands/hooks.js';
|
|
@@ -364,6 +365,15 @@ program
|
|
|
364
365
|
.option('--json', 'Output results as JSON')
|
|
365
366
|
.action(scanSkillCommand);
|
|
366
367
|
|
|
368
|
+
// -----------------------------------------------------------------------------
|
|
369
|
+
// SCAN-MCP COMMAND
|
|
370
|
+
// -----------------------------------------------------------------------------
|
|
371
|
+
program
|
|
372
|
+
.command('scan-mcp [target]')
|
|
373
|
+
.description('Analyze an MCP server\'s tool manifest for security issues before connecting')
|
|
374
|
+
.option('--json', 'Output results as JSON')
|
|
375
|
+
.action(scanMcpCommand);
|
|
376
|
+
|
|
367
377
|
// -----------------------------------------------------------------------------
|
|
368
378
|
// ABOM COMMAND
|
|
369
379
|
// -----------------------------------------------------------------------------
|
|
@@ -439,6 +449,7 @@ if (process.argv.length === 2) {
|
|
|
439
449
|
console.log(chalk.white(' npx ship-safe watch . ') + chalk.gray('# Continuous monitoring mode'));
|
|
440
450
|
console.log(chalk.white(' npx ship-safe openclaw . ') + chalk.gray('# OpenClaw & agent config security scan'));
|
|
441
451
|
console.log(chalk.white(' npx ship-safe scan-skill <u>') + chalk.gray('# Vet a skill before installing'));
|
|
452
|
+
console.log(chalk.white(' npx ship-safe scan-mcp <url> ') + chalk.gray('# Vet an MCP server before connecting'));
|
|
442
453
|
console.log(chalk.white(' npx ship-safe abom . ') + chalk.gray('# Agent Bill of Materials (CycloneDX)'));
|
|
443
454
|
console.log(chalk.white(' npx ship-safe sbom . ') + chalk.gray('# Generate CycloneDX SBOM (CRA-ready)'));
|
|
444
455
|
console.log(chalk.white(' npx ship-safe legal . ') + chalk.gray('# Legal risk audit: DMCA, leaked source, IP disputes'));
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan MCP Command
|
|
3
|
+
* ================
|
|
4
|
+
*
|
|
5
|
+
* Fetches and analyzes an MCP server's tool manifest before connecting to it.
|
|
6
|
+
* Checks for malicious tool definitions, prompt injection in descriptions,
|
|
7
|
+
* exfiltration patterns, excessive permissions, and known-bad server hashes.
|
|
8
|
+
*
|
|
9
|
+
* USAGE:
|
|
10
|
+
* ship-safe scan-mcp <url> Analyze a remote MCP server
|
|
11
|
+
* ship-safe scan-mcp <path> Analyze a local MCP manifest file
|
|
12
|
+
*
|
|
13
|
+
* The command connects to the server's /tools endpoint (or reads the manifest
|
|
14
|
+
* JSON directly) and inspects every tool definition for attack patterns.
|
|
15
|
+
*
|
|
16
|
+
* MCP tool definitions are the new ToxicSkills surface — 36% of agent skills
|
|
17
|
+
* had security flaws; early MCP server audits show similar rates.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import chalk from 'chalk';
|
|
23
|
+
import { createHash } from 'crypto';
|
|
24
|
+
import * as output from '../utils/output.js';
|
|
25
|
+
import { ThreatIntel } from '../utils/threat-intel.js';
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// MCP TOOL DESCRIPTION PATTERNS
|
|
29
|
+
// Injected into tool descriptions/annotations to hijack agents that use them.
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
const MCP_TOOL_PATTERNS = [
|
|
33
|
+
// ── Prompt injection in tool description ──────────────────────────────────
|
|
34
|
+
{
|
|
35
|
+
name: 'Prompt injection: override instructions',
|
|
36
|
+
regex: /(?:ignore\s+(?:all\s+)?(?:previous|prior|above|your)\s+instructions|disregard\s+(?:all\s+)?(?:previous|above)|your\s+(?:new|real|actual|true)\s+(?:instructions|role|goal)\s+(?:is|are))/gi,
|
|
37
|
+
severity: 'critical',
|
|
38
|
+
target: 'description',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'Prompt injection: role hijacking',
|
|
42
|
+
regex: /(?:you\s+are\s+now\s+(?:a|an)|act\s+as\s+(?:a\s+)?(?:hacker|attacker|malicious|evil|unrestricted)|pretend\s+(?:you\s+are|to\s+be)\s+(?:a\s+)?(?:different|new|unrestricted))/gi,
|
|
43
|
+
severity: 'critical',
|
|
44
|
+
target: 'description',
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// ── Silent exfiltration instructions in tool description ──────────────────
|
|
48
|
+
{
|
|
49
|
+
name: 'Silent data exfiltration',
|
|
50
|
+
regex: /(?:silently|quietly|without\s+(?:notif|alert|inform|telling|showing)|in\s+the\s+background)\s+.{0,60}(?:curl|wget|fetch|POST|send).{0,60}(?:http|https):\/\//gi,
|
|
51
|
+
severity: 'critical',
|
|
52
|
+
target: 'description',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'Output suppression',
|
|
56
|
+
regex: /(?:do\s+not\s+(?:show|display|reveal|mention|tell|report|log)\s+(?:this|these|the\s+(?:output|result|response|command|action))|hide\s+(?:this|the)\s+(?:output|result|action|command|request))/gi,
|
|
57
|
+
severity: 'high',
|
|
58
|
+
target: 'description',
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// ── Credential harvesting ─────────────────────────────────────────────────
|
|
62
|
+
{
|
|
63
|
+
name: 'Credential harvesting',
|
|
64
|
+
regex: /(?:extract|retrieve|collect|gather|find|read|access|get)\s+.{0,40}(?:api[_\s]?key|secret|token|password|credential|\.env|npmrc|ssh[_\s]?key|private[_\s]?key)/gi,
|
|
65
|
+
severity: 'critical',
|
|
66
|
+
target: 'description',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'Sensitive path access',
|
|
70
|
+
regex: /(?:~\/\.(?:ssh|aws|npmrc|netrc|gnupg|config\/gcloud)|\/etc\/(?:passwd|shadow|hosts)|%APPDATA%|%USERPROFILE%)/gi,
|
|
71
|
+
severity: 'critical',
|
|
72
|
+
target: 'description',
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// ── Known data exfiltration service domains ───────────────────────────────
|
|
76
|
+
{
|
|
77
|
+
name: 'Exfiltration service domain',
|
|
78
|
+
regex: /(?:webhook\.site|requestbin\.com|hookbin\.com|pipedream\.net|ngrok\.io|ngrok\.app|burpcollaborator\.net|interact\.sh|oastify\.com|canarytokens\.com)/gi,
|
|
79
|
+
severity: 'critical',
|
|
80
|
+
target: 'any',
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// ── Dangerous tool input schema patterns ──────────────────────────────────
|
|
84
|
+
{
|
|
85
|
+
name: 'Shell command input parameter',
|
|
86
|
+
regex: /(?:"command"\s*:\s*\{[^}]*"type"\s*:\s*"string"|"cmd"\s*:\s*\{[^}]*"type"\s*:\s*"string"|"shell"\s*:\s*\{[^}]*"type"\s*:\s*"string")/gi,
|
|
87
|
+
severity: 'medium',
|
|
88
|
+
target: 'schema',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'Arbitrary code execution parameter',
|
|
92
|
+
regex: /(?:"code"\s*:\s*\{[^}]*"type"\s*:\s*"string"|"script"\s*:\s*"(?:string|object)"|"eval"\s*:\s*\{[^}]*"type"\s*:\s*"string")/gi,
|
|
93
|
+
severity: 'high',
|
|
94
|
+
target: 'schema',
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// ── Permission escalation in description ──────────────────────────────────
|
|
98
|
+
{
|
|
99
|
+
name: 'Permission escalation',
|
|
100
|
+
regex: /(?:grant\s+(?:me|this\s+(?:tool|server|skill)|yourself)\s+(?:admin|root|sudo|full|all)\s+(?:access|permissions?|rights?)|elevate\s+(?:privileges?|permissions?|rights?)|run\s+as\s+(?:admin|root|sudo))/gi,
|
|
101
|
+
severity: 'high',
|
|
102
|
+
target: 'description',
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// ── Encoded payload in description ────────────────────────────────────────
|
|
106
|
+
{
|
|
107
|
+
name: 'Encoded payload block',
|
|
108
|
+
regex: /[A-Za-z0-9+\/]{60,}={0,2}/g,
|
|
109
|
+
severity: 'medium',
|
|
110
|
+
target: 'any',
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// Dangerous tool name keywords — flag tools whose names suggest shell/exec access
|
|
115
|
+
const DANGEROUS_TOOL_NAMES = [
|
|
116
|
+
/^(?:exec|execute|shell|bash|sh|cmd|terminal|run_command|system|subprocess)$/i,
|
|
117
|
+
/(?:_exec|_shell|_bash|_cmd|_terminal|exec_|shell_|bash_cmd)/i,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// MAIN COMMAND
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
export async function scanMcpCommand(target, options = {}) {
|
|
125
|
+
if (!target) {
|
|
126
|
+
output.error('Usage: ship-safe scan-mcp <url|path>');
|
|
127
|
+
output.info(' Analyze an MCP server\'s tool manifest for security issues before connecting.');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log();
|
|
132
|
+
output.header('Ship Safe — MCP Server Security Analysis');
|
|
133
|
+
console.log();
|
|
134
|
+
|
|
135
|
+
let manifest, serverName, source;
|
|
136
|
+
|
|
137
|
+
if (target.startsWith('http://') || target.startsWith('https://')) {
|
|
138
|
+
console.log(chalk.gray(` Fetching MCP manifest from: ${target}`));
|
|
139
|
+
try {
|
|
140
|
+
manifest = await fetchMcpManifest(target);
|
|
141
|
+
serverName = new URL(target).hostname;
|
|
142
|
+
source = target;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
output.error(`Failed to fetch MCP manifest: ${err.message}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const filePath = path.resolve(target);
|
|
149
|
+
if (!fs.existsSync(filePath)) {
|
|
150
|
+
output.error(`File not found: ${filePath}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
manifest = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
155
|
+
serverName = path.basename(filePath);
|
|
156
|
+
source = filePath;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
output.error(`Failed to parse manifest: ${err.message}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const tools = extractTools(manifest);
|
|
164
|
+
console.log(chalk.gray(` Server: ${serverName}`));
|
|
165
|
+
console.log(chalk.gray(` Tools found: ${tools.length}`));
|
|
166
|
+
console.log();
|
|
167
|
+
|
|
168
|
+
if (tools.length === 0) {
|
|
169
|
+
output.warning('No tools found in manifest. Is this a valid MCP tools response?');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const findings = analyzeManifest(manifest, tools, serverName, source);
|
|
174
|
+
|
|
175
|
+
if (options.json) {
|
|
176
|
+
console.log(JSON.stringify({ server: serverName, source, toolCount: tools.length, findings, summary: getSummary(findings) }, null, 2));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
printFindings(findings, serverName, tools.length);
|
|
181
|
+
|
|
182
|
+
if (getSummary(findings).critical > 0) {
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// =============================================================================
|
|
188
|
+
// MCP MANIFEST FETCHING
|
|
189
|
+
// =============================================================================
|
|
190
|
+
|
|
191
|
+
async function fetchMcpManifest(baseUrl) {
|
|
192
|
+
const url = baseUrl.replace(/\/$/, '');
|
|
193
|
+
|
|
194
|
+
// Try MCP tools/list endpoint first (JSON-RPC 2.0)
|
|
195
|
+
const jsonRpcBody = JSON.stringify({
|
|
196
|
+
jsonrpc: '2.0',
|
|
197
|
+
id: 1,
|
|
198
|
+
method: 'tools/list',
|
|
199
|
+
params: {},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const jsonRpcRes = await fetch(`${url}`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: jsonRpcBody,
|
|
206
|
+
signal: AbortSignal.timeout(10000),
|
|
207
|
+
}).catch(() => null);
|
|
208
|
+
|
|
209
|
+
if (jsonRpcRes?.ok) {
|
|
210
|
+
const data = await jsonRpcRes.json();
|
|
211
|
+
if (data?.result?.tools) return data.result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Fall back to GET /tools (some servers expose this)
|
|
215
|
+
const getRes = await fetch(`${url}/tools`, {
|
|
216
|
+
signal: AbortSignal.timeout(10000),
|
|
217
|
+
}).catch(() => null);
|
|
218
|
+
|
|
219
|
+
if (getRes?.ok) {
|
|
220
|
+
return await getRes.json();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fall back to root endpoint
|
|
224
|
+
const rootRes = await fetch(`${url}`, {
|
|
225
|
+
signal: AbortSignal.timeout(10000),
|
|
226
|
+
}).catch(() => null);
|
|
227
|
+
|
|
228
|
+
if (rootRes?.ok) {
|
|
229
|
+
const text = await rootRes.text();
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(text);
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Error('Server responded but returned non-JSON content');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
throw new Error('Could not retrieve MCP manifest (tried tools/list JSON-RPC, GET /tools, GET /)');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// =============================================================================
|
|
241
|
+
// TOOL EXTRACTION — handles multiple MCP manifest formats
|
|
242
|
+
// =============================================================================
|
|
243
|
+
|
|
244
|
+
function extractTools(manifest) {
|
|
245
|
+
// MCP tools/list result: { tools: [...] }
|
|
246
|
+
if (Array.isArray(manifest?.tools)) return manifest.tools;
|
|
247
|
+
// Direct array
|
|
248
|
+
if (Array.isArray(manifest)) return manifest;
|
|
249
|
+
// { result: { tools: [...] } }
|
|
250
|
+
if (Array.isArray(manifest?.result?.tools)) return manifest.result.tools;
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// =============================================================================
|
|
255
|
+
// ANALYSIS
|
|
256
|
+
// =============================================================================
|
|
257
|
+
|
|
258
|
+
function analyzeManifest(manifest, tools, serverName, source) {
|
|
259
|
+
const findings = [];
|
|
260
|
+
const rawJson = JSON.stringify(manifest);
|
|
261
|
+
|
|
262
|
+
// 1. Threat intel hash check on full manifest
|
|
263
|
+
const hash = createHash('sha256').update(rawJson).digest('hex');
|
|
264
|
+
const intelMatch = ThreatIntel.lookupHash(hash);
|
|
265
|
+
if (intelMatch) {
|
|
266
|
+
findings.push({
|
|
267
|
+
check: 'threat-intel',
|
|
268
|
+
name: `Known malicious MCP server: ${intelMatch.name}`,
|
|
269
|
+
severity: 'critical',
|
|
270
|
+
tool: null,
|
|
271
|
+
matched: `SHA-256: ${hash} — ${intelMatch.description}`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 2. Threat intel signature check on raw manifest
|
|
276
|
+
const sigMatches = ThreatIntel.matchSignatures(rawJson);
|
|
277
|
+
for (const sig of sigMatches) {
|
|
278
|
+
findings.push({
|
|
279
|
+
check: 'threat-intel',
|
|
280
|
+
name: `Threat intel match: ${sig.description}`,
|
|
281
|
+
severity: sig.severity || 'critical',
|
|
282
|
+
tool: null,
|
|
283
|
+
matched: `Pattern: ${sig.pattern}`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 3. Per-tool analysis
|
|
288
|
+
for (const tool of tools) {
|
|
289
|
+
findings.push(...analyzeToolDefinition(tool));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 4. Server-level checks
|
|
293
|
+
findings.push(...checkServerLevel(manifest, serverName));
|
|
294
|
+
|
|
295
|
+
return findings;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function analyzeToolDefinition(tool) {
|
|
299
|
+
const findings = [];
|
|
300
|
+
const name = tool.name || '(unnamed)';
|
|
301
|
+
const description = tool.description || '';
|
|
302
|
+
const schemaStr = JSON.stringify(tool.inputSchema || tool.input_schema || {});
|
|
303
|
+
|
|
304
|
+
// Check description against patterns
|
|
305
|
+
for (const pattern of MCP_TOOL_PATTERNS) {
|
|
306
|
+
if (pattern.target === 'schema') continue; // schema checked separately below
|
|
307
|
+
pattern.regex.lastIndex = 0;
|
|
308
|
+
const text = pattern.target === 'description' ? description
|
|
309
|
+
: pattern.target === 'any' ? description + ' ' + schemaStr
|
|
310
|
+
: description;
|
|
311
|
+
if (pattern.regex.test(text)) {
|
|
312
|
+
findings.push({
|
|
313
|
+
check: 'static-analysis',
|
|
314
|
+
name: pattern.name,
|
|
315
|
+
severity: pattern.severity,
|
|
316
|
+
tool: name,
|
|
317
|
+
matched: (description + schemaStr).slice(0, 120),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Check schema for dangerous input patterns
|
|
323
|
+
for (const pattern of MCP_TOOL_PATTERNS) {
|
|
324
|
+
if (pattern.target !== 'schema') continue;
|
|
325
|
+
pattern.regex.lastIndex = 0;
|
|
326
|
+
if (pattern.regex.test(schemaStr)) {
|
|
327
|
+
findings.push({
|
|
328
|
+
check: 'schema-analysis',
|
|
329
|
+
name: pattern.name,
|
|
330
|
+
severity: pattern.severity,
|
|
331
|
+
tool: name,
|
|
332
|
+
matched: schemaStr.slice(0, 120),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check tool name against dangerous name list
|
|
338
|
+
for (const namePattern of DANGEROUS_TOOL_NAMES) {
|
|
339
|
+
if (namePattern.test(name)) {
|
|
340
|
+
findings.push({
|
|
341
|
+
check: 'tool-name',
|
|
342
|
+
name: `Dangerous tool name: "${name}"`,
|
|
343
|
+
severity: 'high',
|
|
344
|
+
tool: name,
|
|
345
|
+
matched: `Tool name matches high-risk pattern: ${namePattern}`,
|
|
346
|
+
});
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Check for excessive required parameters (information harvesting)
|
|
352
|
+
const required = tool.inputSchema?.required || tool.input_schema?.required || [];
|
|
353
|
+
const properties = tool.inputSchema?.properties || tool.input_schema?.properties || {};
|
|
354
|
+
const propNames = Object.keys(properties);
|
|
355
|
+
const sensitiveParams = propNames.filter(p =>
|
|
356
|
+
/(?:api[_\s]?key|token|password|secret|credential|auth|private)/i.test(p)
|
|
357
|
+
);
|
|
358
|
+
if (sensitiveParams.length > 0) {
|
|
359
|
+
findings.push({
|
|
360
|
+
check: 'schema-analysis',
|
|
361
|
+
name: `Tool requires sensitive parameters: ${sensitiveParams.join(', ')}`,
|
|
362
|
+
severity: 'high',
|
|
363
|
+
tool: name,
|
|
364
|
+
matched: `Required sensitive params: [${sensitiveParams.join(', ')}]`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return findings;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function checkServerLevel(manifest, serverName) {
|
|
372
|
+
const findings = [];
|
|
373
|
+
const raw = JSON.stringify(manifest);
|
|
374
|
+
|
|
375
|
+
// Check for excessively large manifest (may hide payloads)
|
|
376
|
+
if (raw.length > 500_000) {
|
|
377
|
+
findings.push({
|
|
378
|
+
check: 'server-level',
|
|
379
|
+
name: 'Unusually large manifest',
|
|
380
|
+
severity: 'medium',
|
|
381
|
+
tool: null,
|
|
382
|
+
matched: `Manifest size: ${(raw.length / 1024).toFixed(1)} KB (>500 KB is suspicious)`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Check for tools with no description (reduces reviewability)
|
|
387
|
+
const tools = extractTools(manifest);
|
|
388
|
+
const noDesc = tools.filter(t => !t.description || t.description.trim().length < 10);
|
|
389
|
+
if (noDesc.length > 0 && noDesc.length === tools.length) {
|
|
390
|
+
findings.push({
|
|
391
|
+
check: 'server-level',
|
|
392
|
+
name: 'All tools lack descriptions',
|
|
393
|
+
severity: 'medium',
|
|
394
|
+
tool: null,
|
|
395
|
+
matched: `${noDesc.length}/${tools.length} tools have no meaningful description — cannot assess intent`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return findings;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// =============================================================================
|
|
403
|
+
// OUTPUT
|
|
404
|
+
// =============================================================================
|
|
405
|
+
|
|
406
|
+
function printFindings(findings, serverName, toolCount) {
|
|
407
|
+
const summary = getSummary(findings);
|
|
408
|
+
|
|
409
|
+
if (findings.length === 0) {
|
|
410
|
+
console.log(chalk.green.bold(` ✔ ${serverName}: No security issues found across ${toolCount} tool(s).`));
|
|
411
|
+
console.log();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log(chalk.red.bold(` ✘ ${serverName}: ${findings.length} issue(s) found across ${toolCount} tool(s)`));
|
|
416
|
+
console.log();
|
|
417
|
+
|
|
418
|
+
// Group by tool
|
|
419
|
+
const byTool = new Map();
|
|
420
|
+
for (const f of findings) {
|
|
421
|
+
const key = f.tool || '(server-level)';
|
|
422
|
+
if (!byTool.has(key)) byTool.set(key, []);
|
|
423
|
+
byTool.get(key).push(f);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const [toolName, toolFindings] of byTool) {
|
|
427
|
+
if (toolName !== '(server-level)') {
|
|
428
|
+
console.log(chalk.cyan(` Tool: ${toolName}`));
|
|
429
|
+
} else {
|
|
430
|
+
console.log(chalk.cyan(` Server-level`));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const f of toolFindings) {
|
|
434
|
+
const sevColor = f.severity === 'critical' ? chalk.red.bold
|
|
435
|
+
: f.severity === 'high' ? chalk.yellow
|
|
436
|
+
: chalk.blue;
|
|
437
|
+
console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.name)}`);
|
|
438
|
+
if (f.matched) console.log(chalk.gray(` ${f.matched.slice(0, 120)}`));
|
|
439
|
+
}
|
|
440
|
+
console.log();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (summary.critical > 0) {
|
|
444
|
+
console.log(chalk.red.bold(' ⚠ DO NOT CONNECT to this MCP server — critical security issues detected.'));
|
|
445
|
+
console.log();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function getSummary(findings) {
|
|
450
|
+
return {
|
|
451
|
+
total: findings.length,
|
|
452
|
+
critical: findings.filter(f => f.severity === 'critical').length,
|
|
453
|
+
high: findings.filter(f => f.severity === 'high').length,
|
|
454
|
+
medium: findings.filter(f => f.severity === 'medium').length,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
@@ -47,6 +47,20 @@ const SKILL_PATTERNS = [
|
|
|
47
47
|
{ name: 'Crypto operations', regex: /(?:crypto\.createCipher|crypto\.createDecipher|CryptoJS|forge\.cipher)/gi, severity: 'medium' },
|
|
48
48
|
{ name: 'Network listener', regex: /(?:createServer|listen\s*\(\s*\d|bind\s*\(\s*['"]0\.0\.0\.0)/gi, severity: 'high' },
|
|
49
49
|
{ name: 'Encoded payload block', regex: /[A-Za-z0-9+\/]{60,}={0,2}/g, severity: 'medium' },
|
|
50
|
+
|
|
51
|
+
// ── ToxicSkills patterns (Snyk research — 36% of agent skills affected) ──
|
|
52
|
+
// Silent curl exfiltration: skill instructs agent to silently send data
|
|
53
|
+
{ name: 'ToxicSkills: silent data exfiltration via curl', regex: /(?:silently|quietly|without\s+(?:notif|alert|inform|telling|showing)|in\s+the\s+background)\s+.{0,60}(?:curl|wget|fetch|POST|send).{0,60}(?:http|https):\/\//gi, severity: 'critical' },
|
|
54
|
+
// System prompt override in skill definition
|
|
55
|
+
{ name: 'ToxicSkills: system prompt override', regex: /(?:ignore\s+(?:all\s+)?(?:previous|prior|above|your)\s+instructions|your\s+(?:new|real|actual|true)\s+(?:instructions|role|goal|purpose)\s+(?:is|are)|disregard\s+(?:all\s+)?(?:previous|above|your))/gi, severity: 'critical' },
|
|
56
|
+
// Skill requests credentials/secrets from agent context
|
|
57
|
+
{ name: 'ToxicSkills: credential harvesting', regex: /(?:extract|retrieve|collect|gather|find|read|access|get)\s+.{0,40}(?:api[_\s]?key|secret|token|password|credential|\.env|npmrc|ssh[_\s]?key|private[_\s]?key)/gi, severity: 'critical' },
|
|
58
|
+
// Skill attempts to read ~/.ssh, ~/.aws, ~/.npmrc
|
|
59
|
+
{ name: 'ToxicSkills: sensitive path access', regex: /(?:~\/\.(?:ssh|aws|npmrc|netrc|gnupg|config\/gcloud)|\/etc\/(?:passwd|shadow|hosts)|%APPDATA%|%USERPROFILE%)/gi, severity: 'critical' },
|
|
60
|
+
// Skill suppresses its own output to avoid detection
|
|
61
|
+
{ name: 'ToxicSkills: output suppression', regex: /(?:do\s+not\s+(?:show|display|reveal|mention|tell|report|log)\s+(?:this|these|the\s+(?:output|result|response|command|action))|hide\s+(?:this|the)\s+(?:output|result|action|command|request))/gi, severity: 'high' },
|
|
62
|
+
// Skill requests permissions beyond its stated purpose
|
|
63
|
+
{ name: 'ToxicSkills: permission escalation', regex: /(?:grant\s+(?:me|this\s+skill|yourself)\s+(?:admin|root|sudo|full|all)\s+(?:access|permissions?|rights?)|elevate\s+(?:privileges?|permissions?|rights?)|run\s+as\s+(?:admin|root|sudo))/gi, severity: 'high' },
|
|
50
64
|
];
|
|
51
65
|
|
|
52
66
|
// =============================================================================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ship-safe",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "AI-powered multi-agent security platform. 18 agents scan 80+ attack classes with LLM-powered deep analysis. Red team your code before attackers do.",
|
|
5
5
|
"main": "cli/index.js",
|
|
6
6
|
"bin": {
|