clawmoat 0.4.0 → 0.7.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/server/index.js CHANGED
@@ -6,15 +6,28 @@ const PORT = process.env.PORT || 3000;
6
6
  const SITE_URL = process.env.SITE_URL || 'https://clawmoat.com';
7
7
 
8
8
  const PRICES = {
9
- // One-time purchase
10
- 'pro-skill': process.env.PRICE_PRO_SKILL || 'price_1T1avaAUiOw2ZIordarLcoff',
11
- // Subscriptions (30-day free trial)
12
- 'shield-monthly': process.env.PRICE_SHIELD_MONTHLY || 'price_1T1avaAUiOw2ZIorQXuxNyM3',
13
- 'shield-yearly': process.env.PRICE_SHIELD_YEARLY || 'price_1T1avaAUiOw2ZIorAtBLXBOg',
14
- 'team-monthly': process.env.PRICE_TEAM_MONTHLY || 'price_1T1avaAUiOw2ZIorAqeOaahQ',
15
- 'team-yearly': process.env.PRICE_TEAM_YEARLY || 'price_1T1avbAUiOw2ZIorDLUicwin',
9
+ // Pro subscriptions
10
+ 'shield-monthly': process.env.PRICE_SHIELD_MONTHLY || 'price_1T0an4AUiOw2ZIorxQRyAxvQ', // $14.99/mo
11
+ 'shield-yearly': process.env.PRICE_SHIELD_YEARLY || 'price_1T0an4AUiOw2ZIorfHx7RowT', // $149/yr
12
+ // Team subscriptions
13
+ 'team-monthly': process.env.PRICE_TEAM_MONTHLY || 'price_1T0aqrAUiOw2ZIorh4gjBPGt', // $49/mo
14
+ 'team-yearly': process.env.PRICE_TEAM_YEARLY || 'price_1T0asRAUiOw2ZIorxAi69uwl', // $499/yr
16
15
  };
17
16
 
17
+ // In-memory license store (replace with DB in production)
18
+ const licenses = new Map();
19
+
20
+ function generateLicenseKey() {
21
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
22
+ const segments = [];
23
+ for (let s = 0; s < 4; s++) {
24
+ let seg = '';
25
+ for (let i = 0; i < 5; i++) seg += chars[Math.floor(Math.random() * chars.length)];
26
+ segments.push(seg);
27
+ }
28
+ return 'CM-' + segments.join('-');
29
+ }
30
+
18
31
  function cors(res) {
19
32
  res.setHeader('Access-Control-Allow-Origin', '*');
20
33
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
@@ -57,30 +70,103 @@ const server = http.createServer(async (req, res) => {
57
70
  const priceId = PRICES[body.plan];
58
71
 
59
72
  if (!priceId) {
60
- return json(res, 400, { error: 'Invalid plan. Use: pro-monthly, pro-yearly, team-monthly, team-yearly' });
73
+ return json(res, 400, { error: 'Invalid plan. Use: shield-monthly, shield-yearly, team-monthly, team-yearly' });
61
74
  }
62
75
 
63
76
  try {
64
- const mode = body.plan === 'pro-skill' ? 'payment' : 'subscription';
65
- const session = await stripe.checkout.sessions.create({
66
- mode,
77
+ const sessionParams = {
78
+ mode: 'subscription',
67
79
  line_items: [{ price: priceId, quantity: 1 }],
68
80
  success_url: `${SITE_URL}/thanks.html?session_id={CHECKOUT_SESSION_ID}`,
69
81
  cancel_url: `${SITE_URL}/#pricing`,
70
82
  allow_promotion_codes: true,
71
- });
83
+ customer_email: body.email || undefined,
84
+ subscription_data: {
85
+ trial_period_days: 30,
86
+ metadata: { plan: body.plan },
87
+ },
88
+ };
89
+ const session = await stripe.checkout.sessions.create(sessionParams);
72
90
  return json(res, 200, { url: session.url });
73
91
  } catch (err) {
74
92
  return json(res, 500, { error: err.message });
75
93
  }
76
94
  }
77
95
 
78
- // Stripe webhook (for future use)
96
+ // Stripe webhook
79
97
  if (req.method === 'POST' && req.url === '/api/webhook') {
80
- // TODO: handle subscription events
98
+ const rawBody = await new Promise((resolve) => {
99
+ let body = '';
100
+ req.on('data', c => body += c);
101
+ req.on('end', () => resolve(body));
102
+ });
103
+
104
+ const sig = req.headers['stripe-signature'];
105
+ const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
106
+
107
+ let event;
108
+ if (endpointSecret && sig) {
109
+ try {
110
+ event = stripe.webhooks.constructEvent(rawBody, sig, endpointSecret);
111
+ } catch (err) {
112
+ console.error('Webhook signature verification failed:', err.message);
113
+ return json(res, 400, { error: 'Invalid signature' });
114
+ }
115
+ } else {
116
+ try { event = JSON.parse(rawBody); }
117
+ catch { return json(res, 400, { error: 'Invalid JSON' }); }
118
+ }
119
+
120
+ console.log(`Webhook: ${event.type}`);
121
+
122
+ switch (event.type) {
123
+ case 'checkout.session.completed': {
124
+ const session = event.data.object;
125
+ const email = session.customer_email || session.customer_details?.email;
126
+ const licenseKey = generateLicenseKey();
127
+ console.log(`New customer: ${email}, license: ${licenseKey}`);
128
+ // TODO: Store in database, send welcome email with license key
129
+ // For now, log it — license fulfillment is manual via email
130
+ licenses.set(licenseKey, {
131
+ email,
132
+ customerId: session.customer,
133
+ subscriptionId: session.subscription,
134
+ plan: session.metadata?.plan || 'unknown',
135
+ createdAt: new Date().toISOString(),
136
+ active: true,
137
+ });
138
+ break;
139
+ }
140
+ case 'customer.subscription.deleted':
141
+ case 'customer.subscription.updated': {
142
+ const sub = event.data.object;
143
+ // Deactivate license if subscription cancelled
144
+ for (const [key, lic] of licenses.entries()) {
145
+ if (lic.subscriptionId === sub.id) {
146
+ lic.active = sub.status === 'active' || sub.status === 'trialing';
147
+ console.log(`License ${key}: active=${lic.active} (status=${sub.status})`);
148
+ }
149
+ }
150
+ break;
151
+ }
152
+ }
153
+
81
154
  return json(res, 200, { received: true });
82
155
  }
83
156
 
157
+ // License validation endpoint (called by CLI)
158
+ if (req.method === 'POST' && req.url === '/api/validate') {
159
+ const body = await readBody(req);
160
+ const key = body.key;
161
+ if (!key) return json(res, 400, { error: 'Missing key' });
162
+
163
+ const lic = licenses.get(key);
164
+ if (!lic || !lic.active) {
165
+ return json(res, 200, { valid: false });
166
+ }
167
+ return json(res, 200, { valid: true, plan: lic.plan, email: lic.email });
168
+ }
169
+
84
170
  json(res, 404, { error: 'Not found' });
85
171
  });
