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/README.md +31 -0
- package/bin/clawmoat.js +483 -1
- package/docs/blog/index.html +24 -0
- package/docs/blog/supply-chain-agents.html +166 -0
- package/docs/blog/supply-chain-agents.md +79 -0
- package/docs/index.html +131 -57
- package/package.json +1 -1
- package/server/index.js +100 -14
- package/src/guardian/alerts.js +138 -0
- package/src/guardian/cve-verify.js +129 -0
- package/src/guardian/index.js +147 -1
- package/src/guardian/insider-threat.js +498 -0
- package/src/guardian/network-log.js +281 -0
- package/src/guardian/skill-integrity.js +290 -0
- package/src/middleware/openclaw.js +104 -2
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
|
-
//
|
|
10
|
-
'
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
'
|
|
14
|
-
'team-
|
|
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:
|
|
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
|
|
65
|
-
|
|
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
|
|
96
|
+
// Stripe webhook
|
|
79
97
|
if (req.method === 'POST' && req.url === '/api/webhook') {
|
|
80
|
-
|
|
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 };
|
package/src/guardian/index.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|