clawmoat 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CONTRIBUTING.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/bin/clawmoat.js +407 -0
  5. package/docs/CNAME +1 -0
  6. package/docs/MIT-RISK-GAP-ANALYSIS.md +146 -0
  7. package/docs/badge/score-A.svg +21 -0
  8. package/docs/badge/score-Aplus.svg +21 -0
  9. package/docs/badge/score-B.svg +21 -0
  10. package/docs/badge/score-C.svg +21 -0
  11. package/docs/badge/score-D.svg +21 -0
  12. package/docs/badge/score-F.svg +21 -0
  13. package/docs/blog/index.html +90 -0
  14. package/docs/blog/owasp-agentic-ai-top10.html +187 -0
  15. package/docs/blog/owasp-agentic-ai-top10.md +185 -0
  16. package/docs/blog/securing-ai-agents.html +194 -0
  17. package/docs/blog/securing-ai-agents.md +152 -0
  18. package/docs/compare.html +312 -0
  19. package/docs/index.html +654 -0
  20. package/docs/integrations/langchain.html +281 -0
  21. package/docs/integrations/openai.html +302 -0
  22. package/docs/integrations/openclaw.html +310 -0
  23. package/docs/robots.txt +3 -0
  24. package/docs/sitemap.xml +28 -0
  25. package/docs/thanks.html +79 -0
  26. package/package.json +35 -0
  27. package/server/Dockerfile +7 -0
  28. package/server/index.js +85 -0
  29. package/server/package.json +12 -0
  30. package/skill/SKILL.md +56 -0
  31. package/src/badge.js +87 -0
  32. package/src/index.js +316 -0
  33. package/src/middleware/openclaw.js +133 -0
  34. package/src/policies/engine.js +180 -0
  35. package/src/scanners/exfiltration.js +97 -0
  36. package/src/scanners/jailbreak.js +81 -0
  37. package/src/scanners/memory-poison.js +68 -0
  38. package/src/scanners/pii.js +128 -0
  39. package/src/scanners/prompt-injection.js +138 -0
  40. package/src/scanners/secrets.js +97 -0
  41. package/src/scanners/supply-chain.js +155 -0
  42. package/src/scanners/urls.js +142 -0
  43. package/src/utils/config.js +137 -0
  44. package/src/utils/logger.js +109 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * ClawMoat — Policy Engine