86
172
 
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ClawMoat Alert Delivery System
3
+ *
4
+ * Unified alerting with console, file, and webhook delivery.
5
+ * Rate-limited to avoid alert storms.
6
+ *
7
+ * @module clawmoat/guardian/alerts
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const http = require('http');
13
+ const https = require('https');
14
+
15
+ const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
16
+
17
+ class AlertManager {
18
+ /**
19
+ * @param {Object} opts
20
+ * @param {string[]} [opts.channels] - ['console', 'file', 'webhook']
21
+ * @param {string} [opts.logFile] - Path for file channel (default: audit.log)
22
+ * @param {string} [opts.webhookUrl] - URL for webhook channel
23
+ * @param {number} [opts.rateLimitMs] - Min ms between duplicate alerts (default: 300000 = 5 min)
24
+ * @param {boolean} [opts.quiet] - Suppress console output
25
+ */
26
+ constructor(opts = {}) {
27
+ this.channels = opts.channels || ['console'];
28
+ this.logFile = opts.logFile || 'audit.log';
29
+ this.webhookUrl = opts.webhookUrl || null;
30
+ this.rateLimitMs = opts.rateLimitMs ?? 300000;
31
+ this.quiet = opts.quiet || false;
32
+ this._recentAlerts = new Map(); // key -> timestamp
33
+ this._alertCount = 0;
34
+ }
35
+
36
+ /**
37
+ * Send an alert through configured channels.
38
+ * @param {Object} alert
39
+ * @param {string} alert.severity - 'info' | 'warning' | 'critical'
40
+ * @param {string} alert.type - Alert category
41
+ * @param {string} alert.message - Human-readable message
42
+ * @param {Object} [alert.details] - Additional data
43
+ * @returns {{ delivered: boolean, rateLimited: boolean }}
44
+ */
45
+ send(alert) {
46
+ const key = `${alert.type}:${alert.message}`;
47
+ const now = Date.now();
48
+ const lastSent = this._recentAlerts.get(key);
49
+
50
+ if (lastSent && (now - lastSent) < this.rateLimitMs) {
51
+ return { delivered: false, rateLimited: true };
52
+ }
53
+
54
+ this._recentAlerts.set(key, now);
55
+ this._alertCount++;
56
+
57
+ // Prune old entries periodically
58
+ if (this._recentAlerts.size > 1000) {
59
+ for (const [k, ts] of this._recentAlerts) {
60
+ if (now - ts > this.rateLimitMs) this._recentAlerts.delete(k);
61
+ }
62
+ }
63
+
64
+ const entry = {
65
+ timestamp: new Date().toISOString(),
66
+ severity: alert.severity || 'info',
67
+ type: alert.type || 'unknown',
68
+ message: alert.message || '',
69
+ details: alert.details || null,
70
+ };
71
+
72
+ for (const channel of this.channels) {
73
+ switch (channel) {
74
+ case 'console':
75
+ this._deliverConsole(entry);
76
+ break;
77
+ case 'file':
78
+ this._deliverFile(entry);
79
+ break;
80
+ case 'webhook':
81
+ this._deliverWebhook(entry);
82
+ break;
83
+ }
84
+ }
85
+
86
+ return { delivered: true, rateLimited: false };
87
+ }
88
+
89
+ _deliverConsole(entry) {
90
+ if (this.quiet) return;
91
+ const colors = { info: '\x1b[36m', warning: '\x1b[33m', critical: '\x1b[31m' };
92
+ const icons = { info: 'ℹ️', warning: '⚠️', critical: '🚨' };
93
+ const c = colors[entry.severity] || '';
94
+ const icon = icons[entry.severity] || '•';
95
+ console.error(
96
+ `${icon} ${c}[${entry.severity.toUpperCase()}]\x1b[0m ${entry.type}: ${entry.message}`
97
+ );
98
+ }
99
+
100
+ _deliverFile(entry) {
101
+ try {
102
+ const dir = path.dirname(this.logFile);
103
+ if (dir !== '.' && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
104
+ fs.appendFileSync(this.logFile, JSON.stringify(entry) + '\n');
105
+ } catch {}
106
+ }
107
+
108
+ _deliverWebhook(entry) {
109
+ if (!this.webhookUrl) return;
110
+ try {
111
+ const url = new URL(this.webhookUrl);
112
+ const transport = url.protocol === 'https:' ? https : http;
113
+ const body = JSON.stringify(entry);
114
+ const req = transport.request({
115
+ hostname: url.hostname,
116
+ port: url.port,
117
+ path: url.pathname + url.search,
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
120
+ });
121
+ req.on('error', () => {});
122
+ req.write(body);
123
+ req.end();
124
+ } catch {}
125
+ }
126
+
127
+ /** Get total alerts sent. */
128
+ get count() {
129
+ return this._alertCount;
130
+ }
131
+
132
+ /** Clear rate limit cache. */
133
+ clearRateLimit() {
134
+ this._recentAlerts.clear();
135
+ }
136
+ }
137
+
138
+ module.exports = { AlertManager, SEVERITY_RANK };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * CVE Verifier — validates CVE IDs against GitHub Advisory Database
3
+ * No external dependencies; uses Node.js built-in https module.
4
+ */
5
+
6
+ const https = require('https');
7
+
8
+ class CVEVerifier {
9
+ /**
10
+ * @param {object} [opts]
11
+ * @param {string} [opts.githubToken] - Optional GitHub PAT for higher rate limits
12
+ */
13
+ constructor(opts = {}) {
14
+ this.githubToken = opts.githubToken || process.env.GITHUB_TOKEN || null;
15
+ }
16
+
17
+ /**
18
+ * Validate CVE ID format
19
+ * @param {string} cveId
20
+ * @returns {boolean}
21
+ */
22
+ static isValidCVEFormat(cveId) {
23
+ return /^CVE-\d{4}-\d{4,}$/.test(cveId);
24
+ }
25
+
26
+ /**
27
+ * Fetch advisory data from GitHub Advisory Database API
28
+ * @param {string} cveId e.g. "CVE-2026-26960"
29
+ * @returns {Promise<object>} { valid, severity, summary, publishedAt, references, affectedPackages, raw }
30
+ */
31
+ async lookup(cveId) {
32
+ if (!CVEVerifier.isValidCVEFormat(cveId)) {
33
+ return { valid: false, error: `Invalid CVE format: ${cveId}` };
34
+ }
35
+
36
+ const url = `https://api.github.com/advisories?cve_id=${encodeURIComponent(cveId)}`;
37
+ let data;
38
+ try {
39
+ data = await this._fetch(url);
40
+ } catch (err) {
41
+ return { valid: false, error: `API request failed: ${err.message}` };
42
+ }
43
+
44
+ if (!Array.isArray(data) || data.length === 0) {
45
+ return { valid: false, cveId, severity: null, summary: null, publishedAt: null, references: [], affectedPackages: [] };
46
+ }
47
+
48
+ const advisory = data[0];
49
+ return {
50
+ valid: true,
51
+ cveId,
52
+ severity: advisory.severity || null,
53
+ summary: advisory.summary || null,
54
+ publishedAt: advisory.published_at || null,
55
+ references: (advisory.references || []).map(r => (typeof r === 'string' ? r : r.url)).filter(Boolean),
56
+ affectedPackages: (advisory.vulnerabilities || []).map(v => ({
57
+ ecosystem: v.package?.ecosystem || null,
58
+ name: v.package?.name || null,
59
+ vulnerableRange: v.vulnerable_version_range || null,
60
+ })),
61
+ ghsaId: advisory.ghsa_id || null,
62
+ htmlUrl: advisory.html_url || null,
63
+ raw: advisory,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Check whether a URL is a legitimate GitHub advisory link
69
+ * @param {string} url
70
+ * @returns {{ legitimate: boolean, reason: string }}
71
+ */
72
+ static checkAdvisoryUrl(url) {
73
+ try {
74
+ const parsed = new URL(url);
75
+ const isGitHub = parsed.hostname === 'github.com' && parsed.pathname.startsWith('/advisories/');
76
+ if (isGitHub) {
77
+ return { legitimate: true, reason: 'URL points to official GitHub Advisory Database' };
78
+ }
79
+ return { legitimate: false, reason: `Domain "${parsed.hostname}" is not github.com/advisories` };
80
+ } catch {
81
+ return { legitimate: false, reason: 'Invalid URL' };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Verify a CVE + optional suspicious URL together
87
+ * @param {string} cveId
88
+ * @param {string} [suspiciousUrl]
89
+ * @returns {Promise<object>}
90
+ */
91
+ async verify(cveId, suspiciousUrl) {
92
+ const result = await this.lookup(cveId);
93
+ if (suspiciousUrl) {
94
+ result.urlCheck = CVEVerifier.checkAdvisoryUrl(suspiciousUrl);
95
+ }
96
+ return result;
97
+ }
98
+
99
+ /** @private */
100
+ _fetch(url) {
101
+ return new Promise((resolve, reject) => {
102
+ const headers = {
103
+ 'User-Agent': 'ClawMoat-CVE-Verifier',
104
+ 'Accept': 'application/vnd.github+json',
105
+ };
106
+ if (this.githubToken) {
107
+ headers['Authorization'] = `Bearer ${this.githubToken}`;
108
+ }
109
+
110
+ https.get(url, { headers }, (res) => {
111
+ let body = '';
112
+ res.on('data', chunk => body += chunk);
113
+ res.on('end', () => {
114
+ if (res.statusCode !== 200) {
115
+ return reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
116
+ }
117
+ try {
118
+ resolve(JSON.parse(body));
119
+ } catch (e) {
120
+ reject(new Error(`JSON parse error: ${e.message}`));
121
+ }
122
+ });
123
+ res.on('error', reject);
124
+ }).on('error', reject);
125
+ });
126
+ }
127
+ }
128
+
129
+ module.exports = { CVEVerifier };
@@ -22,8 +22,10 @@
22
22
  * const log = guardian.audit();
23
23
  */
24
24
 
25
+ const fs = require('fs');
25
26
  const path = require('path');
26
27
  const os = require('os');
28
+ const crypto = require('crypto');
27
29
  const { SecurityLogger } = require('../utils/logger');
28
30
 
29
31
  // ─── Permission Tiers ───────────────────────────────────────────────
@@ -539,4 +541,148 @@ class HostGuardian {
539
541
  }
540
542
  }
541
543
 
542
- module.exports = { HostGuardian, TIERS, FORBIDDEN_ZONES, DANGEROUS_COMMANDS, SAFE_COMMANDS };
544
+ // ─── Credential Monitor ─────────────────────────────────────────
545
+
546
+ class CredentialMonitor {
547
+ /**
548
+ * Watch ~/.openclaw/credentials/ for file access and modifications.
549
+ * @param {Object} opts
550
+ * @param {string} [opts.credDir] - Credentials directory path
551
+ * @param {Function} [opts.onAlert] - Alert callback
552
+ * @param {boolean} [opts.quiet] - Suppress console output
553
+ */
554
+ constructor(opts = {}) {
555
+ this.credDir = opts.credDir || path.join(os.homedir(), '.openclaw', 'credentials');
556
+ this.onAlert = opts.onAlert || null;
557
+ this.quiet = opts.quiet || false;
558
+ this.watcher = null;
559
+ this.fileHashes = {};
560
+ }
561
+
562
+ /**
563
+ * Hash all credential files and start watching.
564
+ * @returns {{ files: number, watching: boolean }}
565
+ */
566
+ start() {
567
+ // Initial hash of all credential files
568
+ this._hashAllFiles();
569
+
570
+ if (!fs.existsSync(this.credDir)) {
571
+ return { files: 0, watching: false };
572
+ }
573
+
574
+ this.watcher = fs.watch(this.credDir, (eventType, filename) => {
575
+ if (!filename) return;
576
+ const filePath = path.join(this.credDir, filename);
577
+
578
+ if (eventType === 'change') {
579
+ const oldHash = this.fileHashes[filename];
580
+ const newHash = this._hashFile(filePath);
581
+
582
+ if (oldHash && newHash && oldHash !== newHash) {
583
+ this._alert({
584
+ severity: 'critical',
585
+ type: 'credential_modified',
586
+ message: `Credential file modified: ${filename}`,
587
+ details: { file: filename, oldHash, newHash },
588
+ });
589
+ this.fileHashes[filename] = newHash;
590
+ } else if (!oldHash && newHash) {
591
+ this._alert({
592
+ severity: 'warning',
593
+ type: 'credential_accessed',
594
+ message: `Credential file accessed: ${filename}`,
595
+ details: { file: filename },
596
+ });
597
+ this.fileHashes[filename] = newHash;
598
+ }
599
+ }
600
+
601
+ if (eventType === 'rename') {
602
+ if (fs.existsSync(filePath)) {
603
+ // File created
604
+ const hash = this._hashFile(filePath);
605
+ this._alert({
606
+ severity: 'warning',
607
+ type: 'credential_created',
608
+ message: `New credential file: ${filename}`,
609
+ details: { file: filename, hash },
610
+ });
611
+ this.fileHashes[filename] = hash;
612
+ } else {
613
+ // File deleted
614
+ this._alert({
615
+ severity: 'critical',
616
+ type: 'credential_deleted',
617
+ message: `Credential file deleted: ${filename}`,
618
+ details: { file: filename },
619
+ });
620
+ delete this.fileHashes[filename];
621
+ }
622
+ }
623
+ });
624
+
625
+ return { files: Object.keys(this.fileHashes).length, watching: true };
626
+ }
627
+
628
+ stop() {
629
+ if (this.watcher) {
630
+ this.watcher.close();
631
+ this.watcher = null;
632
+ }
633
+ }
634
+
635
+ /** Verify credential file integrity against stored hashes. */
636
+ verify() {
637
+ const results = { ok: true, changed: [], missing: [] };
638
+ for (const [filename, storedHash] of Object.entries(this.fileHashes)) {
639
+ const filePath = path.join(this.credDir, filename);
640
+ const currentHash = this._hashFile(filePath);
641
+ if (!currentHash) {
642
+ results.missing.push(filename);
643
+ results.ok = false;
644
+ } else if (currentHash !== storedHash) {
645
+ results.changed.push(filename);
646
+ results.ok = false;
647
+ }
648
+ }
649
+ return results;
650
+ }
651
+
652
+ /** Get current file hashes. */
653
+ getHashes() {
654
+ return { ...this.fileHashes };
655
+ }
656
+
657
+ _hashAllFiles() {
658
+ if (!fs.existsSync(this.credDir)) return;
659
+ try {
660
+ const files = fs.readdirSync(this.credDir);
661
+ for (const f of files) {
662
+ const hash = this._hashFile(path.join(this.credDir, f));
663
+ if (hash) this.fileHashes[f] = hash;
664
+ }
665
+ } catch {}
666
+ }
667
+
668
+ _hashFile(filePath) {
669
+ try {
670
+ const content = fs.readFileSync(filePath);
671
+ return crypto.createHash('sha256').update(content).digest('hex');
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+
677
+ _alert(alert) {
678
+ if (!this.quiet) {
679
+ const icons = { info: 'ℹ️', warning: '⚠️', critical: '🚨' };
680
+ console.error(`${icons[alert.severity] || '•'} [CredentialMonitor] ${alert.message}`);
681
+ }
682
+ if (this.onAlert) this.onAlert(alert);
683
+ }
684
+ }
685
+
686
+ const { CVEVerifier } = require('./cve-verify');
687
+
688
+ module.exports = { HostGuardian, CredentialMonitor, CVEVerifier, TIERS, FORBIDDEN_ZONES, DANGEROUS_COMMANDS, SAFE_COMMANDS };