dsh-sensitive-guard 0.0.0-stage → 0.3.2
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/CHANGELOG.md +91 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/client.js +718 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/index.js +1183 -0
- package/locale/en.json +25 -0
- package/locale/zh.json +25 -0
- package/package.json +38 -4
- package/test.mjs +376 -0
package/index.js
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-sensitive-guard — WorkBuddy-style 敏感保护 for DSH.
|
|
3
|
+
*
|
|
4
|
+
* Ingress: `agent/pre-step` (user messages) and `tools/post-execute` (tool
|
|
5
|
+
* results) replace detected credentials with stable `<SG_hex12>` placeholders
|
|
6
|
+
* before anything is committed to the session log, so model context never
|
|
7
|
+
* carries plaintext. The plaintext goes into a local AES-256-GCM vault.
|
|
8
|
+
*
|
|
9
|
+
* Egress: the platform's `tools/pre-execute` only allows allow/deny/cancel/ask
|
|
10
|
+
* (no argument rewrite), so secret use at execution time goes through
|
|
11
|
+
* `sensitive_guard_export`, which writes the plaintext to a 0600 file the
|
|
12
|
+
* command consumes (`$(cat …)`). Exported files expire after EXPORT_TTL_MS.
|
|
13
|
+
*
|
|
14
|
+
* Human reveal happens in Settings via `POST /guard/reveal`, which is
|
|
15
|
+
* same-origin checked, audited, and restricted to loopback by default. LAN
|
|
16
|
+
* access is opt-in through `allowCidrs` in rules.json, and even then the Host
|
|
17
|
+
* header must be an IP literal (blocks DNS rebinding) and POSTs must be JSON
|
|
18
|
+
* (forces a CORS preflight the server never answers). `/guard/vault` carries
|
|
19
|
+
* the 8-char preview, so it gets the same gate; tool output deliberately
|
|
20
|
+
* carries no preview at all, since tool results land in model context.
|
|
21
|
+
*
|
|
22
|
+
* `llm/stream` scrubs outbound `text-delta` chunks as a backstop for log
|
|
23
|
+
* content written before this plugin existed. It must stay an async generator:
|
|
24
|
+
* the host `yield*`s its return value.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import { isIP } from 'node:net';
|
|
31
|
+
import os from 'node:os';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
|
|
34
|
+
const VAULT_ROUTE = '/guard/vault';
|
|
35
|
+
const REVEAL_ROUTE = '/guard/reveal';
|
|
36
|
+
const SETTINGS_ROUTE = '/guard/settings';
|
|
37
|
+
const PLUGIN_VERSION = '0.3.2';
|
|
38
|
+
|
|
39
|
+
/** Stable placeholder form: <SG_ + 12 hex chars of sha256(plaintext) + >. */
|
|
40
|
+
const PH_EXACT = /^<SG_[0-9a-f]{12}>$/;
|
|
41
|
+
const PH_HINT = '<SG_';
|
|
42
|
+
const HASH_EXACT = /^[0-9a-f]{12}$/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Vendor prefixes are not the secret — the tail is. Keeping `sk-` / `ghp_` in
|
|
46
|
+
* place keeps the sentence readable and tells you which service leaked, while
|
|
47
|
+
* the placeholder covers only the part that actually authenticates.
|
|
48
|
+
*/
|
|
49
|
+
const KEEP_PREFIX = /^(sk_live_|sk_test_|rk_live_|rk_test_|pk_live_|pk_test_|sk-|sk_|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-|npm_|hf_|AKIA|ASIA|xox[abprs]-|AIza|whsec_|SG\.)/;
|
|
50
|
+
|
|
51
|
+
/** Prefix to leave in the text; empty when nothing meaningful remains to mask. */
|
|
52
|
+
function prefixOf(value) {
|
|
53
|
+
if (typeof value !== 'string') return '';
|
|
54
|
+
const m = KEEP_PREFIX.exec(value);
|
|
55
|
+
if (!m) return '';
|
|
56
|
+
return value.length - m[0].length >= 6 ? m[0] : '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Preview keeps at most the first 8 chars, and only for long values. */
|
|
60
|
+
const PREVIEW_MIN = 20;
|
|
61
|
+
/** Vault growth cap; least-recently-hit entries are evicted past this. */
|
|
62
|
+
const MAX_VAULT_ENTRIES = 500;
|
|
63
|
+
/** Coalesce vault writes — every capture used to rewrite the whole file. */
|
|
64
|
+
const PERSIST_DEBOUNCE_MS = 250;
|
|
65
|
+
/** Exported plaintext files expire; they are the only plaintext on disk. */
|
|
66
|
+
const EXPORT_TTL_MS = 15 * 60 * 1000;
|
|
67
|
+
/** Per-string scan window: a blocking regex over a huge blob would stall the host. */
|
|
68
|
+
const MAX_SCAN_CHARS = 200000;
|
|
69
|
+
|
|
70
|
+
/* ---------------------------------------------------------------- vault -- */
|
|
71
|
+
|
|
72
|
+
let dataDir_ = null;
|
|
73
|
+
function dataDir() {
|
|
74
|
+
if (dataDir_) return dataDir_;
|
|
75
|
+
const profileDir = process.env.DSH_PROFILE_DIR;
|
|
76
|
+
const base = profileDir
|
|
77
|
+
|| (process.env.DSH_PROFILE
|
|
78
|
+
? path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'profiles', process.env.DSH_PROFILE)
|
|
79
|
+
: null)
|
|
80
|
+
|| path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'));
|
|
81
|
+
dataDir_ = path.join(base, 'sensitive-guard');
|
|
82
|
+
return dataDir_;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const vault = { key: null, entries: new Map(), byPh: new Map(), loaded: false, keyError: null };
|
|
86
|
+
const stats = {
|
|
87
|
+
maskedValues: 0, maskedRuns: 0, outboundScrubs: 0, reveals: 0, exports: 0,
|
|
88
|
+
failOpen: 0, evicted: 0, exportCleanups: 0,
|
|
89
|
+
};
|
|
90
|
+
const audit = [];
|
|
91
|
+
let lastError = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Protection can be paused from Settings. Two shapes:
|
|
95
|
+
* - `until` a timestamp (auto-resumes, survives a restart)
|
|
96
|
+
* - `untilRestart` (deliberately not persisted — a restart re-arms the guard)
|
|
97
|
+
*/
|
|
98
|
+
const pause = { until: 0, untilRestart: false };
|
|
99
|
+
|
|
100
|
+
function pauseFile() { return path.join(dataDir(), 'pause.json'); }
|
|
101
|
+
|
|
102
|
+
function loadPause() {
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(fs.readFileSync(pauseFile(), 'utf8'));
|
|
105
|
+
// `untilRestart` is deliberately not restored: a restart re-arms the guard.
|
|
106
|
+
if (raw && Number.isFinite(raw.until) && raw.until > Date.now()) pause.until = raw.until;
|
|
107
|
+
} catch { /* no pause file yet */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function persistPause() {
|
|
111
|
+
try {
|
|
112
|
+
fs.mkdirSync(dataDir(), { recursive: true });
|
|
113
|
+
fs.writeFileSync(pauseFile(), JSON.stringify({ until: pause.until }), { mode: 0o600 });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
lastError = `pause persist: ${String((error && error.message) || error)}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @returns remaining ms while paused, 0 when protection is active. */
|
|
120
|
+
function pausedMs() {
|
|
121
|
+
if (pause.untilRestart) return Number.POSITIVE_INFINITY;
|
|
122
|
+
return Math.max(0, pause.until - Date.now());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function vaultFile() { return path.join(dataDir(), 'vault.json'); }
|
|
126
|
+
function keyFile() { return path.join(dataDir(), 'vault.key'); }
|
|
127
|
+
function rulesFile() { return path.join(dataDir(), 'rules.json'); }
|
|
128
|
+
|
|
129
|
+
let keyAclWarned = false;
|
|
130
|
+
|
|
131
|
+
/** POSIX mode bits are a no-op on Windows — tighten the DACL explicitly there. */
|
|
132
|
+
function hardenFilePermissions(file) {
|
|
133
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
134
|
+
if (process.platform !== 'win32') return;
|
|
135
|
+
const user = process.env.USERNAME;
|
|
136
|
+
if (!user) return;
|
|
137
|
+
try {
|
|
138
|
+
execFileSync('icacls', [file, '/inheritance:r', '/grant:r', `${user}:F`], { stdio: 'ignore', windowsHide: true });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!keyAclWarned) {
|
|
141
|
+
keyAclWarned = true;
|
|
142
|
+
lastError = `vault.key ACL: ${String((error && error.message) || error)}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function loadKey() {
|
|
148
|
+
if (vault.key) return vault.key;
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(dataDir(), { recursive: true });
|
|
151
|
+
try {
|
|
152
|
+
const raw = fs.readFileSync(keyFile());
|
|
153
|
+
if (raw.length !== 32) throw new Error('vault.key is not 32 bytes');
|
|
154
|
+
vault.key = raw;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error && error.code !== 'ENOENT') throw error;
|
|
157
|
+
const fresh = randomBytes(32);
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(keyFile(), fresh, { flag: 'wx', mode: 0o600 });
|
|
160
|
+
vault.key = fresh;
|
|
161
|
+
} catch (writeError) {
|
|
162
|
+
// Another process created the key between our read and write: adopt it
|
|
163
|
+
// instead of failing open for the rest of the session.
|
|
164
|
+
if (!writeError || writeError.code !== 'EEXIST') throw writeError;
|
|
165
|
+
const raw = fs.readFileSync(keyFile());
|
|
166
|
+
if (raw.length !== 32) throw new Error('vault.key is not 32 bytes');
|
|
167
|
+
vault.key = raw;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
hardenFilePermissions(keyFile());
|
|
171
|
+
return vault.key;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
vault.keyError = String((error && error.message) || error);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function encrypt(plaintext) {
|
|
179
|
+
const iv = randomBytes(12);
|
|
180
|
+
const cipher = createCipheriv('aes-256-gcm', loadKey(), iv);
|
|
181
|
+
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
182
|
+
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${ct.toString('base64')}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function decrypt(payload) {
|
|
186
|
+
const [tag, ivB64, tagB64, ctB64] = String(payload).split(':');
|
|
187
|
+
if (tag !== 'v1') throw new Error('unknown vault version');
|
|
188
|
+
const decipher = createDecipheriv('aes-256-gcm', loadKey(), Buffer.from(ivB64, 'base64'));
|
|
189
|
+
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
|
190
|
+
return Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64')), decipher.final()]).toString('utf8');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hashOf(value) {
|
|
194
|
+
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 12);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loadVault() {
|
|
198
|
+
if (vault.loaded) return;
|
|
199
|
+
vault.loaded = true;
|
|
200
|
+
try {
|
|
201
|
+
const raw = JSON.parse(fs.readFileSync(vaultFile(), 'utf8'));
|
|
202
|
+
for (const entry of Array.isArray(raw && raw.entries) ? raw.entries : []) {
|
|
203
|
+
if (!entry || typeof entry.enc !== 'string') continue;
|
|
204
|
+
if (typeof entry.hash !== 'string' || !HASH_EXACT.test(entry.hash)) continue;
|
|
205
|
+
if (typeof entry.ph !== 'string' || !PH_EXACT.test(entry.ph)) continue;
|
|
206
|
+
vault.entries.set(entry.hash, entry);
|
|
207
|
+
vault.byPh.set(entry.ph, entry.hash);
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error && error.code !== 'ENOENT') lastError = `vault load: ${String(error.message || error)}`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let persistTimer = null;
|
|
215
|
+
|
|
216
|
+
function persistVault(immediate) {
|
|
217
|
+
if (!immediate) {
|
|
218
|
+
if (persistTimer) return;
|
|
219
|
+
persistTimer = setTimeout(() => { persistTimer = null; persistVault(true); }, PERSIST_DEBOUNCE_MS);
|
|
220
|
+
if (typeof persistTimer.unref === 'function') persistTimer.unref();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
fs.mkdirSync(dataDir(), { recursive: true });
|
|
225
|
+
const payload = JSON.stringify({ version: 1, entries: [...vault.entries.values()] });
|
|
226
|
+
const tmp = `${vaultFile()}.tmp`;
|
|
227
|
+
fs.writeFileSync(tmp, payload, { mode: 0o600 });
|
|
228
|
+
fs.renameSync(tmp, vaultFile());
|
|
229
|
+
} catch (error) {
|
|
230
|
+
lastError = `vault persist: ${String((error && error.message) || error)}`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Evict least-recently-hit entries so the vault cannot grow without bound. */
|
|
235
|
+
function trimVault() {
|
|
236
|
+
if (vault.entries.size <= MAX_VAULT_ENTRIES) return;
|
|
237
|
+
const ordered = [...vault.entries.values()]
|
|
238
|
+
.sort((a, b) => String(a.lastHitAt || a.createdAt).localeCompare(String(b.lastHitAt || b.createdAt)));
|
|
239
|
+
for (let i = 0; i < ordered.length && vault.entries.size > MAX_VAULT_ENTRIES; i += 1) {
|
|
240
|
+
const victim = ordered[i];
|
|
241
|
+
vault.entries.delete(victim.hash);
|
|
242
|
+
vault.byPh.delete(victim.ph);
|
|
243
|
+
stats.evicted += 1;
|
|
244
|
+
noteAudit('evict', { ph: victim.ph });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function noteAudit(kind, extra) {
|
|
249
|
+
audit.push({ at: new Date().toISOString(), kind, ...extra });
|
|
250
|
+
if (audit.length > 200) audit.splice(0, audit.length - 200);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Map plaintext → stable placeholder, encrypting into the vault on first sight. */
|
|
254
|
+
function remember(plaintext, rule, origin, label) {
|
|
255
|
+
loadVault();
|
|
256
|
+
const hash = hashOf(plaintext);
|
|
257
|
+
let entry = vault.entries.get(hash);
|
|
258
|
+
if (entry) {
|
|
259
|
+
entry.hits = (entry.hits || 0) + 1;
|
|
260
|
+
entry.lastHitAt = new Date().toISOString();
|
|
261
|
+
return entry.ph;
|
|
262
|
+
}
|
|
263
|
+
const ph = `<SG_${hash}>`;
|
|
264
|
+
entry = {
|
|
265
|
+
hash,
|
|
266
|
+
ph,
|
|
267
|
+
enc: encrypt(plaintext),
|
|
268
|
+
prefix: prefixOf(plaintext),
|
|
269
|
+
preview: plaintext.length >= PREVIEW_MIN ? `${plaintext.slice(0, 8)}…` : '',
|
|
270
|
+
rule: String(rule || 'unknown'),
|
|
271
|
+
label: String(label || rule || 'unknown'),
|
|
272
|
+
origin: String(origin || 'unknown'),
|
|
273
|
+
createdAt: new Date().toISOString(),
|
|
274
|
+
hits: 1,
|
|
275
|
+
lastHitAt: new Date().toISOString(),
|
|
276
|
+
};
|
|
277
|
+
vault.entries.set(hash, entry);
|
|
278
|
+
vault.byPh.set(ph, hash);
|
|
279
|
+
stats.maskedValues += 1;
|
|
280
|
+
noteAudit('capture', { ph, rule: entry.rule, origin: entry.origin });
|
|
281
|
+
trimVault();
|
|
282
|
+
persistVault();
|
|
283
|
+
return ph;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Metadata for the Settings UI — may carry the 8-char preview. */
|
|
287
|
+
function entryView(entry) {
|
|
288
|
+
return {
|
|
289
|
+
ph: entry.ph,
|
|
290
|
+
preview: entry.preview,
|
|
291
|
+
rule: entry.rule,
|
|
292
|
+
label: entry.label || entry.rule,
|
|
293
|
+
origin: entry.origin,
|
|
294
|
+
createdAt: entry.createdAt,
|
|
295
|
+
lastHitAt: entry.lastHitAt,
|
|
296
|
+
hits: entry.hits || 1,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Metadata safe to hand a model. Deliberately drops `preview`: the first 8
|
|
302
|
+
* characters of a secret are still secret, and a tool result lands in exactly
|
|
303
|
+
* the context this plugin exists to keep clean.
|
|
304
|
+
*/
|
|
305
|
+
function entryViewForModel(entry) {
|
|
306
|
+
return {
|
|
307
|
+
ph: entry.ph,
|
|
308
|
+
rule: entry.rule,
|
|
309
|
+
origin: entry.origin,
|
|
310
|
+
createdAt: entry.createdAt,
|
|
311
|
+
lastHitAt: entry.lastHitAt,
|
|
312
|
+
hits: entry.hits || 1,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ---------------------------------------------------------------- rules -- */
|
|
317
|
+
|
|
318
|
+
const BUILTIN_RULES = [
|
|
319
|
+
{ id: 'openai-key', group: 0, re: /\bsk-[A-Za-z0-9_-]{16,}\b/g },
|
|
320
|
+
{ id: 'openai-live', group: 0, re: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
321
|
+
{ id: 'github-token', group: 0, re: /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,})\b/g },
|
|
322
|
+
{ id: 'gitlab-token', group: 0, re: /\bglpat-[A-Za-z0-9_-]{16,}\b/g },
|
|
323
|
+
{ id: 'npm-token', group: 0, re: /\bnpm_[A-Za-z0-9]{16,}\b/g },
|
|
324
|
+
{ id: 'hf-token', group: 0, re: /\bhf_[A-Za-z0-9]{16,}\b/g },
|
|
325
|
+
{ id: 'aws-key', group: 0, re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
326
|
+
{ id: 'slack-token', group: 0, re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
327
|
+
{ id: 'google-key', group: 0, re: /\bAIza[0-9A-Za-z_-]{30,}\b/g },
|
|
328
|
+
{ id: 'stripe-key', group: 0, re: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
329
|
+
{ id: 'sendgrid-key', group: 0, re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g },
|
|
330
|
+
{ id: 'segment-secret', group: 0, re: /\bwhsec_[A-Za-z0-9]{16,}\b/g },
|
|
331
|
+
{ id: 'jwt', group: 0, re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
332
|
+
{ id: 'private-key-block', group: 0, re: /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]{8,}?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/g },
|
|
333
|
+
{ id: 'keyword-assign', group: 3, re: /\b(password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|client[_-]?secret)(\s*[=:]\s*["']?)([^\s"',;)}\]]{8,})/gi },
|
|
334
|
+
// Prose form: people write "我的 api key 是 sk-…" far more often than they
|
|
335
|
+
// write `api_key=…`. Chinese separators and keywords are included because the
|
|
336
|
+
// assign-only rule silently missed every sentence-shaped secret. The optional
|
|
337
|
+
// `[A-Za-z_]{0,12}` tail catches env-style names such as SECRET_KEY or
|
|
338
|
+
// DB_PASSWORD_HASH, where `secret` is glued to a suffix by an underscore.
|
|
339
|
+
{ id: 'keyword-prose', group: 2, re: /(password|passwd|pwd|secret|token|api[\s_-]?key|apikey|access[\s_-]?key|client[\s_-]?secret|密码|口令|密钥|令牌|凭证|私钥)[A-Za-z_]{0,12}\s*(?:[:=:]|是|为|就是|is)\s*["'`]?([^\s"'`,;)}\]]{8,})/gi },
|
|
340
|
+
// Opaque bearer credentials have no vendor prefix and no `key: value` shape.
|
|
341
|
+
{ id: 'bearer-token', group: 1, re: /\bBearer\s+([A-Za-z0-9\-._~+/]{12,}={0,2})/gi },
|
|
342
|
+
// Command-line passwords. Scoped to clients whose `-p` / `-a` really means a
|
|
343
|
+
// password, so `docker -p 8080:80` and `-path` never match.
|
|
344
|
+
{ id: 'cli-password', group: 1, re: /\b(?:mysql|mysqldump|mysqladmin|redis-cli|mongo|mongosh)\b[^\n]{0,80}?\s(?:-p|--password[= ]|-a)\s*["']?([^\s"'`-]\S{3,})/gi },
|
|
345
|
+
{ id: 'url-password', group: 1, re: /\b[a-z][a-z0-9+.-]{1,15}:\/\/[^\s/:@?#]{1,256}:([^\s/?@#]{6,})@/gi },
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* `d` gives exact group offsets. Recovering an offset with
|
|
350
|
+
* `match[0].indexOf(value)` picks the wrong place whenever the value also
|
|
351
|
+
* occurs earlier in the match (a URL whose password repeats the user name).
|
|
352
|
+
*/
|
|
353
|
+
function withIndices(re) {
|
|
354
|
+
return re.flags.includes('d') ? re : new RegExp(re.source, `${re.flags}d`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Compiling these at module scope means a bad regex would throw during import,
|
|
358
|
+
// before `apply` can catch anything. Keep it contained and fall back to the
|
|
359
|
+
// location-recovery path (indexOf) for whatever fails.
|
|
360
|
+
try {
|
|
361
|
+
for (const rule of BUILTIN_RULES) rule.re = withIndices(rule.re);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.warn('[sensitive-guard] rule precompile failed:', error && error.message);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let fileRules = { list: [], outboundScrub: true, entropy: false, entropyThreshold: 4.5, allowCidrs: [], error: null, mtime: 0, checkedAt: 0 };
|
|
367
|
+
|
|
368
|
+
function reloadRules(force) {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
if (!force && now - fileRules.checkedAt < 2000) return;
|
|
371
|
+
fileRules.checkedAt = now;
|
|
372
|
+
let stat;
|
|
373
|
+
try { stat = fs.statSync(rulesFile()); } catch { return; }
|
|
374
|
+
if (!force && stat.mtimeMs === fileRules.mtime) return;
|
|
375
|
+
fileRules.mtime = stat.mtimeMs;
|
|
376
|
+
try {
|
|
377
|
+
const raw = JSON.parse(fs.readFileSync(rulesFile(), 'utf8'));
|
|
378
|
+
const list = [];
|
|
379
|
+
for (const item of Array.isArray(raw && raw.extraRules) ? raw.extraRules : []) {
|
|
380
|
+
if (!item || typeof item.pattern !== 'string') continue;
|
|
381
|
+
try {
|
|
382
|
+
list.push({
|
|
383
|
+
id: String(item.id || 'custom'),
|
|
384
|
+
group: Number.isInteger(item.group) ? item.group : 0,
|
|
385
|
+
re: withIndices(new RegExp(item.pattern, typeof item.flags === 'string' ? item.flags.replace(/[gyd]/g, '') + 'g' : 'g')),
|
|
386
|
+
});
|
|
387
|
+
} catch { /* skip invalid user rule */ }
|
|
388
|
+
}
|
|
389
|
+
fileRules.list = list;
|
|
390
|
+
fileRules.outboundScrub = raw && raw.outboundScrub === false ? false : true;
|
|
391
|
+
fileRules.entropy = raw && raw.entropy === true;
|
|
392
|
+
fileRules.entropyThreshold = raw && Number.isFinite(raw.entropyThreshold) ? raw.entropyThreshold : 4.5;
|
|
393
|
+
fileRules.disabled = new Set(Array.isArray(raw && raw.disabled) ? raw.disabled.map(String) : []);
|
|
394
|
+
// Opt-in LAN exposure for the reveal routes. Empty (the default) keeps them
|
|
395
|
+
// loopback-only. Entries are CIDRs, e.g. ["192.168.0.0/16"].
|
|
396
|
+
const wanted = Array.isArray(raw && raw.allowCidrs) ? raw.allowCidrs.map((item) => String(item).trim()) : [];
|
|
397
|
+
const accepted = wanted.filter((item) => parseCidr(item) !== null);
|
|
398
|
+
const rejected = wanted.filter((item) => parseCidr(item) === null);
|
|
399
|
+
fileRules.allowCidrs = accepted;
|
|
400
|
+
fileRules.error = null;
|
|
401
|
+
if (rejected.length > 0) {
|
|
402
|
+
fileRules.error = `allowCidrs 忽略无效项: ${rejected.join(', ')}(需形如 192.168.0.0/16,且不接受 /0)`;
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
fileRules.error = String((error && error.message) || error);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function disabledSet() {
|
|
410
|
+
return fileRules.disabled instanceof Set ? fileRules.disabled : new Set();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function activeRules() {
|
|
414
|
+
reloadRules(false);
|
|
415
|
+
const disabled = disabledSet();
|
|
416
|
+
return [...BUILTIN_RULES, ...fileRules.list].filter((rule) => !disabled.has(rule.id));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/* -------------------------------------------------------------- scrubbing -- */
|
|
420
|
+
|
|
421
|
+
/** Shannon entropy in bits per character — high for random-looking blobs. */
|
|
422
|
+
function shannon(value) {
|
|
423
|
+
const freq = new Map();
|
|
424
|
+
for (const ch of value) freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
425
|
+
let bits = 0;
|
|
426
|
+
for (const n of freq.values()) {
|
|
427
|
+
const p = n / value.length;
|
|
428
|
+
bits -= p * Math.log2(p);
|
|
429
|
+
}
|
|
430
|
+
return bits;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Opt-in entropy detection for unprefixed blobs (a random token pasted with no
|
|
435
|
+
* context). Off by default: base64 images, file hashes and UUIDs all score high
|
|
436
|
+
* and would be masked for no reason. Enable with `"entropy": true` in rules.json.
|
|
437
|
+
*/
|
|
438
|
+
function entropyHits(text) {
|
|
439
|
+
const out = [];
|
|
440
|
+
const re = /[A-Za-z0-9+/=_-]{24,}/g;
|
|
441
|
+
const threshold = Number.isFinite(fileRules.entropyThreshold) ? fileRules.entropyThreshold : 4.5;
|
|
442
|
+
let m;
|
|
443
|
+
while ((m = re.exec(text)) !== null) {
|
|
444
|
+
if (m[0].includes(PH_HINT)) continue;
|
|
445
|
+
if (shannon(m[0]) >= threshold) {
|
|
446
|
+
out.push({ offset: m.index, end: m.index + m[0].length, value: m[0], rule: 'high-entropy', label: 'secret' });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Non-overlapping hits: [{offset, end, value, rule}]. Already-masked values are skipped. */
|
|
453
|
+
function findHits(text) {
|
|
454
|
+
// A pathological custom rule can backtrack for minutes on a huge blob, and this
|
|
455
|
+
// runs synchronously inside a hook — cap the window rather than freeze the host.
|
|
456
|
+
const scan = text.length > MAX_SCAN_CHARS ? text.slice(0, MAX_SCAN_CHARS) : text;
|
|
457
|
+
const hits = [];
|
|
458
|
+
for (const rule of activeRules()) {
|
|
459
|
+
rule.re.lastIndex = 0;
|
|
460
|
+
let match;
|
|
461
|
+
while ((match = rule.re.exec(scan)) !== null) {
|
|
462
|
+
if (match[0].length === 0) { rule.re.lastIndex += 1; continue; }
|
|
463
|
+
const value = match[rule.group];
|
|
464
|
+
if (typeof value !== 'string' || value.length < 6) continue;
|
|
465
|
+
if (value.includes(PH_HINT)) continue;
|
|
466
|
+
const range = match.indices && match.indices[rule.group];
|
|
467
|
+
const offset = range ? range[0] : match.index + match[0].indexOf(value);
|
|
468
|
+
const end = range ? range[1] : offset + value.length;
|
|
469
|
+
// The keyword group (when the rule has one) says *what* leaked — password /
|
|
470
|
+
// token / key — which the rule id alone cannot express for keyword-prose.
|
|
471
|
+
const label = rule.group > 1 && typeof match[1] === 'string' ? match[1] : rule.id;
|
|
472
|
+
hits.push({ offset, end, value, rule: rule.id, label });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (fileRules.entropy) {
|
|
476
|
+
for (const hit of entropyHits(scan)) hits.push(hit);
|
|
477
|
+
}
|
|
478
|
+
hits.sort((a, b) => a.offset - b.offset || b.end - a.end);
|
|
479
|
+
const kept = [];
|
|
480
|
+
let last = -1;
|
|
481
|
+
for (const hit of hits) {
|
|
482
|
+
if (hit.offset >= last) { kept.push(hit); last = hit.end; }
|
|
483
|
+
}
|
|
484
|
+
return kept;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function scrubText(text, origin) {
|
|
488
|
+
if (typeof text !== 'string' || text.length < 6) return [text, false];
|
|
489
|
+
const hits = findHits(text);
|
|
490
|
+
if (!hits.length) return [text, false];
|
|
491
|
+
let out = '';
|
|
492
|
+
let cursor = 0;
|
|
493
|
+
for (const hit of hits) {
|
|
494
|
+
out += text.slice(cursor, hit.offset);
|
|
495
|
+
try {
|
|
496
|
+
// Leave the vendor prefix in the sentence; only the authenticating tail
|
|
497
|
+
// becomes a placeholder. The vault still keys on the complete value.
|
|
498
|
+
const head = prefixOf(hit.value);
|
|
499
|
+
if (head) out += head;
|
|
500
|
+
out += remember(hit.value, hit.rule, origin, hit.label);
|
|
501
|
+
stats.maskedRuns += 1;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
lastError = `capture: ${String((error && error.message) || error)}`;
|
|
504
|
+
stats.failOpen += 1;
|
|
505
|
+
out += hit.value; // vault unavailable: fail open (context keeps plaintext)
|
|
506
|
+
}
|
|
507
|
+
cursor = hit.end;
|
|
508
|
+
}
|
|
509
|
+
out += text.slice(cursor);
|
|
510
|
+
return [out, true];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Identifier-shaped keys carry handles, not secrets; rewriting one breaks lookups. */
|
|
514
|
+
function isIdentifierKey(key) {
|
|
515
|
+
if (typeof key !== 'string') return false;
|
|
516
|
+
return key === 'id' || key === 'uuid' || key === 'guid' || /(?:Id|ID|_id)$/.test(key);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Rebuild plain objects/arrays with strings scrubbed; identifier keys are never
|
|
521
|
+
* touched and non-plain instances are returned unchanged.
|
|
522
|
+
*/
|
|
523
|
+
function scrubDeep(value, origin, key) {
|
|
524
|
+
if (typeof value === 'string') {
|
|
525
|
+
if (isIdentifierKey(key)) return [value, false];
|
|
526
|
+
return scrubText(value, origin);
|
|
527
|
+
}
|
|
528
|
+
if (Array.isArray(value)) {
|
|
529
|
+
let changed = false;
|
|
530
|
+
const next = value.map((item) => {
|
|
531
|
+
const [v, ch] = scrubDeep(item, origin, key);
|
|
532
|
+
if (ch) changed = true;
|
|
533
|
+
return v;
|
|
534
|
+
});
|
|
535
|
+
return [changed ? next : value, changed];
|
|
536
|
+
}
|
|
537
|
+
if (value && typeof value === 'object') {
|
|
538
|
+
const proto = Object.getPrototypeOf(value);
|
|
539
|
+
if (proto !== Object.prototype && proto !== null) return [value, false];
|
|
540
|
+
let changed = false;
|
|
541
|
+
const next = {};
|
|
542
|
+
for (const [k, v] of Object.entries(value)) {
|
|
543
|
+
const [nv, ch] = scrubDeep(v, origin, k);
|
|
544
|
+
if (ch) changed = true;
|
|
545
|
+
next[k] = nv;
|
|
546
|
+
}
|
|
547
|
+
return [changed ? next : value, changed];
|
|
548
|
+
}
|
|
549
|
+
return [value, false];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/* ----------------------------------------------------------------- hooks -- */
|
|
553
|
+
|
|
554
|
+
function attachHooks(ctx) {
|
|
555
|
+
// Ingress A: user messages entering the step (before the log commit).
|
|
556
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
557
|
+
const decision = await next();
|
|
558
|
+
try {
|
|
559
|
+
// A rejected step never enters the log; rewriting its messages would only
|
|
560
|
+
// corrupt the shape the loop reads back.
|
|
561
|
+
if (!decision || decision.kind !== 'enter') return decision;
|
|
562
|
+
if (pausedMs() > 0) return decision;
|
|
563
|
+
if (!payload || !Array.isArray(payload.messages)) return decision;
|
|
564
|
+
let changed = false;
|
|
565
|
+
const messages = payload.messages.map((message) => {
|
|
566
|
+
if (!message || typeof message !== 'object') return message;
|
|
567
|
+
const [content, did] = scrubDeep(message.content, 'user');
|
|
568
|
+
if (!did) return message;
|
|
569
|
+
changed = true;
|
|
570
|
+
return { ...message, content };
|
|
571
|
+
});
|
|
572
|
+
if (!changed) return decision;
|
|
573
|
+
return { ...decision, messages };
|
|
574
|
+
} catch (error) {
|
|
575
|
+
lastError = `pre-step: ${String((error && error.message) || error)}`;
|
|
576
|
+
return decision;
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Ingress B: tool results entering the log / model context.
|
|
581
|
+
// `next()` resolves to `{ kind: "accept" }` with no `content` of its own — the
|
|
582
|
+
// accepted decision only carries `content` when some listener replaces it. The
|
|
583
|
+
// text to scrub therefore lives in `result.content`.
|
|
584
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
585
|
+
const decision = await next();
|
|
586
|
+
try {
|
|
587
|
+
if (!decision || decision.kind !== 'accept') return decision;
|
|
588
|
+
if (pausedMs() > 0) return decision;
|
|
589
|
+
if (Object.hasOwn(decision, 'value')) return decision;
|
|
590
|
+
const source = decision.content !== undefined ? decision.content : (result && result.content);
|
|
591
|
+
if (source === undefined) return decision;
|
|
592
|
+
const [content, did] = scrubDeep(source, 'tool');
|
|
593
|
+
if (!did) return decision;
|
|
594
|
+
return { ...decision, content };
|
|
595
|
+
} catch (error) {
|
|
596
|
+
lastError = `post-execute: ${String((error && error.message) || error)}`;
|
|
597
|
+
return decision;
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// Backstop: scrub outbound text deltas for content logged before this plugin
|
|
602
|
+
// existed. Never rewrites the stored log.
|
|
603
|
+
//
|
|
604
|
+
// `llm/stream` is a stream waterfall: the host does `yield* stream(...)` and
|
|
605
|
+
// consumes the return value as an async iterable. An `async function` here
|
|
606
|
+
// returns a Promise, which kills the whole turn with
|
|
607
|
+
// "yield* (intermediate value) is not async iterable" — so this listener MUST
|
|
608
|
+
// be an async generator. Only `text` deltas are touched: rewriting a tool-call
|
|
609
|
+
// chunk would hand the tool a placeholder instead of its real arguments.
|
|
610
|
+
ctx.on('llm/stream', async function* (options, next) {
|
|
611
|
+
let stream;
|
|
612
|
+
try {
|
|
613
|
+
stream = next();
|
|
614
|
+
} catch (error) {
|
|
615
|
+
lastError = `llm/stream: ${String((error && error.message) || error)}`;
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
619
|
+
lastError = 'llm/stream: downstream listener did not return an async iterable';
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (pausedMs() > 0 || !fileRules.outboundScrub) {
|
|
623
|
+
yield* stream;
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
try { reloadRules(false); } catch { /* rules file is optional */ }
|
|
627
|
+
if (pausedMs() > 0 || !fileRules.outboundScrub) {
|
|
628
|
+
yield* stream;
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
let scrubbed = 0;
|
|
632
|
+
for await (const chunk of stream) {
|
|
633
|
+
let out = chunk;
|
|
634
|
+
if (fileRules.outboundScrub && chunk && typeof chunk.text === 'string'
|
|
635
|
+
&& (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')) {
|
|
636
|
+
try {
|
|
637
|
+
const [text, changed] = scrubText(chunk.text, 'outbound');
|
|
638
|
+
if (changed) { out = { ...chunk, text }; scrubbed += 1; }
|
|
639
|
+
} catch (error) {
|
|
640
|
+
lastError = `llm/stream: ${String((error && error.message) || error)}`;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
yield out;
|
|
644
|
+
}
|
|
645
|
+
if (scrubbed > 0) stats.outboundScrubs += scrubbed;
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* ---------------------------------------------------------------- tools -- */
|
|
650
|
+
|
|
651
|
+
const STATUS_OUTPUT = {
|
|
652
|
+
type: 'object',
|
|
653
|
+
additionalProperties: false,
|
|
654
|
+
properties: {
|
|
655
|
+
summary: { type: 'string' },
|
|
656
|
+
entries: {
|
|
657
|
+
type: 'array',
|
|
658
|
+
items: {
|
|
659
|
+
type: 'object',
|
|
660
|
+
additionalProperties: false,
|
|
661
|
+
properties: {
|
|
662
|
+
ph: { type: 'string', description: '上下文占位符' },
|
|
663
|
+
rule: { type: 'string', description: '命中的规则 id' },
|
|
664
|
+
origin: { type: 'string', description: '首次捕获来源' },
|
|
665
|
+
createdAt: { type: 'string' },
|
|
666
|
+
lastHitAt: { type: 'string' },
|
|
667
|
+
hits: { type: 'number' },
|
|
668
|
+
},
|
|
669
|
+
required: ['ph', 'rule', 'createdAt', 'hits'],
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
stats: { type: 'object' },
|
|
673
|
+
dataDir: { type: 'string' },
|
|
674
|
+
lastError: { type: 'string' },
|
|
675
|
+
version: { type: 'string' },
|
|
676
|
+
},
|
|
677
|
+
required: ['summary', 'entries', 'stats', 'dataDir', 'version'],
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const EXPORT_OUTPUT = {
|
|
681
|
+
type: 'object',
|
|
682
|
+
additionalProperties: false,
|
|
683
|
+
properties: {
|
|
684
|
+
path: { type: 'string', description: '明文文件路径(0600)' },
|
|
685
|
+
note: { type: 'string' },
|
|
686
|
+
},
|
|
687
|
+
required: ['path', 'note'],
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Delete exported plaintext older than the TTL. Export files are the only
|
|
692
|
+
* plaintext this plugin ever writes to disk, so they must not accumulate.
|
|
693
|
+
*/
|
|
694
|
+
function sweepExports(dir) {
|
|
695
|
+
let removed = 0;
|
|
696
|
+
try {
|
|
697
|
+
const now = Date.now();
|
|
698
|
+
for (const name of fs.readdirSync(dir)) {
|
|
699
|
+
if (!name.endsWith('.txt')) continue;
|
|
700
|
+
const full = path.join(dir, name);
|
|
701
|
+
try {
|
|
702
|
+
if (now - fs.statSync(full).mtimeMs > EXPORT_TTL_MS) { fs.unlinkSync(full); removed += 1; }
|
|
703
|
+
} catch { /* raced with another sweep */ }
|
|
704
|
+
}
|
|
705
|
+
} catch { /* directory not created yet */ }
|
|
706
|
+
if (removed > 0) {
|
|
707
|
+
stats.exportCleanups += removed;
|
|
708
|
+
noteAudit('export-sweep', { removed });
|
|
709
|
+
}
|
|
710
|
+
return removed;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* @param view — entry projector; defaults to the UI view (with preview). Pass
|
|
715
|
+
* `entryViewForModel` for anything that can end up in model context.
|
|
716
|
+
*/
|
|
717
|
+
function snapshot(view) {
|
|
718
|
+
loadVault();
|
|
719
|
+
const project = typeof view === 'function' ? view : entryView;
|
|
720
|
+
return {
|
|
721
|
+
entries: [...vault.entries.values()]
|
|
722
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
723
|
+
.map(project),
|
|
724
|
+
stats: { ...stats, vaultEntries: vault.entries.size },
|
|
725
|
+
dataDir: dataDir(),
|
|
726
|
+
lastError: lastError || vault.keyError || fileRules.error || null,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function registerTools(ctx) {
|
|
731
|
+
ctx.effect(() => ctx.tools.register({
|
|
732
|
+
name: 'sensitive_guard_status',
|
|
733
|
+
description: '查看「敏感保护」的运行状态:保管库条目的元数据(占位符、命中规则、来源、时间、命中次数)、计数统计与错误。返回值不含任何明文字符,也不含明文片段。',
|
|
734
|
+
parameters: {},
|
|
735
|
+
output: {
|
|
736
|
+
schema: STATUS_OUTPUT,
|
|
737
|
+
render(_args, value) {
|
|
738
|
+
return [{ type: 'text', text: value.summary }];
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
async execute() {
|
|
742
|
+
const snap = snapshot(entryViewForModel);
|
|
743
|
+
const rules = activeRules();
|
|
744
|
+
return {
|
|
745
|
+
summary: `敏感保护 v${PLUGIN_VERSION}:保管库 ${snap.entries.length} 条,规则 ${rules.length} 条(内置 ${BUILTIN_RULES.length} + 自定义 ${fileRules.list.length}),累计遮挡 ${stats.maskedValues} 个值,出网补洗 ${stats.outboundScrubs} 次,失败放行 ${stats.failOpen} 次${snap.lastError ? `,最近错误:${snap.lastError}` : ''}。`,
|
|
746
|
+
entries: snap.entries,
|
|
747
|
+
stats: snap.stats,
|
|
748
|
+
dataDir: snap.dataDir,
|
|
749
|
+
lastError: snap.lastError || '',
|
|
750
|
+
version: PLUGIN_VERSION,
|
|
751
|
+
};
|
|
752
|
+
},
|
|
753
|
+
}), 'sensitive-guard: status tool');
|
|
754
|
+
|
|
755
|
+
ctx.effect(() => ctx.tools.register({
|
|
756
|
+
name: 'sensitive_guard_export',
|
|
757
|
+
description: '把一条被遮挡的敏感数据(<SG_ 占位符)导出为本机 0600 权限的明文文件,供命令执行时引用(如 --password "$(cat <path>)"、source <path>)。明文只写入该文件,不会进入会话上下文。仅在确实需要用凭据执行操作时调用;每次导出都会记入审计。',
|
|
758
|
+
parameters: {
|
|
759
|
+
type: 'object',
|
|
760
|
+
additionalProperties: false,
|
|
761
|
+
properties: {
|
|
762
|
+
placeholder: { type: 'string', description: '保管库占位符,形如 <SG_012345abcdef>' },
|
|
763
|
+
},
|
|
764
|
+
required: ['placeholder'],
|
|
765
|
+
},
|
|
766
|
+
output: {
|
|
767
|
+
schema: EXPORT_OUTPUT,
|
|
768
|
+
render(_args, value) {
|
|
769
|
+
return [{ type: 'text', text: `${value.path}\n${value.note}` }];
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
async execute(args) {
|
|
773
|
+
const ph = String((args && args.placeholder) || '').trim();
|
|
774
|
+
if (!PH_EXACT.test(ph)) throw new Error(`占位符格式不正确:<SG_12位十六进制>,收到 ${ph.slice(0, 40)}`);
|
|
775
|
+
loadVault();
|
|
776
|
+
const hash = vault.byPh.get(ph);
|
|
777
|
+
const entry = hash && vault.entries.get(hash);
|
|
778
|
+
if (!entry) throw new Error('保管库中没有该占位符');
|
|
779
|
+
const plaintext = decrypt(entry.enc);
|
|
780
|
+
const dir = path.join(dataDir(), 'exports');
|
|
781
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
782
|
+
const swept = sweepExports(dir);
|
|
783
|
+
const file = path.join(dir, `${ph.slice(4, -1)}.txt`);
|
|
784
|
+
fs.writeFileSync(file, plaintext, { mode: 0o600 });
|
|
785
|
+
hardenFilePermissions(file);
|
|
786
|
+
stats.exports += 1;
|
|
787
|
+
entry.lastHitAt = new Date().toISOString();
|
|
788
|
+
noteAudit('export', { ph });
|
|
789
|
+
persistVault(true);
|
|
790
|
+
return {
|
|
791
|
+
path: file,
|
|
792
|
+
note: `明文仅存在于该文件,${Math.round(EXPORT_TTL_MS / 60000)} 分钟内有效(下次导出时自动清理${swept > 0 ? `,本次已清理 ${swept} 个过期文件` : ''});用完也可立即删除。会话上下文里不会出现明文。每次导出都会记入审计。`,
|
|
793
|
+
};
|
|
794
|
+
},
|
|
795
|
+
}), 'sensitive-guard: export tool');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/* --------------------------------------------------------------- routes -- */
|
|
799
|
+
|
|
800
|
+
function sendJson(res, status, body) {
|
|
801
|
+
res.writeHead(status, {
|
|
802
|
+
'content-type': 'application/json; charset=utf-8',
|
|
803
|
+
'cache-control': 'no-store',
|
|
804
|
+
'x-content-type-options': 'nosniff',
|
|
805
|
+
});
|
|
806
|
+
res.end(JSON.stringify(body));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Parse an IPv4 literal into 4 bytes; null when malformed. */
|
|
810
|
+
function ipv4Bytes(ip) {
|
|
811
|
+
const parts = String(ip).split('.');
|
|
812
|
+
if (parts.length !== 4) return null;
|
|
813
|
+
const out = new Uint8Array(4);
|
|
814
|
+
for (let i = 0; i < 4; i += 1) {
|
|
815
|
+
if (!/^\d{1,3}$/.test(parts[i])) return null;
|
|
816
|
+
const n = Number(parts[i]);
|
|
817
|
+
if (n > 255) return null;
|
|
818
|
+
out[i] = n;
|
|
819
|
+
}
|
|
820
|
+
return out;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Parse an IPv6 literal into 16 bytes, expanding `::` and embedded IPv4. */
|
|
824
|
+
function ipv6Bytes(input) {
|
|
825
|
+
if (isIP(input) !== 6) return null;
|
|
826
|
+
const ip = String(input);
|
|
827
|
+
const gap = ip.indexOf('::');
|
|
828
|
+
const head = gap === -1 ? ip : ip.slice(0, gap);
|
|
829
|
+
const tail = gap === -1 ? '' : ip.slice(gap + 2);
|
|
830
|
+
|
|
831
|
+
const groups = (part) => (part.length === 0 ? [] : part.split(':'));
|
|
832
|
+
const expandV4 = (list) => {
|
|
833
|
+
if (list.length === 0) return list;
|
|
834
|
+
const last = list[list.length - 1];
|
|
835
|
+
if (!last.includes('.')) return list;
|
|
836
|
+
const b = ipv4Bytes(last);
|
|
837
|
+
if (b === null) return null;
|
|
838
|
+
return [...list.slice(0, -1), ((b[0] << 8) | b[1]).toString(16), ((b[2] << 8) | b[3]).toString(16)];
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
const h = expandV4(groups(head));
|
|
842
|
+
const t = expandV4(groups(tail));
|
|
843
|
+
if (h === null || t === null) return null;
|
|
844
|
+
const all = gap === -1 ? h : [...h, ...new Array(Math.max(0, 8 - h.length - t.length)).fill('0'), ...t];
|
|
845
|
+
if (all.length !== 8) return null;
|
|
846
|
+
|
|
847
|
+
const out = new Uint8Array(16);
|
|
848
|
+
for (let i = 0; i < 8; i += 1) {
|
|
849
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(all[i])) return null;
|
|
850
|
+
const v = parseInt(all[i], 16);
|
|
851
|
+
out[i * 2] = v >> 8;
|
|
852
|
+
out[i * 2 + 1] = v & 0xff;
|
|
853
|
+
}
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** IPv4 → 4 bytes, IPv6 → 16 bytes, anything else → null. */
|
|
858
|
+
function ipBytes(ip) {
|
|
859
|
+
return ipv4Bytes(ip) || ipv6Bytes(ip);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Normalize a socket address: drop the `::ffff:` v4-mapped prefix and the
|
|
864
|
+
* `%zone` suffix, so `::ffff:192.168.1.5` matches `192.168.0.0/16`.
|
|
865
|
+
*/
|
|
866
|
+
function normalizeIp(address) {
|
|
867
|
+
if (typeof address !== 'string' || address.length === 0) return '';
|
|
868
|
+
let ip = address;
|
|
869
|
+
if (ip.startsWith('::ffff:') && isIP(ip.slice(7)) === 4) ip = ip.slice(7);
|
|
870
|
+
const zone = ip.indexOf('%');
|
|
871
|
+
if (zone !== -1) ip = ip.slice(0, zone);
|
|
872
|
+
return ip;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** `{ base, bits }` for a valid CIDR, null otherwise. `/0` is refused: it would
|
|
876
|
+
* expose the reveal routes to the whole internet, never a deliberate choice. */
|
|
877
|
+
function parseCidr(cidr) {
|
|
878
|
+
const text = String(cidr || '').trim();
|
|
879
|
+
const slash = text.lastIndexOf('/');
|
|
880
|
+
if (slash === -1) return null;
|
|
881
|
+
const base = normalizeIp(text.slice(0, slash));
|
|
882
|
+
const bytes = ipBytes(base);
|
|
883
|
+
if (bytes === null) return null;
|
|
884
|
+
const bits = Number(text.slice(slash + 1));
|
|
885
|
+
const max = bytes.length * 8;
|
|
886
|
+
if (!Number.isInteger(bits) || bits < 1 || bits > max) return null;
|
|
887
|
+
return { base: bytes, bits };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function cidrContains(cidr, ip) {
|
|
891
|
+
const net = parseCidr(cidr);
|
|
892
|
+
if (net === null) return false;
|
|
893
|
+
const target = ipBytes(normalizeIp(ip));
|
|
894
|
+
if (target === null || target.length !== net.base.length) return false;
|
|
895
|
+
const fullBytes = Math.floor(net.bits / 8);
|
|
896
|
+
for (let i = 0; i < fullBytes; i += 1) {
|
|
897
|
+
if (target[i] !== net.base[i]) return false;
|
|
898
|
+
}
|
|
899
|
+
const rest = net.bits % 8;
|
|
900
|
+
if (rest === 0) return true;
|
|
901
|
+
const mask = 0xff << (8 - rest) & 0xff;
|
|
902
|
+
return (target[fullBytes] & mask) === (net.base[fullBytes] & mask);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* How this request reached us:
|
|
907
|
+
* 'loopback' — same machine (always allowed)
|
|
908
|
+
* 'lan' — matched an explicit `allowCidrs` entry in rules.json
|
|
909
|
+
* 'restricted' — non-loopback without an allowCidrs match: no plaintext, no
|
|
910
|
+
* 8-char preview, no state changes. The vault route still
|
|
911
|
+
* answers so Settings can explain itself instead of showing a
|
|
912
|
+
* bare "failed to load".
|
|
913
|
+
* null — no peer address at all
|
|
914
|
+
*
|
|
915
|
+
* These routes emit plaintext or previews, so exposure past loopback is opt-in
|
|
916
|
+
* and empty by default: the web server may bind 0.0.0.0, and without the
|
|
917
|
+
* default any LAN peer could POST its way to a full secret.
|
|
918
|
+
*/
|
|
919
|
+
function accessLevel(req) {
|
|
920
|
+
const ip = remoteIp(req);
|
|
921
|
+
if (ip === '127.0.0.1' || ip === '::1') return 'loopback';
|
|
922
|
+
if (ip.length === 0) return null;
|
|
923
|
+
let list = [];
|
|
924
|
+
try { reloadRules(false); list = fileRules.allowCidrs || []; } catch { list = []; }
|
|
925
|
+
for (const cidr of list) {
|
|
926
|
+
if (cidrContains(cidr, ip)) return 'lan';
|
|
927
|
+
}
|
|
928
|
+
return 'restricted';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function remoteIp(req) {
|
|
932
|
+
return normalizeIp(req && req.socket && req.socket.remoteAddress);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The narrowest block that would admit this peer, so the rejection message can
|
|
937
|
+
* carry a ready-to-paste config instead of leaving the user to work it out.
|
|
938
|
+
*/
|
|
939
|
+
function suggestCidr(ip) {
|
|
940
|
+
const v4 = ipv4Bytes(ip);
|
|
941
|
+
if (v4 !== null) return `${v4[0]}.${v4[1]}.${v4[2]}.0/24`;
|
|
942
|
+
const v6 = ipv6Bytes(ip);
|
|
943
|
+
if (v6 !== null) {
|
|
944
|
+
const g = [];
|
|
945
|
+
for (let i = 0; i < 4; i += 1) g.push(((v6[i * 2] << 8) | v6[i * 2 + 1]).toString(16));
|
|
946
|
+
return `${g.join(':')}::/64`;
|
|
947
|
+
}
|
|
948
|
+
return '';
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* For non-loopback access the Host header must be an IP literal. DNS rebinding
|
|
953
|
+
* needs a domain name, and `sameOriginOk` cannot tell a rebound request from a
|
|
954
|
+
* real one — both send a matching Origin and Host.
|
|
955
|
+
*/
|
|
956
|
+
function hostHeaderOk(req, level) {
|
|
957
|
+
if (level === 'loopback') return true;
|
|
958
|
+
const raw = String(req.headers.host || '');
|
|
959
|
+
if (raw.length === 0) return false;
|
|
960
|
+
let name = raw;
|
|
961
|
+
if (name.startsWith('[')) {
|
|
962
|
+
const end = name.indexOf(']');
|
|
963
|
+
if (end === -1) return false;
|
|
964
|
+
name = name.slice(1, end);
|
|
965
|
+
} else if (name.indexOf(':') === name.lastIndexOf(':')) {
|
|
966
|
+
name = name.split(':')[0];
|
|
967
|
+
}
|
|
968
|
+
return normalizeIp(name).length > 0 && isIP(normalizeIp(name)) !== 0;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* A cross-site `text/plain` POST needs no preflight; requiring JSON forces one,
|
|
973
|
+
* and this server never answers preflights. Loopback is exempt so local tools
|
|
974
|
+
* keep working.
|
|
975
|
+
*/
|
|
976
|
+
function jsonPostOk(req, level) {
|
|
977
|
+
if (level === 'loopback') return true;
|
|
978
|
+
return /^application\/json\b/i.test(String(req.headers['content-type'] || ''));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Single entry gate for the three routes. Returns the level so callers can
|
|
983
|
+
* branch, or null after having already sent the rejection.
|
|
984
|
+
*
|
|
985
|
+
* `allowRestricted` lets the vault route answer read-only metadata to an
|
|
986
|
+
* unauthorized peer (no preview, no plaintext) so the UI can explain the
|
|
987
|
+
* situation. Reveal and settings keep the hard 403.
|
|
988
|
+
*/
|
|
989
|
+
function guard(req, res, { origin = false, allowRestricted = false } = {}) {
|
|
990
|
+
const level = accessLevel(req);
|
|
991
|
+
if (level === null) {
|
|
992
|
+
sendJson(res, 403, { error: 'no-peer-address' });
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
if (level === 'restricted') {
|
|
996
|
+
if (allowRestricted) return level;
|
|
997
|
+
sendJson(res, 403, {
|
|
998
|
+
error: 'not-allowed',
|
|
999
|
+
hint: '这台机器不是服务的本机:默认只允许本机回环。要在这里查看明文,二选一——在服务所在机器的浏览器打开 127.0.0.1,或在该机器 rules.json 里加 allowCidrs。',
|
|
1000
|
+
suggested: suggestCidr(remoteIp(req)),
|
|
1001
|
+
host: String(req.headers.host || ''),
|
|
1002
|
+
});
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
if (!hostHeaderOk(req, level)) { sendJson(res, 403, { error: 'bad-host' }); return null; }
|
|
1006
|
+
if (origin && !sameOriginOk(req)) { sendJson(res, 403, { error: 'cross-origin' }); return null; }
|
|
1007
|
+
return level;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function sameOriginOk(req) {
|
|
1011
|
+
const origin = req.headers.origin;
|
|
1012
|
+
if (!origin) return true;
|
|
1013
|
+
try { return new URL(origin).host === req.headers.host; } catch { return false; }
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function readBody(req, limit) {
|
|
1017
|
+
return new Promise((resolve, reject) => {
|
|
1018
|
+
const chunks = [];
|
|
1019
|
+
let size = 0;
|
|
1020
|
+
req.on('data', (chunk) => {
|
|
1021
|
+
size += chunk.length;
|
|
1022
|
+
if (size > limit) { reject(new Error('body too large')); req.destroy(); return; }
|
|
1023
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1024
|
+
});
|
|
1025
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
1026
|
+
req.on('error', reject);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function registerRoutes(ctx) {
|
|
1031
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1032
|
+
kind: 'exact',
|
|
1033
|
+
path: VAULT_ROUTE,
|
|
1034
|
+
handler: async (req, res) => {
|
|
1035
|
+
const level = guard(req, res, { allowRestricted: true });
|
|
1036
|
+
if (level === null) return;
|
|
1037
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { sendJson(res, 405, { error: 'method' }); return; }
|
|
1038
|
+
const snap = snapshot();
|
|
1039
|
+
// A peer outside the allowlist gets structure only: placeholders, rule
|
|
1040
|
+
// ids, timestamps, hit counts. `preview` is 8 plaintext characters, so it
|
|
1041
|
+
// is dropped — the point of the page is knowing what is protected, not
|
|
1042
|
+
// seeing it.
|
|
1043
|
+
const restricted = level === 'restricted';
|
|
1044
|
+
const entries = restricted
|
|
1045
|
+
? snap.entries.map(({ preview, ...rest }) => rest)
|
|
1046
|
+
: snap.entries;
|
|
1047
|
+
sendJson(res, 200, {
|
|
1048
|
+
ok: true,
|
|
1049
|
+
version: PLUGIN_VERSION,
|
|
1050
|
+
placeholderFormat: '<SG_十六进制>',
|
|
1051
|
+
restricted,
|
|
1052
|
+
entries,
|
|
1053
|
+
audit: restricted ? [] : [...audit].reverse(),
|
|
1054
|
+
stats: snap.stats,
|
|
1055
|
+
dataDir: snap.dataDir,
|
|
1056
|
+
lastError: snap.lastError,
|
|
1057
|
+
outboundScrub: fileRules.outboundScrub,
|
|
1058
|
+
rules: { builtin: BUILTIN_RULES.length, custom: fileRules.list.length, error: fileRules.error },
|
|
1059
|
+
// Tells the UI whether this page is being served over LAN (allowCidrs).
|
|
1060
|
+
access: {
|
|
1061
|
+
level,
|
|
1062
|
+
allowCidrs: (fileRules.allowCidrs || []).slice(),
|
|
1063
|
+
suggested: restricted ? suggestCidr(remoteIp(req)) : null,
|
|
1064
|
+
host: String(req.headers.host || ''),
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
},
|
|
1068
|
+
}), 'sensitive-guard: vault route');
|
|
1069
|
+
|
|
1070
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1071
|
+
kind: 'exact',
|
|
1072
|
+
path: REVEAL_ROUTE,
|
|
1073
|
+
handler: async (req, res) => {
|
|
1074
|
+
const level = guard(req, res, { origin: true });
|
|
1075
|
+
if (level === null) return;
|
|
1076
|
+
if (req.method !== 'POST') { sendJson(res, 405, { error: 'method' }); return; }
|
|
1077
|
+
if (!jsonPostOk(req, level)) { sendJson(res, 403, { error: 'content-type' }); return; }
|
|
1078
|
+
let body;
|
|
1079
|
+
try { body = JSON.parse(await readBody(req, 4096) || '{}'); } catch { sendJson(res, 400, { error: 'bad-json' }); return; }
|
|
1080
|
+
const ph = String(body && body.placeholder || '').trim();
|
|
1081
|
+
if (!PH_EXACT.test(ph)) { sendJson(res, 400, { error: 'bad-placeholder' }); return; }
|
|
1082
|
+
loadVault();
|
|
1083
|
+
const hash = vault.byPh.get(ph);
|
|
1084
|
+
const entry = hash && vault.entries.get(hash);
|
|
1085
|
+
if (!entry) { sendJson(res, 404, { error: 'not-found' }); return; }
|
|
1086
|
+
try {
|
|
1087
|
+
const plaintext = decrypt(entry.enc);
|
|
1088
|
+
const prefix = typeof entry.prefix === 'string' ? entry.prefix : '';
|
|
1089
|
+
// `tail` is for the inline chip: the prefix already sits in the message
|
|
1090
|
+
// text, so repeating it would read as `sk-sk-…`. `plaintext` stays whole
|
|
1091
|
+
// for the Settings page and for export.
|
|
1092
|
+
const tail = prefix && plaintext.startsWith(prefix) ? plaintext.slice(prefix.length) : plaintext;
|
|
1093
|
+
stats.reveals += 1;
|
|
1094
|
+
entry.lastHitAt = new Date().toISOString();
|
|
1095
|
+
noteAudit('reveal', { ph });
|
|
1096
|
+
persistVault();
|
|
1097
|
+
sendJson(res, 200, { placeholder: ph, plaintext, body: tail, prefix, rule: entry.rule });
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
sendJson(res, 500, { error: String((error && error.message) || error) });
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
}), 'sensitive-guard: reveal route');
|
|
1103
|
+
|
|
1104
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1105
|
+
kind: 'exact',
|
|
1106
|
+
path: SETTINGS_ROUTE,
|
|
1107
|
+
handler: async (req, res) => {
|
|
1108
|
+
const level = guard(req, res, { origin: true });
|
|
1109
|
+
if (level === null) return;
|
|
1110
|
+
const shape = () => {
|
|
1111
|
+
const ms = pausedMs();
|
|
1112
|
+
const finite = Number.isFinite(ms);
|
|
1113
|
+
return {
|
|
1114
|
+
paused: ms > 0,
|
|
1115
|
+
untilRestart: pause.untilRestart,
|
|
1116
|
+
until: finite && ms > 0 ? Date.now() + ms : null,
|
|
1117
|
+
remainingMs: finite ? ms : null,
|
|
1118
|
+
};
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1122
|
+
sendJson(res, 200, shape());
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (req.method !== 'POST') { sendJson(res, 405, { error: 'method' }); return; }
|
|
1126
|
+
if (!jsonPostOk(req, level)) { sendJson(res, 403, { error: 'content-type' }); return; }
|
|
1127
|
+
|
|
1128
|
+
let body;
|
|
1129
|
+
try { body = JSON.parse(await readBody(req, 2048) || '{}'); } catch { sendJson(res, 400, { error: 'bad-json' }); return; }
|
|
1130
|
+
const action = String((body && body.action) || '');
|
|
1131
|
+
|
|
1132
|
+
if (action === 'resume') {
|
|
1133
|
+
pause.until = 0;
|
|
1134
|
+
pause.untilRestart = false;
|
|
1135
|
+
persistPause();
|
|
1136
|
+
noteAudit('resume', {});
|
|
1137
|
+
} else if (action === 'pause') {
|
|
1138
|
+
if (body.untilRestart === true) {
|
|
1139
|
+
pause.untilRestart = true;
|
|
1140
|
+
pause.until = 0;
|
|
1141
|
+
} else {
|
|
1142
|
+
const minutes = Number(body.minutes);
|
|
1143
|
+
// Clamp: a typo must not silently disable protection for a week.
|
|
1144
|
+
const safe = Number.isFinite(minutes) && minutes > 0 && minutes <= 1440
|
|
1145
|
+
? Math.round(minutes)
|
|
1146
|
+
: 30;
|
|
1147
|
+
pause.untilRestart = false;
|
|
1148
|
+
pause.until = Date.now() + safe * 60000;
|
|
1149
|
+
}
|
|
1150
|
+
persistPause();
|
|
1151
|
+
noteAudit('pause', { untilRestart: pause.untilRestart, until: pause.until || null });
|
|
1152
|
+
} else {
|
|
1153
|
+
sendJson(res, 400, { error: 'unknown-action' });
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
sendJson(res, 200, shape());
|
|
1158
|
+
},
|
|
1159
|
+
}), 'sensitive-guard: settings route');
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/* ----------------------------------------------------------------- apply -- */
|
|
1163
|
+
|
|
1164
|
+
// `webServer` used to be a hard dependency of the whole plugin, so a profile
|
|
1165
|
+
// without it silently disabled scrubbing as well. Only `tools` is required now;
|
|
1166
|
+
// the routes attach when a web server exists.
|
|
1167
|
+
export const inject = ['tools'];
|
|
1168
|
+
|
|
1169
|
+
export function apply(ctx) {
|
|
1170
|
+
// Host-side twin of the client guard. A throwing apply leaves the plugin half
|
|
1171
|
+
// registered (hooks without tools, or the reverse), which is worse than being
|
|
1172
|
+
// plainly off — contain it and report through status instead.
|
|
1173
|
+
try {
|
|
1174
|
+
try { reloadRules(true); } catch { /* rules file is optional */ }
|
|
1175
|
+
loadPause();
|
|
1176
|
+
attachHooks(ctx);
|
|
1177
|
+
registerTools(ctx);
|
|
1178
|
+
ctx.inject(['webServer'], (webCtx) => { registerRoutes(webCtx); });
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
lastError = `apply: ${String((error && error.message) || error)}`;
|
|
1181
|
+
console.warn('[sensitive-guard] host plugin disabled after error:', error && error.message);
|
|
1182
|
+
}
|
|
1183
|
+
}
|