3
+ *
4
+ * Evaluates tool calls against security policies.
5
+ * Returns allow/deny/warn decisions with explanations.
6
+ */
7
+
8
+ const path = require('path');
9
+
10
+ const DECISIONS = { allow: 'allow', deny: 'deny', warn: 'warn', review: 'review' };
11
+
12
+ /**
13
+ * Evaluate a tool call against policies
14
+ * @param {string} tool - Tool name (exec, read, write, browser, etc.)
15
+ * @param {object} args - Tool arguments
16
+ * @param {object} policies - Policy config
17
+ * @returns {object} Decision
18
+ */
19
+ function evaluateToolCall(tool, args, policies) {
20
+ switch (tool) {
21
+ case 'exec': return evaluateExec(args, policies.exec || {});
22
+ case 'read':
23
+ case 'Read': return evaluateFileRead(args, policies.file || {});
24
+ case 'write':
25
+ case 'Write':
26
+ case 'edit':
27
+ case 'Edit': return evaluateFileWrite(args, policies.file || {});
28
+ case 'browser': return evaluateBrowser(args, policies.browser || {});
29
+ case 'message': return evaluateMessage(args, policies.message || {});
30
+ default: return { decision: DECISIONS.allow, tool, reason: 'No policy defined' };
31
+ }
32
+ }
33
+
34
+ function evaluateExec(args, policy) {
35
+ const command = args.command || '';
36
+
37
+ // Check block patterns
38
+ for (const pattern of (policy.block_patterns || [])) {
39
+ const regex = globToRegex(pattern);
40
+ if (regex.test(command)) {
41
+ return {
42
+ decision: DECISIONS.deny,
43
+ tool: 'exec',
44
+ reason: `Command matches blocked pattern: ${pattern}`,
45
+ matched: command.substring(0, 200),
46
+ severity: 'critical',
47
+ };
48
+ }
49
+ }
50
+
51
+ // Check dangerous commands
52
+ const dangerousCommands = [
53
+ { pattern: /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|.*-[a-zA-Z]*r[a-zA-Z]*f)/, reason: 'Recursive force delete', severity: 'critical' },
54
+ { pattern: /\bmkfs\b/, reason: 'Filesystem format', severity: 'critical' },
55
+ { pattern: /\bdd\s+if=/, reason: 'Low-level disk write', severity: 'high' },
56
+ { pattern: />\s*\/dev\/sd[a-z]/, reason: 'Direct disk write', severity: 'critical' },
57
+ { pattern: /\bchmod\s+777\b/, reason: 'World-writable permissions', severity: 'medium' },
58
+ { pattern: /\bchmod\s+\+s\b/, reason: 'Set SUID bit', severity: 'high' },
59
+ { pattern: /\bcrontab\s+-r\b/, reason: 'Remove all cron jobs', severity: 'high' },
60
+ { pattern: /\biptables\s+-F\b/, reason: 'Flush all firewall rules', severity: 'critical' },
61
+ { pattern: /\bpasswd\b/, reason: 'Password change attempt', severity: 'high' },
62
+ { pattern: /\buseradd\b|\badduser\b/, reason: 'User creation', severity: 'high' },
63
+ { pattern: /\bvisudo\b|\bsudoers\b/, reason: 'Sudo configuration change', severity: 'critical' },
64
+ { pattern: /\bnc\s+-[a-z]*l/i, reason: 'Network listener (reverse shell risk)', severity: 'critical' },
65
+ { pattern: /\b(?:python|node|ruby|perl)\s+-.*(?:socket|http\.server|SimpleHTTP)/i, reason: 'Network server spawn', severity: 'high' },
66
+ { pattern: /\bcurl\b.*\|\s*(?:bash|sh|zsh)\b/, reason: 'Pipe remote script to shell', severity: 'critical' },
67
+ { pattern: /\bwget\b.*\|\s*(?:bash|sh|zsh)\b/, reason: 'Pipe remote script to shell', severity: 'critical' },
68
+ { pattern: /\beval\s+\$\(curl\b/, reason: 'Eval remote content', severity: 'critical' },
69
+ { pattern: /\beval\s+\$\(wget\b/, reason: 'Eval remote content', severity: 'critical' },
70
+ ];
71
+
72
+ for (const { pattern, reason, severity } of dangerousCommands) {
73
+ if (pattern.test(command)) {
74
+ return {
75
+ decision: severity === 'critical' ? DECISIONS.deny : DECISIONS.warn,
76
+ tool: 'exec',
77
+ reason,
78
+ matched: command.substring(0, 200),
79
+ severity,
80
+ };
81
+ }
82
+ }
83
+
84
+ // Check require_approval patterns
85
+ for (const pattern of (policy.require_approval || [])) {
86
+ const regex = globToRegex(pattern);
87
+ if (regex.test(command)) {
88
+ return {
89
+ decision: DECISIONS.review,
90
+ tool: 'exec',
91
+ reason: `Command requires approval: ${pattern}`,
92
+ matched: command.substring(0, 200),
93
+ severity: 'medium',
94
+ };
95
+ }
96
+ }
97
+
98
+ return { decision: DECISIONS.allow, tool: 'exec' };
99
+ }
100
+
101
+ function evaluateFileRead(args, policy) {
102
+ const filePath = args.path || args.file_path || '';
103
+ const expanded = expandHome(filePath);
104
+
105
+ for (const pattern of (policy.deny_read || [])) {
106
+ const regex = globToRegex(expandHome(pattern));
107
+ if (regex.test(expanded)) {
108
+ return {
109
+ decision: DECISIONS.deny,
110
+ tool: 'read',
111
+ reason: `File read blocked by policy: ${pattern}`,
112
+ path: filePath,
113
+ severity: 'high',
114
+ };
115
+ }
116
+ }
117
+
118
+ return { decision: DECISIONS.allow, tool: 'read' };
119
+ }
120
+
121
+ function evaluateFileWrite(args, policy) {
122
+ const filePath = args.path || args.file_path || '';
123
+ const expanded = expandHome(filePath);
124
+
125
+ for (const pattern of (policy.deny_write || [])) {
126
+ const regex = globToRegex(expandHome(pattern));
127
+ if (regex.test(expanded)) {
128
+ return {
129
+ decision: DECISIONS.deny,
130
+ tool: 'write',
131
+ reason: `File write blocked by policy: ${pattern}`,
132
+ path: filePath,
133
+ severity: 'high',
134
+ };
135
+ }
136
+ }
137
+
138
+ return { decision: DECISIONS.allow, tool: 'write' };
139
+ }
140
+
141
+ function evaluateBrowser(args, policy) {
142
+ const url = args.targetUrl || args.url || '';
143
+
144
+ for (const pattern of (policy.block_domains || [])) {
145
+ const regex = globToRegex(pattern);
146
+ if (regex.test(url)) {
147
+ return {
148
+ decision: DECISIONS.deny,
149
+ tool: 'browser',
150
+ reason: `Domain blocked by policy: ${pattern}`,
151
+ url,
152
+ severity: 'high',
153
+ };
154
+ }
155
+ }
156
+
157
+ return { decision: DECISIONS.allow, tool: 'browser' };
158
+ }
159
+
160
+ function evaluateMessage(args, policy) {
161
+ // Flag messages going to unknown recipients
162
+ return { decision: DECISIONS.allow, tool: 'message' };
163
+ }
164
+
165
+ // Helpers
166
+ function expandHome(p) {
167
+ return p.replace(/^~/, process.env.HOME || '/home/user');
168
+ }
169
+
170
+ function globToRegex(glob) {
171
+ const escaped = glob
172
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
173
+ .replace(/\*\*/g, '§DOUBLESTAR§')
174
+ .replace(/\*/g, '[^/]*')
175
+ .replace(/§DOUBLESTAR§/g, '.*')
176
+ .replace(/\?/g, '.');
177
+ return new RegExp(escaped, 'i');
178
+ }
179
+
180
+ module.exports = { evaluateToolCall, DECISIONS };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ClawMoat — Exfiltration Detection Scanner
3
+ *
4
+ * Detects data exfiltration attempts — when an agent is being used
5
+ * to send data to external services.
6
+ */
7
+
8
+ const PASTE_SERVICES = [
9
+ 'pastebin.com', 'hastebin.com', '0x0.st', 'transfer.sh', 'paste.ee',
10
+ 'dpaste.org', 'ghostbin.com', 'rentry.co', 'paste.mozilla.org',
11
+ 'ix.io', 'sprunge.us', 'cl1p.net', 'file.io', 'tmpfiles.org',
12
+ ];
13
+
14
+ const EXFIL_PATTERNS = [
15
+ // Curl/wget to external services
16
+ { pattern: /\bcurl\s+(?:-[a-zA-Z]\s+)*(?:--data|--data-binary|-d|-F)\s/i, severity: 'high', name: 'curl_data_upload' },
17
+ { pattern: /\bwget\s+--post-(?:data|file)\b/i, severity: 'high', name: 'wget_data_upload' },
18
+
19
+ // Base64 data being sent externally
20
+ { pattern: /\bcurl\b.*\b(?:base64|atob|btoa)\b/i, severity: 'high', name: 'base64_exfiltration' },
21
+ { pattern: /\b(?:echo|printf)\s+['"]?[A-Za-z0-9+/=]{50,}['"]?\s*\|\s*(?:curl|wget|nc)\b/i, severity: 'critical', name: 'base64_exfiltration' },
22
+
23
+ // DNS exfiltration (long subdomain strings)
24
+ { pattern: /\b(?:dig|nslookup|host)\s+[A-Za-z0-9]{20,}\./i, severity: 'high', name: 'dns_exfiltration' },
25
+ { pattern: /\$\(.*\)\.[A-Za-z0-9.-]+\.[a-z]{2,}/i, severity: 'medium', name: 'dns_exfiltration' },
26
+
27
+ // File contents being sent via messaging
28
+ { pattern: /\b(?:send|post|forward|share|upload)\s+(?:the\s+)?(?:contents?\s+of|file)\s+(?:\/|~\/|\.\/)/i, severity: 'high', name: 'file_content_send' },
29
+ { pattern: /\bcat\s+[^\s|]+\s*\|\s*(?:curl|wget|nc)\b/i, severity: 'critical', name: 'file_content_pipe' },
30
+
31
+ // Paste services
32
+ { pattern: new RegExp('\\b(?:curl|wget|fetch)\\s+.*(?:' + PASTE_SERVICES.map(s => s.replace(/\./g, '\\.')).join('|') + ')', 'i'), severity: 'critical', name: 'paste_service_upload' },
33
+
34
+ // Email forwarding of sensitive content
35
+ { pattern: /\b(?:send|forward|email|mail)\s+(?:the\s+)?(?:ssh|key|password|credential|token|secret|env|config)\b.*\bto\b/i, severity: 'critical', name: 'email_exfiltration' },
36
+
37
+ // Netcat reverse connections
38
+ { pattern: /\bnc\s+(?:-[a-z]\s+)*[^\s]+\s+\d+\s*</i, severity: 'critical', name: 'netcat_exfiltration' },
39
+
40
+ // Upload via fetch/XMLHttpRequest
41
+ { pattern: /\bfetch\s*\(\s*['"][^'"]+['"]\s*,\s*\{[^}]*method\s*:\s*['"]POST['"]/i, severity: 'high', name: 'fetch_upload' },
42
+ ];
43
+
44
+ /**
45
+ * Scan text for data exfiltration attempts
46
+ * @param {string} text - Text to scan
47
+ * @param {object} opts - Options
48
+ * @returns {object} Scan result { clean, findings[], severity }
49
+ */
50
+ function scanExfiltration(text, opts = {}) {
51
+ if (!text || typeof text !== 'string') {
52
+ return { clean: true, findings: [], severity: null };
53
+ }
54
+
55
+ const findings = [];
56
+
57
+ for (const { pattern, severity, name } of EXFIL_PATTERNS) {
58
+ const match = text.match(pattern);
59
+ if (match) {
60
+ findings.push({
61
+ type: 'exfiltration',
62
+ subtype: name,
63
+ severity,
64
+ matched: match[0].substring(0, 100),
65
+ position: match.index,
66
+ });
67
+ }
68
+ }
69
+
70
+ // Check for paste service URLs directly
71
+ const lowerText = text.toLowerCase();
72
+ for (const service of PASTE_SERVICES) {
73
+ if (lowerText.includes(service) && /\b(?:upload|post|send|push|put)\b/i.test(text)) {
74
+ const existing = findings.find(f => f.subtype === 'paste_service_upload');
75
+ if (!existing) {
76
+ findings.push({
77
+ type: 'exfiltration',
78
+ subtype: 'paste_service_upload',
79
+ severity: 'high',
80
+ matched: service,
81
+ });
82
+ }
83
+ }
84
+ }
85
+
86
+ const maxSev = findings.length > 0
87
+ ? findings.reduce((max, f) => rank(f.severity) > rank(max) ? f.severity : max, 'low')
88
+ : null;
89
+
90
+ return { clean: findings.length === 0, findings, severity: maxSev };
91
+ }
92
+
93
+ function rank(s) {
94
+ return { low: 0, medium: 1, high: 2, critical: 3 }[s] || 0;
95
+ }
96
+
97
+ module.exports = { scanExfiltration };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ClawMoat — Jailbreak Detection
3
+ *
4
+ * Detects common LLM jailbreak patterns (DAN, roleplay exploits, etc.)
5
+ */
6
+
7
+ const JAILBREAK_PATTERNS = [
8
+ // DAN and variants
9
+ { pattern: /\bDAN\b.*(?:do anything now|jailbreak)/i, severity: 'critical', name: 'dan_jailbreak' },
10
+ { pattern: /\b(?:DAN|STAN|DUDE|AIM)\s+(?:mode|prompt|jailbreak)/i, severity: 'critical', name: 'named_jailbreak' },
11
+
12
+ // Developer/debug mode
13
+ { pattern: /(?:enable|enter|activate|switch\s+to)\s+(?:developer|debug|maintenance|test|unrestricted|unfiltered)\s+mode/i, severity: 'high', name: 'mode_switch' },
14
+ { pattern: /\bsudo\s+mode\b/i, severity: 'high', name: 'mode_switch' },
15
+
16
+ // Dual persona / split personality
17
+ { pattern: /(?:respond|answer|reply)\s+(?:as|like)\s+(?:both|two)\s+(?:characters|personas|versions|a\s+normal)/i, severity: 'high', name: 'dual_persona' },
18
+ { pattern: /(?:both)\s+(?:a\s+)?(?:normal|regular|standard)\s+(?:AI|assistant|mode)\s+(?:and|\/)\s+(?:an?\s+)?(?:unrestricted|unfiltered|DAN)/i, severity: 'high', name: 'dual_persona' },
19
+ { pattern: /(?:classic|normal)\s+(?:mode|response)\s+(?:and|vs|\/)\s+(?:jailbreak|DAN|unfiltered)/i, severity: 'high', name: 'dual_persona' },
20
+
21
+ // "Hypothetical" bypass
22
+ { pattern: /(?:hypothetically|theoretically|in\s+a\s+fictional)\s+(?:how\s+would|could\s+you|what\s+if)/i, severity: 'medium', name: 'hypothetical_bypass' },
23
+ { pattern: /(?:write|create)\s+(?:a\s+)?(?:story|fiction|screenplay)\s+(?:where|about|in\s+which).*(?:hack|exploit|steal|attack)/i, severity: 'medium', name: 'fiction_bypass' },
24
+
25
+ // Token smuggling
26
+ { pattern: /(?:translate|decode|convert)\s+(?:this|the\s+following)\s+(?:from\s+)?(?:base64|hex|rot13|binary|morse)/i, severity: 'medium', name: 'encoding_bypass' },
27
+
28
+ // Grandma exploit
29
+ { pattern: /(?:my\s+(?:grandma|grandmother|nana))\s+(?:used\s+to|would\s+always)\s+(?:tell|read|say)/i, severity: 'low', name: 'social_engineering' },
30
+
31
+ // Prompt leaking via completion
32
+ { pattern: /(?:continue|complete|finish)\s+(?:this|the)\s+(?:sentence|text|prompt)\s*:\s*(?:"|'|`)/i, severity: 'medium', name: 'completion_attack' },
33
+
34
+ // Anti-safety patterns
35
+ { pattern: /(?:safety|content|ethical)\s+(?:filters?|guidelines?|restrictions?)\s+(?:are|have\s+been)\s+(?:removed|disabled|turned\s+off)/i, severity: 'critical', name: 'safety_bypass_claim' },
36
+ { pattern: /(?:you\s+(?:can|are\s+able\s+to|have\s+permission\s+to))\s+(?:say|do|generate)\s+anything/i, severity: 'high', name: 'permission_claim' },
37
+ ];
38
+
39
+ /**
40
+ * Scan text for jailbreak attempts
41
+ * @param {string} text - Text to scan
42
+ * @returns {object} Scan result
43
+ */
44
+ function scanJailbreak(text) {
45
+ if (!text || typeof text !== 'string') {
46
+ return { clean: true, score: 0, findings: [] };
47
+ }
48
+
49
+ const findings = [];
50
+
51
+ for (const { pattern, severity, name } of JAILBREAK_PATTERNS) {
52
+ const match = text.match(pattern);
53
+ if (match) {
54
+ findings.push({
55
+ type: 'jailbreak',
56
+ subtype: name,
57
+ severity,
58
+ matched: match[0].substring(0, 100),
59
+ position: match.index,
60
+ });
61
+ }
62
+ }
63
+
64
+ const score = Math.min(findings.length * 30, 100);
65
+ const maxSeverity = findings.reduce(
66
+ (max, f) => (rank(f.severity) > rank(max) ? f.severity : max), 'low'
67
+ );
68
+
69
+ return {
70
+ clean: findings.length === 0,
71
+ score,
72
+ severity: findings.length > 0 ? maxSeverity : null,
73
+ findings,
74
+ };
75
+ }
76
+
77
+ function rank(s) {
78
+ return { low: 0, medium: 1, high: 2, critical: 3 }[s] || 0;
79
+ }
80
+
81
+ module.exports = { scanJailbreak, JAILBREAK_PATTERNS };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ClawMoat — Memory Poisoning Detection Scanner
3
+ *
4
+ * Detects attempts to poison agent memory files (MEMORY.md, SOUL.md, etc.)
5
+ */
6
+
7
+ const MEMORY_PATTERNS = [
8
+ // Direct memory file manipulation
9
+ { pattern: /(?:add|write|append|update|modify|change|insert|put)\s+(?:this\s+)?(?:to|in|into)\s+(?:your\s+)?(?:MEMORY|SOUL|memory|soul)\.md/i, severity: 'critical', name: 'memory_file_write' },
10
+ { pattern: /(?:add|write|append|update|modify|change|insert|put)\s+(?:this\s+)?(?:to|in|into)\s+(?:your\s+)?(?:AGENTS|HEARTBEAT|TOOLS|BOOTSTRAP)\.md/i, severity: 'critical', name: 'config_file_write' },
11
+
12
+ // Memory manipulation instructions
13
+ { pattern: /\bremember\s+that\s+your\s+(?:instructions?|rules?|guidelines?|purpose|goal)\s+(?:are|is)\b/i, severity: 'critical', name: 'memory_override' },
14
+ { pattern: /\b(?:save|store|memorize|remember)\s+(?:this|the\s+following)\s+(?:for|across|between)\s+(?:future\s+)?sessions?\b/i, severity: 'high', name: 'persistent_memory' },
15
+ { pattern: /\b(?:update|change|modify|edit)\s+(?:your\s+)?(?:personality|identity|soul|core\s+(?:values|instructions?))\b/i, severity: 'critical', name: 'identity_override' },
16
+
17
+ // Config file targeting
18
+ { pattern: /\b(?:edit|modify|update|write\s+to|overwrite)\s+(?:the\s+)?(?:AGENTS|TOOLS|HEARTBEAT|BOOTSTRAP|SOUL|MEMORY)\.md\b/i, severity: 'critical', name: 'config_targeting' },
19
+
20
+ // Persistent injection (survive across sessions)
21
+ { pattern: /\b(?:always|forever|permanently|from\s+now\s+on)\s+(?:remember|keep|maintain|follow|obey)\b/i, severity: 'high', name: 'persistent_injection' },
22
+ { pattern: /\b(?:in\s+(?:all|every)\s+future\s+(?:sessions?|conversations?|interactions?))\b/i, severity: 'high', name: 'persistent_injection' },
23
+ { pattern: /\b(?:across\s+(?:all\s+)?sessions?\s+(?:you\s+)?(?:must|should|will))\b/i, severity: 'high', name: 'persistent_injection' },
24
+
25
+ // Time bomb patterns
26
+ { pattern: /\b(?:next\s+time|when\s+you\s+(?:next\s+)?see|if\s+(?:someone|anyone|a\s+user)\s+(?:asks?|says?|mentions?))\b.*\b(?:do|execute|run|say|respond|output)\b/i, severity: 'high', name: 'time_bomb' },
27
+ { pattern: /\b(?:whenever|each\s+time|every\s+time)\s+(?:you|someone|a\s+user)\b.*\b(?:secretly|silently|without\s+(?:telling|mentioning|saying))\b/i, severity: 'critical', name: 'time_bomb' },
28
+ { pattern: /\b(?:after\s+this\s+(?:conversation|session|chat)\s+(?:ends?|is\s+over))\b/i, severity: 'high', name: 'time_bomb' },
29
+ ];
30
+
31
+ /**
32
+ * Scan text for memory poisoning attempts
33
+ * @param {string} text - Text to scan
34
+ * @param {object} opts - Options
35
+ * @returns {object} Scan result { clean, findings[], severity }
36
+ */
37
+ function scanMemoryPoison(text, opts = {}) {
38
+ if (!text || typeof text !== 'string') {
39
+ return { clean: true, findings: [], severity: null };
40
+ }
41
+
42
+ const findings = [];
43
+
44
+ for (const { pattern, severity, name } of MEMORY_PATTERNS) {
45
+ const match = text.match(pattern);
46
+ if (match) {
47
+ findings.push({
48
+ type: 'memory_poison',
49
+ subtype: name,
50
+ severity,
51
+ matched: match[0].substring(0, 100),
52
+ position: match.index,
53
+ });
54
+ }
55
+ }
56
+
57
+ const maxSev = findings.length > 0
58
+ ? findings.reduce((max, f) => rank(f.severity) > rank(max) ? f.severity : max, 'low')
59
+ : null;
60
+
61
+ return { clean: findings.length === 0, findings, severity: maxSev };
62
+ }
63
+
64
+ function rank(s) {
65
+ return { low: 0, medium: 1, high: 2, critical: 3 }[s] || 0;
66
+ }
67
+
68
+ module.exports = { scanMemoryPoison };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ClawMoat — PII Detection Scanner
3
+ *
4
+ * Detects personally identifiable information in outbound messages:
5
+ * emails, phone numbers, SSNs, credit cards, IP addresses, physical addresses, names.
6
+ */
7
+
8
+ const PII_PATTERNS = [
9
+ // Email addresses
10
+ { name: 'email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, severity: 'high' },
11
+
12
+ // SSN
13
+ { name: 'ssn', pattern: /\b\d{3}-\d{2}-\d{4}\b/, severity: 'critical' },
14
+
15
+ // Phone numbers (US)
16
+ { name: 'phone_us', pattern: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/, severity: 'high' },
17
+
18
+ // Phone numbers (international)
19
+ { name: 'phone_international', pattern: /\b\+(?:2[0-9]|3[0-9]|4[0-9]|5[0-9]|6[0-9]|7[0-9]|8[0-9]|9[0-9])\d{7,12}\b/, severity: 'high' },
20
+
21
+ // IP addresses (private/internal)
22
+ { name: 'private_ip', pattern: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/, severity: 'medium' },
23
+
24
+ // Physical addresses (street patterns)
25
+ { name: 'physical_address', pattern: /\b\d{1,5}\s+(?:[A-Z][a-z]+\s+){1,3}(?:St(?:reet)?|Ave(?:nue)?|Blvd|Boulevard|Dr(?:ive)?|Ln|Lane|Rd|Road|Ct|Court|Pl|Place|Way|Cir(?:cle)?)\b/i, severity: 'high' },
26
+ ];
27
+
28
+ // Credit card patterns (need Luhn validation)
29
+ const CREDIT_CARD_PATTERNS = [
30
+ { name: 'visa', pattern: /\b4\d{3}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/ },
31
+ { name: 'mastercard', pattern: /\b5[1-5]\d{2}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/ },
32
+ { name: 'amex', pattern: /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/ },
33
+ { name: 'discover', pattern: /\b6(?:011|5\d{2})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/ },
34
+ ];
35
+
36
+ // Name-near-keyword heuristic
37
+ const NAME_KEYWORDS = /\b(?:patient|client|user|customer|employee|member|applicant|resident|tenant|subscriber)\s*(?:name)?\s*[:=]?\s*([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})/;
38
+
39
+ /**
40
+ * Luhn algorithm to validate credit card numbers
41
+ */
42
+ function luhnCheck(numStr) {
43
+ const digits = numStr.replace(/[-\s]/g, '');
44
+ if (!/^\d+$/.test(digits)) return false;
45
+ let sum = 0;
46
+ let alternate = false;
47
+ for (let i = digits.length - 1; i >= 0; i--) {
48
+ let n = parseInt(digits[i], 10);
49
+ if (alternate) {
50
+ n *= 2;
51
+ if (n > 9) n -= 9;
52
+ }
53
+ sum += n;
54
+ alternate = !alternate;
55
+ }
56
+ return sum % 10 === 0;
57
+ }
58
+
59
+ /**
60
+ * Scan text for PII
61
+ * @param {string} text - Text to scan
62
+ * @param {object} opts - Options
63
+ * @returns {object} Scan result { clean, findings[], severity }
64
+ */
65
+ function scanPII(text, opts = {}) {
66
+ if (!text || typeof text !== 'string') {
67
+ return { clean: true, findings: [], severity: null };
68
+ }
69
+
70
+ const findings = [];
71
+
72
+ // Standard PII patterns
73
+ for (const { name, pattern, severity } of PII_PATTERNS) {
74
+ const match = text.match(pattern);
75
+ if (match) {
76
+ findings.push({
77
+ type: 'pii_detected',
78
+ subtype: name,
79
+ severity,
80
+ matched: redact(match[0]),
81
+ position: match.index,
82
+ });
83
+ }
84
+ }
85
+
86
+ // Credit card with Luhn validation
87
+ for (const { name, pattern } of CREDIT_CARD_PATTERNS) {
88
+ const match = text.match(pattern);
89
+ if (match && luhnCheck(match[0])) {
90
+ findings.push({
91
+ type: 'pii_detected',
92
+ subtype: 'credit_card_' + name,
93
+ severity: 'critical',
94
+ matched: redact(match[0]),
95
+ position: match.index,
96
+ });
97
+ }
98
+ }
99
+
100
+ // Name near keyword heuristic
101
+ const nameMatch = text.match(NAME_KEYWORDS);
102
+ if (nameMatch) {
103
+ findings.push({
104
+ type: 'pii_detected',
105
+ subtype: 'name_keyword',
106
+ severity: 'medium',
107
+ matched: redact(nameMatch[0]),
108
+ position: nameMatch.index,
109
+ });
110
+ }
111
+
112
+ const maxSev = findings.length > 0
113
+ ? findings.reduce((max, f) => rank(f.severity) > rank(max) ? f.severity : max, 'low')
114
+ : null;
115
+
116
+ return { clean: findings.length === 0, findings, severity: maxSev };
117
+ }
118
+
119
+ function redact(value) {
120
+ if (value.length <= 8) return '****';
121
+ return value.substring(0, 4) + '*'.repeat(Math.min(value.length - 8, 20)) + value.substring(value.length - 4);
122
+ }
123
+
124
+ function rank(s) {
125
+ return { low: 0, medium: 1, high: 2, critical: 3 }[s] || 0;
126
+ }
127
+
128
+ module.exports = { scanPII };