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/locale/en.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Sensitive guard",
|
|
4
|
+
"description": "Replaces keys, tokens and passwords with <SG_> placeholders before they enter model context; plaintext stays in a local encrypted vault. Click the lock in Settings to reveal, or use sensitive_guard_export to write a protected file for command use."
|
|
5
|
+
},
|
|
6
|
+
"ui": {
|
|
7
|
+
"nav": "Sensitive guard",
|
|
8
|
+
"heading": "Sensitive data",
|
|
9
|
+
"intro": "These plaintexts live only in the local vault. Sessions and model context carry <SG_> placeholders; click the lock on the right to reveal a value temporarily.",
|
|
10
|
+
"loading": "Loading…",
|
|
11
|
+
"empty": "Nothing protected yet. Keys, tokens and passwords found in input or tool results are captured automatically.",
|
|
12
|
+
"error": "Failed to load",
|
|
13
|
+
"reveal": "Show secret",
|
|
14
|
+
"hide": "Hide secret",
|
|
15
|
+
"refresh": "Refresh",
|
|
16
|
+
"autoHide": "Re-masks in 20 seconds",
|
|
17
|
+
"colValue": "Value",
|
|
18
|
+
"colKind": "Rule",
|
|
19
|
+
"colTime": "Captured",
|
|
20
|
+
"colHits": "Hits",
|
|
21
|
+
"stats": "{count} entries · actions stay on this machine",
|
|
22
|
+
"placeholder": "Context placeholder format: <SG_hex>",
|
|
23
|
+
"usage": "For the agent: sensitive_guard_export writes the plaintext to a 0600 file; reference it as \"$(cat <path>)\" so plaintext never enters context."
|
|
24
|
+
}
|
|
25
|
+
}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "敏感保护",
|
|
4
|
+
"description": "进入模型上下文前把密钥、令牌与密码替换为 <SG_> 占位符,明文加密保存在本地保管库;在设置页点小锁即可查看真实值,也可通过 sensitive_guard_export 导出为受保护文件供命令使用。"
|
|
5
|
+
},
|
|
6
|
+
"ui": {
|
|
7
|
+
"nav": "敏感保护",
|
|
8
|
+
"heading": "敏感数据管理",
|
|
9
|
+
"intro": "以下明文仅保存在本机保管库。会话与模型上下文里只有 <SG_> 占位符;点右侧小锁可临时查看真实值。",
|
|
10
|
+
"loading": "载入中…",
|
|
11
|
+
"empty": "暂无被保护的敏感数据。输入或工具结果里出现密钥、令牌、密码时会自动入库。",
|
|
12
|
+
"error": "载入失败",
|
|
13
|
+
"reveal": "显示明文",
|
|
14
|
+
"hide": "隐藏明文",
|
|
15
|
+
"refresh": "刷新",
|
|
16
|
+
"autoHide": "20 秒后自动重新遮挡",
|
|
17
|
+
"colValue": "值",
|
|
18
|
+
"colKind": "规则",
|
|
19
|
+
"colTime": "入库时间",
|
|
20
|
+
"colHits": "命中",
|
|
21
|
+
"stats": "共 {count} 条 · 操作只记录在本机",
|
|
22
|
+
"placeholder": "上下文占位符格式:<SG_十六进制>",
|
|
23
|
+
"usage": "模型侧取用:sensitive_guard_export 把明文写入 0600 文件,命令里用 \"$(cat <路径>)\" 引用,明文不进上下文。"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sensitive-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "敏感保护:进入模型上下文前把密钥/令牌/密码替换为 <SG_> 占位符,明文加密存本地保管库;设置页与对话内提供小锁点击还原,工具可将明文导出为 0600 文件供命令执行时使用。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "wangxinyu",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/wangxy496/dsh-sensitive-guard.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/wangxy496/dsh-sensitive-guard/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/wangxy496/dsh-sensitive-guard#readme",
|
|
15
|
+
"keywords": ["dsh", "dsh-plugin", "sensitive", "mask", "vault", "secret", "redact"],
|
|
16
|
+
"private": false,
|
|
17
|
+
"type": "module",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./index.js",
|
|
23
|
+
"./client": "./client.js",
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
"./locale/*.json": "./locale/*.json",
|
|
26
|
+
"./icon": "./icon.svg"
|
|
27
|
+
},
|
|
28
|
+
"files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg", "README.md", "CHANGELOG.md", "LICENSE", "test.mjs"],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node test.mjs"
|
|
31
|
+
},
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
34
|
+
"client": {
|
|
35
|
+
"platform": "web",
|
|
36
|
+
"immediately": true,
|
|
37
|
+
"inject": ["@deepseek-ai/dsh-client-ui-sidebar"]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/test.mjs
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-check for dsh-sensitive-guard. Runs entirely offline against a temporary
|
|
3
|
+
* data directory, so it never touches a real vault.
|
|
4
|
+
*
|
|
5
|
+
* node test.mjs
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { Readable } from 'node:stream';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
// fileURLToPath is the only portable way: on Windows the URL pathname is
|
|
14
|
+
// `/D:/…` and stripping the slash yields a relative path on POSIX hosts.
|
|
15
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TEMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-test-'));
|
|
17
|
+
process.env.DSH_HOME = TEMP_HOME;
|
|
18
|
+
|
|
19
|
+
let failed = 0;
|
|
20
|
+
function check(name, condition, detail) {
|
|
21
|
+
if (condition) {
|
|
22
|
+
console.log(` ok ${name}`);
|
|
23
|
+
} else {
|
|
24
|
+
failed += 1;
|
|
25
|
+
console.log(` FAIL ${name}${detail ? ` (${detail})` : ''}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SECRET = 'sk-proj-abcdefghijklmnopqrstuvwxyz0123';
|
|
30
|
+
|
|
31
|
+
/* ---------------------------------------------------------------- host -- */
|
|
32
|
+
|
|
33
|
+
const mod = await import(pathToFileURL(path.join(ROOT, 'index.js')).href);
|
|
34
|
+
const listeners = new Map();
|
|
35
|
+
const tools = new Map();
|
|
36
|
+
const routes = new Map();
|
|
37
|
+
const ctx = {
|
|
38
|
+
on(name, fn) {
|
|
39
|
+
if (!listeners.has(name)) listeners.set(name, []);
|
|
40
|
+
listeners.get(name).push(fn);
|
|
41
|
+
},
|
|
42
|
+
effect(fn) { try { fn(); } catch { /* registration probe */ } return () => {}; },
|
|
43
|
+
tools: { register(t) { tools.set(t.name, t); return () => {}; } },
|
|
44
|
+
webServer: { register(r) { routes.set(r.path, r); return () => {}; } },
|
|
45
|
+
inject(_deps, cb) { cb(ctx); },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
mod.apply(ctx);
|
|
49
|
+
|
|
50
|
+
console.log('host registration');
|
|
51
|
+
check('inject is [tools]', JSON.stringify(mod.inject) === '["tools"]');
|
|
52
|
+
check('two tools registered', tools.size === 2, [...tools.keys()].join(','));
|
|
53
|
+
check('two routes registered', routes.size === 3, [...routes.keys()].join(','));
|
|
54
|
+
check('three hooks registered', listeners.size === 3, [...listeners.keys()].join(','));
|
|
55
|
+
|
|
56
|
+
const pre = listeners.get('agent/pre-step')[0];
|
|
57
|
+
const post = listeners.get('tools/post-execute')[0];
|
|
58
|
+
const streamFn = listeners.get('llm/stream')[0];
|
|
59
|
+
|
|
60
|
+
console.log('ingress');
|
|
61
|
+
const entered = await pre(
|
|
62
|
+
{ messages: [{ role: 'user', content: `我的 api key 是 ${SECRET}` }] },
|
|
63
|
+
async () => ({ kind: 'enter', messages: [] }),
|
|
64
|
+
);
|
|
65
|
+
const masked = entered.messages[0].content;
|
|
66
|
+
check('user message masked', masked.includes('<SG_'), masked);
|
|
67
|
+
check('vendor prefix kept', masked.includes('sk-<SG_'), masked);
|
|
68
|
+
const ph = (masked.match(/<SG_[0-9a-f]{12}>/) || [])[0];
|
|
69
|
+
|
|
70
|
+
const rejected = { kind: 'reject', reason: 'blocked' };
|
|
71
|
+
const passedThrough = await pre({ messages: [{ content: SECRET }] }, async () => rejected);
|
|
72
|
+
check('reject decision untouched', passedThrough === rejected);
|
|
73
|
+
|
|
74
|
+
const posted = await post({}, { content: [{ type: 'text', text: `t=${SECRET}` }] }, async () => ({ kind: 'accept' }));
|
|
75
|
+
check('tool result masked', posted.content[0].text.includes('<SG_'));
|
|
76
|
+
|
|
77
|
+
const urlOut = (await pre(
|
|
78
|
+
{ messages: [{ content: 'https://alice123:alice123@example.com/x' }] },
|
|
79
|
+
async () => ({ kind: 'enter', messages: [] }),
|
|
80
|
+
)).messages[0].content;
|
|
81
|
+
check('url password offset exact', urlOut.startsWith('https://alice123:<SG_'), urlOut);
|
|
82
|
+
|
|
83
|
+
const cliOut = (await pre(
|
|
84
|
+
{ messages: [{ content: 'mysql -uroot -pabc12345 -h 127.0.0.1' }] },
|
|
85
|
+
async () => ({ kind: 'enter', messages: [] }),
|
|
86
|
+
)).messages[0].content;
|
|
87
|
+
check('cli password masked', cliOut.includes('<SG_'), cliOut);
|
|
88
|
+
|
|
89
|
+
const dockerText = 'docker run -p 8080:80 nginx';
|
|
90
|
+
const dockerOut = (await pre(
|
|
91
|
+
{ messages: [{ content: dockerText }] },
|
|
92
|
+
async () => ({ kind: 'enter', messages: [{ content: dockerText }] }),
|
|
93
|
+
)).messages[0].content;
|
|
94
|
+
check('docker -p not mistaken for password', dockerOut === dockerText, dockerOut);
|
|
95
|
+
|
|
96
|
+
console.log('stream');
|
|
97
|
+
const stream = streamFn({}, () => (async function* () {
|
|
98
|
+
yield { type: 'text-delta', text: SECRET };
|
|
99
|
+
yield { type: 'tool-call-delta', argumentsDelta: `{"k":"${SECRET}"}` };
|
|
100
|
+
yield { type: 'finish' };
|
|
101
|
+
})());
|
|
102
|
+
check('llm/stream is async iterable', typeof stream[Symbol.asyncIterator] === 'function');
|
|
103
|
+
const chunks = [];
|
|
104
|
+
for await (const c of stream) chunks.push(c);
|
|
105
|
+
check('text delta masked', chunks[0].text.includes('<SG_'));
|
|
106
|
+
check('tool call untouched', chunks[1].argumentsDelta.includes(SECRET));
|
|
107
|
+
check('finish passthrough', chunks[2].type === 'finish');
|
|
108
|
+
|
|
109
|
+
console.log('vault and tools');
|
|
110
|
+
const status = await tools.get('sensitive_guard_status').execute({});
|
|
111
|
+
check('status hides preview', !Object.hasOwn(status.entries[0] || {}, 'preview'));
|
|
112
|
+
check('status reports entries', status.entries.length >= 1);
|
|
113
|
+
|
|
114
|
+
const exported = await tools.get('sensitive_guard_export').execute({ placeholder: ph });
|
|
115
|
+
check('export writes plaintext file', fs.readFileSync(exported.path, 'utf8') === SECRET);
|
|
116
|
+
fs.unlinkSync(exported.path);
|
|
117
|
+
|
|
118
|
+
console.log('routes');
|
|
119
|
+
function fakeRes() {
|
|
120
|
+
return { status: 0, body: '', writeHead(c) { this.status = c; }, end(b) { this.body = String(b || ''); } };
|
|
121
|
+
}
|
|
122
|
+
const reveal = routes.get('/guard/reveal');
|
|
123
|
+
const lan = fakeRes();
|
|
124
|
+
await reveal.handler({ method: 'POST', socket: { remoteAddress: '203.0.113.9' }, headers: { host: 'localhost:3080' } }, lan);
|
|
125
|
+
check('non-loopback rejected', lan.status === 403);
|
|
126
|
+
|
|
127
|
+
const local = fakeRes();
|
|
128
|
+
await reveal.handler(Object.assign(Readable.from([Buffer.from(JSON.stringify({ placeholder: ph }))]), {
|
|
129
|
+
method: 'POST', socket: { remoteAddress: '127.0.0.1' }, headers: { host: 'localhost:3080' },
|
|
130
|
+
}), local);
|
|
131
|
+
const revealed = JSON.parse(local.body || '{}');
|
|
132
|
+
check('loopback reveal returns plaintext', revealed.plaintext === SECRET);
|
|
133
|
+
check('reveal splits body and prefix', revealed.prefix === 'sk-' && revealed.body === SECRET.slice(3));
|
|
134
|
+
|
|
135
|
+
console.log('pause switch');
|
|
136
|
+
const settings = routes.get('/guard/settings');
|
|
137
|
+
async function postSettings(payload) {
|
|
138
|
+
const res = fakeRes();
|
|
139
|
+
await settings.handler(Object.assign(Readable.from([Buffer.from(JSON.stringify(payload))]), {
|
|
140
|
+
method: 'POST', socket: { remoteAddress: '127.0.0.1' }, headers: { host: 'localhost:3080' },
|
|
141
|
+
}), res);
|
|
142
|
+
return { status: res.status, body: JSON.parse(res.body || '{}') };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const idle = fakeRes();
|
|
146
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '127.0.0.1' }, headers: { host: 'localhost:3080' } }, idle);
|
|
147
|
+
check('starts unpaused', JSON.parse(idle.body).paused === false);
|
|
148
|
+
|
|
149
|
+
const pausedState = (await postSettings({ action: 'pause', minutes: 30 })).body;
|
|
150
|
+
check('pause returns remaining time', pausedState.paused === true && pausedState.remainingMs > 1700000, JSON.stringify(pausedState));
|
|
151
|
+
|
|
152
|
+
const duringPause = (await pre(
|
|
153
|
+
{ messages: [{ content: `key ${SECRET}` }] },
|
|
154
|
+
async () => ({ kind: 'enter', messages: [{ content: `key ${SECRET}` }] }),
|
|
155
|
+
)).messages[0].content;
|
|
156
|
+
check('masking suspended while paused', duringPause === `key ${SECRET}`, duringPause);
|
|
157
|
+
|
|
158
|
+
const resumed = (await postSettings({ action: 'resume' })).body;
|
|
159
|
+
check('resume clears pause', resumed.paused === false, JSON.stringify(resumed));
|
|
160
|
+
|
|
161
|
+
const afterResume = (await pre(
|
|
162
|
+
{ messages: [{ content: `key ${SECRET}` }] },
|
|
163
|
+
async () => ({ kind: 'enter', messages: [] }),
|
|
164
|
+
)).messages[0].content;
|
|
165
|
+
check('masking back after resume', afterResume.includes('<SG_'), afterResume);
|
|
166
|
+
|
|
167
|
+
const lanToggle = fakeRes();
|
|
168
|
+
await settings.handler({ method: 'POST', socket: { remoteAddress: '203.0.113.9' }, headers: { host: 'localhost:3080' } }, lanToggle);
|
|
169
|
+
check('settings route is loopback-only', lanToggle.status === 403);
|
|
170
|
+
|
|
171
|
+
/* -------------------------------------------------------------- client -- */
|
|
172
|
+
|
|
173
|
+
console.log('client');
|
|
174
|
+
const React = {
|
|
175
|
+
createElement: () => null,
|
|
176
|
+
Fragment: 'Fragment',
|
|
177
|
+
useState: () => [null, () => {}],
|
|
178
|
+
useEffect: () => {},
|
|
179
|
+
useRef: () => ({ current: 0 }),
|
|
180
|
+
useCallback: (fn) => fn,
|
|
181
|
+
};
|
|
182
|
+
let api = null;
|
|
183
|
+
globalThis.window = { __ModuleLoader__: { load(def) { api = def.factory((n) => (n === 'react' ? React : {})); } } };
|
|
184
|
+
globalThis.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
|
|
185
|
+
|
|
186
|
+
const clientSrc = fs.readFileSync(path.join(ROOT, 'client.js'), 'utf8');
|
|
187
|
+
new Function('window', 'globalThis', clientSrc).call(globalThis, globalThis.window, globalThis);
|
|
188
|
+
check('client module exposes apply', api && typeof api.apply === 'function');
|
|
189
|
+
|
|
190
|
+
const registered = [];
|
|
191
|
+
const slots = {
|
|
192
|
+
inject(_name, factory) {
|
|
193
|
+
const it = factory();
|
|
194
|
+
if (it && typeof it.next === 'function') { let s = it.next(); while (!s.done) s = it.next(); }
|
|
195
|
+
},
|
|
196
|
+
register(options) { registered.push(options); return () => {}; },
|
|
197
|
+
};
|
|
198
|
+
const strictCtx = new Proxy({ slots, effect: () => () => {} }, {
|
|
199
|
+
get(target, prop) { if (prop in target) return target[prop]; throw new Error(`no ${String(prop)}`); },
|
|
200
|
+
});
|
|
201
|
+
let threw = null;
|
|
202
|
+
try { api.apply(strictCtx); } catch (error) { threw = error; }
|
|
203
|
+
check('client apply never throws on missing services', threw === null, threw && threw.message);
|
|
204
|
+
|
|
205
|
+
const fullCtx = new Proxy({
|
|
206
|
+
slots,
|
|
207
|
+
effect: () => () => {},
|
|
208
|
+
locale: { register: () => () => {}, bind: () => (k) => k },
|
|
209
|
+
get: (name) => (name === 'uiConversation' ? { userText: { register: () => {} } } : undefined),
|
|
210
|
+
inject: (_deps, cb) => cb({ uiConversation: { userText: { register: () => {} } } }),
|
|
211
|
+
}, {
|
|
212
|
+
get(target, prop) { if (prop in target) return target[prop]; throw new Error(`no ${String(prop)}`); },
|
|
213
|
+
});
|
|
214
|
+
api.apply(fullCtx);
|
|
215
|
+
check('inline renderer uses keyed registration', registered.some((r) => r.key === 'sensitive-guard'));
|
|
216
|
+
|
|
217
|
+
/* ------------------------------------------------------------ behaviour -- */
|
|
218
|
+
|
|
219
|
+
console.log('performance');
|
|
220
|
+
const plain = `${'x'.repeat(300000)}`;
|
|
221
|
+
let started = performance.now();
|
|
222
|
+
await pre({ messages: [{ content: plain }] }, async () => ({ kind: 'enter', messages: [] }));
|
|
223
|
+
const scanMs = performance.now() - started;
|
|
224
|
+
check('300k chars scanned under 400ms', scanMs < 400, `${scanMs.toFixed(0)}ms`);
|
|
225
|
+
|
|
226
|
+
const many = Array.from({ length: 2000 }, () => SECRET).join(' ');
|
|
227
|
+
started = performance.now();
|
|
228
|
+
await pre({ messages: [{ content: many }] }, async () => ({ kind: 'enter', messages: [] }));
|
|
229
|
+
const maskMs = performance.now() - started;
|
|
230
|
+
check('2000 secrets masked under 800ms', maskMs < 800, `${maskMs.toFixed(0)}ms`);
|
|
231
|
+
|
|
232
|
+
console.log('entropy (opt-in)');
|
|
233
|
+
// Base64-ish blob: ~4.9 bits/char. A pure hex string caps at 4.0 bits and can
|
|
234
|
+
// therefore never reach the default 4.5 threshold — documented in README.
|
|
235
|
+
const blob = 'Zx9Kp2Qm7Rt4Wv1Yb6Nc3Ld8Fg5Hs0Jk';
|
|
236
|
+
const blobText = `value ${blob} end`;
|
|
237
|
+
const beforeEntropy = (await pre(
|
|
238
|
+
{ messages: [{ content: blobText }] },
|
|
239
|
+
async () => ({ kind: 'enter', messages: [{ content: blobText }] }),
|
|
240
|
+
)).messages[0].content;
|
|
241
|
+
check('off by default', beforeEntropy.includes(blob), beforeEntropy);
|
|
242
|
+
|
|
243
|
+
fs.writeFileSync(
|
|
244
|
+
path.join(TEMP_HOME, 'sensitive-guard', 'rules.json'),
|
|
245
|
+
JSON.stringify({ entropy: true }),
|
|
246
|
+
);
|
|
247
|
+
await new Promise((r) => setTimeout(r, 2200));
|
|
248
|
+
const afterEntropy = (await pre(
|
|
249
|
+
{ messages: [{ content: blobText }] },
|
|
250
|
+
async () => ({ kind: 'enter', messages: [{ content: blobText }] }),
|
|
251
|
+
)).messages[0].content;
|
|
252
|
+
check('enabled through rules.json', afterEntropy.includes('<SG_'), afterEntropy);
|
|
253
|
+
|
|
254
|
+
/* ------------------------------------------------------- lan allowlist -- */
|
|
255
|
+
|
|
256
|
+
console.log('lan allowlist');
|
|
257
|
+
|
|
258
|
+
// Loopback must stay allowed regardless of what the allowlist says.
|
|
259
|
+
const stillLocal = fakeRes();
|
|
260
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '::ffff:127.0.0.1' }, headers: { host: '127.0.0.1:3080' } }, stillLocal);
|
|
261
|
+
check('v4-mapped loopback still allowed', stillLocal.status === 200, String(stillLocal.status));
|
|
262
|
+
|
|
263
|
+
// Still closed before the allowlist is configured for this read.
|
|
264
|
+
const closedFirst = fakeRes();
|
|
265
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '198.51.100.10' }, headers: { host: '198.51.100.10:3080' } }, closedFirst);
|
|
266
|
+
check('lan closed by default', closedFirst.status === 403, String(closedFirst.status));
|
|
267
|
+
|
|
268
|
+
fs.writeFileSync(
|
|
269
|
+
path.join(TEMP_HOME, 'sensitive-guard', 'rules.json'),
|
|
270
|
+
JSON.stringify({ entropy: true, allowCidrs: ['198.51.100.0/24', '203.0.113.200/32', '::1/128'] }),
|
|
271
|
+
);
|
|
272
|
+
await new Promise((r) => setTimeout(r, 2200));
|
|
273
|
+
|
|
274
|
+
const allowed = fakeRes();
|
|
275
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '198.51.100.10' }, headers: { host: '198.51.100.10:3080' } }, allowed);
|
|
276
|
+
check('allowCidrs opens /16', allowed.status === 200, String(allowed.status));
|
|
277
|
+
|
|
278
|
+
const mapped = fakeRes();
|
|
279
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '::ffff:198.51.100.78' }, headers: { host: '198.51.100.10:3080' } }, mapped);
|
|
280
|
+
check('v4-mapped lan address matched', mapped.status === 200, String(mapped.status));
|
|
281
|
+
|
|
282
|
+
const exactHost = fakeRes();
|
|
283
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '203.0.113.200' }, headers: { host: '203.0.113.200:3080' } }, exactHost);
|
|
284
|
+
check('/32 matches only its own address', exactHost.status === 200, String(exactHost.status));
|
|
285
|
+
|
|
286
|
+
const outside = fakeRes();
|
|
287
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '203.0.113.250' }, headers: { host: '203.0.113.250:3080' } }, outside);
|
|
288
|
+
check('address outside allowCidrs rejected', outside.status === 403, String(outside.status));
|
|
289
|
+
|
|
290
|
+
const wrongSubnet = fakeRes();
|
|
291
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '203.0.113.201' }, headers: { host: '203.0.113.201:3080' } }, wrongSubnet);
|
|
292
|
+
check('/32 does not leak into /24', wrongSubnet.status === 403, String(wrongSubnet.status));
|
|
293
|
+
|
|
294
|
+
// DNS rebinding: an allowed client, but Host is a domain name, not an IP.
|
|
295
|
+
const rebound = fakeRes();
|
|
296
|
+
await settings.handler({ method: 'GET', socket: { remoteAddress: '198.51.100.78' }, headers: { host: 'evil.example.com:3080' } }, rebound);
|
|
297
|
+
check('rebound host header rejected', rebound.status === 403 && JSON.parse(rebound.body).error === 'bad-host', rebound.body);
|
|
298
|
+
|
|
299
|
+
// Cross-site `text/plain` POST needs no preflight; JSON does.
|
|
300
|
+
const plainPost = fakeRes();
|
|
301
|
+
await settings.handler(Object.assign(Readable.from([Buffer.from('{"action":"pause","minutes":30}')]), {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
socket: { remoteAddress: '198.51.100.10' },
|
|
304
|
+
headers: { host: '198.51.100.10:3080', origin: 'http://198.51.100.10:3080', 'content-type': 'text/plain' },
|
|
305
|
+
}), plainPost);
|
|
306
|
+
check('lan POST must be json content-type', plainPost.status === 403 && JSON.parse(plainPost.body).error === 'content-type', plainPost.body);
|
|
307
|
+
|
|
308
|
+
const lanReveal = fakeRes();
|
|
309
|
+
await reveal.handler(Object.assign(Readable.from([Buffer.from(JSON.stringify({ placeholder: ph }))]), {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
socket: { remoteAddress: '198.51.100.10' },
|
|
312
|
+
headers: { host: '198.51.100.10:3080', origin: 'http://198.51.100.10:3080', 'content-type': 'application/json; charset=utf-8' },
|
|
313
|
+
}), lanReveal);
|
|
314
|
+
const lanPayload = JSON.parse(lanReveal.body || '{}');
|
|
315
|
+
check('lan reveal works with json', lanReveal.status === 200 && lanPayload.plaintext === SECRET, lanReveal.body.slice(0, 120));
|
|
316
|
+
|
|
317
|
+
const vaultRoute = routes.get('/guard/vault');
|
|
318
|
+
const exposure = fakeRes();
|
|
319
|
+
await vaultRoute.handler({ method: 'GET', socket: { remoteAddress: '198.51.100.10' }, headers: { host: '198.51.100.10:3080' } }, exposure);
|
|
320
|
+
const exposureBody = JSON.parse(exposure.body || '{}');
|
|
321
|
+
check('vault reports access level', exposureBody.access && exposureBody.access.level === 'lan', JSON.stringify(exposureBody.access));
|
|
322
|
+
|
|
323
|
+
// `/0` would expose the routes to the whole internet; it must be dropped and
|
|
324
|
+
// reported, not silently honoured.
|
|
325
|
+
fs.writeFileSync(
|
|
326
|
+
path.join(TEMP_HOME, 'sensitive-guard', 'rules.json'),
|
|
327
|
+
JSON.stringify({ allowCidrs: ['0.0.0.0/0', '198.51.100.0/24'] }),
|
|
328
|
+
);
|
|
329
|
+
await new Promise((r) => setTimeout(r, 2200));
|
|
330
|
+
const wild = fakeRes();
|
|
331
|
+
await vaultRoute.handler({ method: 'GET', socket: { remoteAddress: '203.0.113.99' }, headers: { host: '203.0.113.99:3080' } }, wild);
|
|
332
|
+
const wildBody = JSON.parse(wild.body || '{}');
|
|
333
|
+
check('/0 is refused (degraded to read-only)', wild.status === 200 && wildBody.restricted === true, `${wild.status} ${wildBody.restricted}`);
|
|
334
|
+
const wildReport = fakeRes();
|
|
335
|
+
await vaultRoute.handler({ method: 'GET', socket: { remoteAddress: '198.51.100.10' }, headers: { host: '198.51.100.10:3080' } }, wildReport);
|
|
336
|
+
const wildReportBody = JSON.parse(wildReport.body || '{}');
|
|
337
|
+
check('rejected cidr is reported', wildReport.status === 200 && String(wildReportBody.rules.error || '').includes('0.0.0.0/0'), String(wildReportBody.rules.error));
|
|
338
|
+
|
|
339
|
+
/* -------------------------------------------------------- read-only mode -- */
|
|
340
|
+
|
|
341
|
+
console.log('read-only degradation');
|
|
342
|
+
|
|
343
|
+
// 203.0.113.99 is outside the allowlist: metadata yes, preview no, reveal no.
|
|
344
|
+
const metaOnly = fakeRes();
|
|
345
|
+
await vaultRoute.handler({ method: 'GET', socket: { remoteAddress: '203.0.113.99' }, headers: { host: '203.0.113.99:3080' } }, metaOnly);
|
|
346
|
+
const metaBody = JSON.parse(metaOnly.body || '{}');
|
|
347
|
+
check('restricted keeps structure', metaOnly.status === 200 && metaBody.entries.length > 0, String(metaBody.entries.length));
|
|
348
|
+
check('restricted drops preview', metaBody.entries.every((e) => !('preview' in e)), JSON.stringify(metaBody.entries[0] || {}));
|
|
349
|
+
check('restricted drops audit', Array.isArray(metaBody.audit) && metaBody.audit.length === 0);
|
|
350
|
+
check('restricted suggests a cidr', metaBody.access.suggested === '203.0.113.0/24', String(metaBody.access.suggested));
|
|
351
|
+
check('restricted reports host', metaBody.access.host === '203.0.113.99:3080', String(metaBody.access.host));
|
|
352
|
+
|
|
353
|
+
const metaReveal = fakeRes();
|
|
354
|
+
await reveal.handler(Object.assign(Readable.from([Buffer.from(JSON.stringify({ placeholder: ph }))]), {
|
|
355
|
+
method: 'POST',
|
|
356
|
+
socket: { remoteAddress: '203.0.113.99' },
|
|
357
|
+
headers: { host: '203.0.113.99:3080', 'content-type': 'application/json' },
|
|
358
|
+
}), metaReveal);
|
|
359
|
+
const metaRevealBody = JSON.parse(metaReveal.body || '{}');
|
|
360
|
+
check('restricted reveal still refused', metaReveal.status === 403 && metaRevealBody.error === 'not-allowed', metaReveal.body.slice(0, 80));
|
|
361
|
+
check('refusal carries the fix', String(metaRevealBody.suggested) === '203.0.113.0/24', String(metaRevealBody.suggested));
|
|
362
|
+
|
|
363
|
+
const metaToggle = fakeRes();
|
|
364
|
+
await settings.handler({ method: 'POST', socket: { remoteAddress: '203.0.113.99' }, headers: { host: '203.0.113.99:3080', 'content-type': 'application/json' } }, metaToggle);
|
|
365
|
+
check('restricted cannot toggle pause', metaToggle.status === 403, String(metaToggle.status));
|
|
366
|
+
|
|
367
|
+
const allowedPreview = fakeRes();
|
|
368
|
+
await vaultRoute.handler({ method: 'GET', socket: { remoteAddress: '198.51.100.10' }, headers: { host: '198.51.100.10:3080' } }, allowedPreview);
|
|
369
|
+
const allowedBody = JSON.parse(allowedPreview.body || '{}');
|
|
370
|
+
check('allowlisted peer still sees preview', allowedBody.restricted === false && allowedBody.entries.some((e) => 'preview' in e), JSON.stringify(allowedBody.entries[0] || {}));
|
|
371
|
+
|
|
372
|
+
/* --------------------------------------------------------------------- */
|
|
373
|
+
|
|
374
|
+
fs.rmSync(TEMP_HOME, { recursive: true, force: true });
|
|
375
|
+
console.log(failed === 0 ? '\nall checks passed' : `\n${failed} check(s) failed`);
|
|
376
|
+
process.exit(failed === 0 ? 0 : 1);
|