dsh-dashcam 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +624 -2
- package/README.zh-CN.md +835 -0
- package/SECURITY.md +28 -0
- package/bin/dsh-dashcam.js +574 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPLIANCE.md +112 -0
- package/docs/ROLLOUT.md +150 -0
- package/docs/SPEC.md +441 -0
- package/docs/ghost-in-the-llm/README.md +21 -0
- package/docs/ghost-in-the-llm/dashcam-ingress-audit-design.md +223 -0
- package/docs/ghost-in-the-llm/dashcam-ingress-record-plan.md +418 -0
- package/docs/ghost-in-the-llm/ghost-message-forensics.md +214 -0
- package/docs/ghost-in-the-llm/ghost-message-third-party-review.md +174 -0
- package/docs/multiprocess.md +292 -0
- package/dsh-dashcam-host/README.md +177 -0
- package/dsh-dashcam-host/README.zh-CN.md +181 -0
- package/dsh-dashcam-host/lib/client.js +1206 -0
- package/dsh-dashcam-host/lib/index.js +971 -0
- package/dsh-dashcam-host/lib/ingress.js +218 -0
- package/dsh-dashcam-host/lib/panel.js +487 -0
- package/dsh-dashcam-host/package.json +29 -0
- package/examples/child-plugin.js +25 -0
- package/examples/compliant-plugin/index.js +30 -0
- package/examples/compliant-plugin/manifest.json +47 -0
- package/examples/demo-policy.json +33 -0
- package/examples/negative-plugin/index.js +20 -0
- package/examples/negative-plugin/manifest.json +36 -0
- package/package.json +73 -4
- package/src/autopsy/activation.js +90 -0
- package/src/autopsy/autopsy.js +222 -0
- package/src/contract/capabilities.js +437 -0
- package/src/contract/charter.js +230 -0
- package/src/contract/manifest.js +162 -0
- package/src/contract/two-key.js +89 -0
- package/src/errors.js +34 -0
- package/src/gates/index.js +289 -0
- package/src/gates/loadability.js +87 -0
- package/src/gates/report.js +78 -0
- package/src/gates/safety.js +194 -0
- package/src/gates/structure.js +161 -0
- package/src/governance/behavior.js +231 -0
- package/src/governance/blacklist.js +66 -0
- package/src/governance/metrics.js +249 -0
- package/src/governance/rollback.js +66 -0
- package/src/governance/rollout.js +88 -0
- package/src/host/child-host.js +216 -0
- package/src/host/local-host.js +441 -0
- package/src/index.js +261 -0
- package/src/policy.js +239 -0
- package/src/portrait/portrait.js +129 -0
- package/src/runtime/bridge.js +1281 -0
- package/src/runtime/derive.js +208 -0
- package/src/runtime/facade.js +40 -0
- package/src/runtime/guards.js +583 -0
- package/src/runtime/handles/fs.js +193 -0
- package/src/runtime/handles/index.js +30 -0
- package/src/runtime/handles/inject.js +129 -0
- package/src/runtime/handles/net.js +177 -0
- package/src/runtime/handles/proc.js +78 -0
- package/src/runtime/ingress.js +717 -0
- package/src/runtime/ipc/child.js +88 -0
- package/src/runtime/ipc/protocol.js +127 -0
- package/src/runtime/ledger.js +582 -0
- package/src/runtime/one-seal.js +122 -0
- package/src/runtime/redirects.js +102 -0
- package/src/signing/bundle.js +290 -0
- package/src/signing/trust.js +90 -0
- package/src/util/paths.js +187 -0
- package/src/util/stable.js +25 -0
- package/test/attack-surface.test.js +774 -0
- package/test/audit.test.js +636 -0
- package/test/autopsy.test.js +104 -0
- package/test/batch-guard.test.js +81 -0
- package/test/behavior.test.js +132 -0
- package/test/bypass.test.js +231 -0
- package/test/child-host.test.js +127 -0
- package/test/child.test.js +183 -0
- package/test/contract.test.js +157 -0
- package/test/gates.test.js +275 -0
- package/test/glob.test.js +83 -0
- package/test/governance.test.js +102 -0
- package/test/handle.test.js +132 -0
- package/test/helpers/fixtures.js +124 -0
- package/test/ingress.test.js +416 -0
- package/test/invariants.test.js +314 -0
- package/test/ledger-mac.test.js +134 -0
- package/test/mac-custody.test.js +86 -0
- package/test/mandate.test.js +129 -0
- package/test/metrics-batch.test.js +234 -0
- package/test/metrics-telemetry.test.js +217 -0
- package/test/net-proc-handle.test.js +183 -0
- package/test/observe.test.js +169 -0
- package/test/options.test.js +174 -0
- package/test/portrait-labels.test.js +72 -0
- package/test/portrait.test.js +65 -0
- package/test/protocol.test.js +105 -0
- package/test/quick-wins.test.js +245 -0
- package/test/review-fixes.test.js +260 -0
- package/test/review2-fixes.test.js +191 -0
- package/test/review3-fixes.test.js +226 -0
- package/test/review4-fixes.test.js +486 -0
- package/test/review5-fixes.test.js +301 -0
- package/test/review6-fixes.test.js +311 -0
- package/test/runtime.test.js +185 -0
- package/test/sandbox.test.js +236 -0
- package/test/signatures.test.js +170 -0
- package/test/symlink.test.js +288 -0
- package/test/three-lens-fixes.test.js +397 -0
- package/test/worker.test.js +73 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { tempDir, cleanup } = require('./helpers/fixtures');
|
|
8
|
+
const { Ledger } = require('../src/runtime/ledger');
|
|
9
|
+
const { stableStringify, sha256 } = require('../src/util/stable');
|
|
10
|
+
const { OneSealStore } = require('../src/runtime/one-seal');
|
|
11
|
+
const { createGuardChain } = require('../src/runtime/guards');
|
|
12
|
+
const { LocalHost } = require('../src/host/local-host');
|
|
13
|
+
const { spawnSync } = require('node:child_process');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
|
|
16
|
+
function record(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
plugin: 'com.example.audit',
|
|
19
|
+
capability: 'fs.read',
|
|
20
|
+
action: 'fs.read',
|
|
21
|
+
scope: { paths: ['/tmp/x'] },
|
|
22
|
+
decision: 'allow',
|
|
23
|
+
ruleId: 'two-key',
|
|
24
|
+
reason: '读取缓存',
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('Ledger:正常账本可逐行重算', () => {
|
|
30
|
+
const dir = tempDir('audit-ok-');
|
|
31
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
32
|
+
const ledger = new Ledger(file);
|
|
33
|
+
ledger.append(record());
|
|
34
|
+
ledger.append(record({ decision: 'deny', ruleId: 'not_granted', reason: '宿主未授予' }));
|
|
35
|
+
const report = new Ledger(file).verify();
|
|
36
|
+
assert.equal(report.ok, true);
|
|
37
|
+
assert.equal(report.count, 2);
|
|
38
|
+
assert.equal(report.errors.length, 0);
|
|
39
|
+
assert.match(report.head, /^[a-f0-9]{64}$/);
|
|
40
|
+
cleanup(dir);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('Ledger:尾行损坏时报告 json_invalid,而不是让 verify 抛异常', () => {
|
|
44
|
+
const dir = tempDir('audit-tail-');
|
|
45
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
46
|
+
const ledger = new Ledger(file);
|
|
47
|
+
ledger.append(record());
|
|
48
|
+
ledger.append(record());
|
|
49
|
+
fs.appendFileSync(file, '{"v":1,"seq":3,"broken\n');
|
|
50
|
+
const report = new Ledger(file).verify();
|
|
51
|
+
assert.equal(report.ok, false);
|
|
52
|
+
assert.equal(report.head, null);
|
|
53
|
+
assert.ok(report.errors.some((error) => error.code === 'json_invalid' && error.line === 3));
|
|
54
|
+
cleanup(dir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('Ledger:v1 历史账本仍可验证(版本感知字段集)', () => {
|
|
58
|
+
const dir = tempDir('audit-v1-');
|
|
59
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
60
|
+
const base = {
|
|
61
|
+
v: 1,
|
|
62
|
+
seq: 1,
|
|
63
|
+
ts: '2026-10-01T00:00:00.000Z',
|
|
64
|
+
plugin: 'com.example.old',
|
|
65
|
+
pluginVersion: '1.0.0',
|
|
66
|
+
capability: 'fs.read',
|
|
67
|
+
action: 'fs.read',
|
|
68
|
+
scope: { paths: ['/tmp/x'] },
|
|
69
|
+
decision: 'allow',
|
|
70
|
+
ruleId: 'two-key',
|
|
71
|
+
reason: '旧版记录',
|
|
72
|
+
prevHash: 'GENESIS',
|
|
73
|
+
};
|
|
74
|
+
base.hash = sha256(stableStringify(base));
|
|
75
|
+
fs.writeFileSync(file, JSON.stringify(base) + '\n');
|
|
76
|
+
const report = new Ledger(file).verify();
|
|
77
|
+
assert.equal(report.ok, true, JSON.stringify(report.errors));
|
|
78
|
+
assert.equal(report.count, 1);
|
|
79
|
+
cleanup(dir);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('Ledger:v1 记录里塞入 intercepted 会被 hash 校验抓住', () => {
|
|
83
|
+
const dir = tempDir('audit-v1-tamper-');
|
|
84
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
85
|
+
const base = {
|
|
86
|
+
v: 1,
|
|
87
|
+
seq: 1,
|
|
88
|
+
ts: '2026-10-01T00:00:00.000Z',
|
|
89
|
+
plugin: 'com.example.old',
|
|
90
|
+
pluginVersion: null,
|
|
91
|
+
capability: 'fs.read',
|
|
92
|
+
action: 'fs.read',
|
|
93
|
+
scope: { paths: ['/tmp/x'] },
|
|
94
|
+
decision: 'allow',
|
|
95
|
+
ruleId: 'two-key',
|
|
96
|
+
reason: '旧版记录',
|
|
97
|
+
prevHash: 'GENESIS',
|
|
98
|
+
};
|
|
99
|
+
base.hash = sha256(stableStringify(base));
|
|
100
|
+
base.intercepted = true;
|
|
101
|
+
fs.writeFileSync(file, JSON.stringify(base) + '\n');
|
|
102
|
+
const report = new Ledger(file).verify();
|
|
103
|
+
assert.equal(report.ok, false);
|
|
104
|
+
assert.ok(report.errors.some((error) => error.code === 'hash_mismatch'));
|
|
105
|
+
cleanup(dir);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('Ledger:中间行被篡改时定位到具体行号', () => {
|
|
109
|
+
const dir = tempDir('audit-tamper-');
|
|
110
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
111
|
+
const ledger = new Ledger(file);
|
|
112
|
+
ledger.append(record());
|
|
113
|
+
ledger.append(record());
|
|
114
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
115
|
+
const first = JSON.parse(lines[0]);
|
|
116
|
+
first.decision = 'deny';
|
|
117
|
+
lines[0] = JSON.stringify(first);
|
|
118
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
|
119
|
+
const report = new Ledger(file).verify();
|
|
120
|
+
assert.equal(report.ok, false);
|
|
121
|
+
assert.ok(report.errors.some((error) => error.code === 'hash_mismatch' && error.line === 1));
|
|
122
|
+
cleanup(dir);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('One-Seal:审计写失败时释放占位,不白烧一次性授权(failMode=closed 显式开启)', () => {
|
|
126
|
+
const dir = tempDir('audit-seal-');
|
|
127
|
+
const scope = { paths: [path.join(dir, '**')] };
|
|
128
|
+
const seal = new OneSealStore({ ttlMs: 60_000 });
|
|
129
|
+
seal.issue({ id: 'seal-1', oneSeal: true, sealTtlMs: 60_000 });
|
|
130
|
+
const guard = createGuardChain({
|
|
131
|
+
manifest: {
|
|
132
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
133
|
+
dashcam: {
|
|
134
|
+
spec: 'dsh-dashcam/1',
|
|
135
|
+
content: null,
|
|
136
|
+
requests: [{ capability: 'fs.read', scope, reason: '读取缓存' }],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
grants: [{
|
|
140
|
+
id: 'seal-1',
|
|
141
|
+
capability: 'fs.read',
|
|
142
|
+
scope,
|
|
143
|
+
enabled: true,
|
|
144
|
+
oneSeal: true,
|
|
145
|
+
expiresAt: null,
|
|
146
|
+
sealTtlMs: 60_000,
|
|
147
|
+
}],
|
|
148
|
+
ledger: { append() { throw new Error('disk full'); } },
|
|
149
|
+
seal,
|
|
150
|
+
// closed 是 opt-in:只有显式配置才回到「写不进账就拒绝执行」的护栏语义。
|
|
151
|
+
policy: { ledger: { failMode: 'closed' } },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assert.throws(() => guard.enforce('fs.read', { path: path.join(dir, 'a.txt') }), /Ledger 写入失败/);
|
|
155
|
+
assert.equal(seal.isAvailable('seal-1'), true, '一次性授权不应被审计故障消耗');
|
|
156
|
+
cleanup(dir);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('One-Seal:failMode=open(默认)时账本故障不拦活,放行且消费一次性授权', () => {
|
|
160
|
+
const dir = tempDir('audit-seal-open-');
|
|
161
|
+
const scope = { paths: [path.join(dir, '**')] };
|
|
162
|
+
const seal = new OneSealStore({ ttlMs: 60_000 });
|
|
163
|
+
seal.issue({ id: 'seal-1', oneSeal: true, sealTtlMs: 60_000 });
|
|
164
|
+
let appendCalls = 0;
|
|
165
|
+
const guard = createGuardChain({
|
|
166
|
+
manifest: {
|
|
167
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
168
|
+
dashcam: {
|
|
169
|
+
spec: 'dsh-dashcam/1',
|
|
170
|
+
content: null,
|
|
171
|
+
requests: [{ capability: 'fs.read', scope, reason: '读取缓存' }],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
grants: [{
|
|
175
|
+
id: 'seal-1',
|
|
176
|
+
capability: 'fs.read',
|
|
177
|
+
scope,
|
|
178
|
+
enabled: true,
|
|
179
|
+
oneSeal: true,
|
|
180
|
+
expiresAt: null,
|
|
181
|
+
sealTtlMs: 60_000,
|
|
182
|
+
}],
|
|
183
|
+
ledger: { append() { appendCalls += 1; throw new Error('disk full'); } },
|
|
184
|
+
seal,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 默认 open:账本故障不抛、活动照常放行(行车记录仪不拦车);
|
|
188
|
+
// 放行了就要烧掉一次性授权,不能留下可重放的窗口。
|
|
189
|
+
const decision = guard.enforce('fs.read', { path: path.join(dir, 'a.txt') });
|
|
190
|
+
assert.equal(decision.allowed, true);
|
|
191
|
+
assert.equal(decision.ledgerSeq, null);
|
|
192
|
+
assert.equal(seal.isAvailable('seal-1'), false, '放行必须 commit 一次性授权');
|
|
193
|
+
// 原始写入 + ledger_fail 标记都尝试过(标记也失败属预期)。
|
|
194
|
+
assert.equal(appendCalls, 2);
|
|
195
|
+
cleanup(dir);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('归属标注:账本记录带 profiled / manifestHash(有契约与零契约两种形态)', () => {
|
|
199
|
+
const dir = tempDir('audit-attribution-');
|
|
200
|
+
const scope = { paths: [path.join(dir, '**')] };
|
|
201
|
+
const requests = [{ capability: 'fs.read', scope, reason: '读取缓存' }];
|
|
202
|
+
const manifestOf = (reqs) => ({
|
|
203
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
204
|
+
dashcam: { spec: 'dsh-dashcam/1', content: null, requests: reqs },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// 有契约:profiled=true,manifestHash 是声明清单的稳定哈希。
|
|
208
|
+
const fileA = path.join(dir, 'a.jsonl');
|
|
209
|
+
const guardA = createGuardChain({
|
|
210
|
+
manifest: manifestOf(requests),
|
|
211
|
+
grants: [{ id: 'g1', capability: 'fs.read', scope, enabled: true }],
|
|
212
|
+
ledger: new Ledger(fileA),
|
|
213
|
+
});
|
|
214
|
+
guardA.enforce('fs.read', { path: path.join(dir, 'a.txt') });
|
|
215
|
+
const rowA = new Ledger(fileA).records()[0];
|
|
216
|
+
assert.equal(rowA.profiled, true);
|
|
217
|
+
assert.equal(rowA.manifestHash, sha256(stableStringify(requests)));
|
|
218
|
+
|
|
219
|
+
// 零契约:profiled=false,manifestHash=null——「没契约」本身是可审计事实。
|
|
220
|
+
const fileB = path.join(dir, 'b.jsonl');
|
|
221
|
+
const guardB = createGuardChain({
|
|
222
|
+
manifest: manifestOf([]),
|
|
223
|
+
grants: [],
|
|
224
|
+
ledger: new Ledger(fileB),
|
|
225
|
+
});
|
|
226
|
+
const denial = guardB.check('fs.read', { path: path.join(dir, 'b.txt') });
|
|
227
|
+
assert.equal(denial.allowed, false);
|
|
228
|
+
const rowB = new Ledger(fileB).records()[0];
|
|
229
|
+
assert.equal(rowB.profiled, false);
|
|
230
|
+
assert.equal(rowB.manifestHash, null);
|
|
231
|
+
cleanup(dir);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('N1:本进程首次打开的账本若刚被外部写入,构造时显式告警', () => {
|
|
235
|
+
const dir = tempDir('audit-n1-');
|
|
236
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
237
|
+
// 模拟另一个写入者进程刚落了一行(本进程对这条路径没有任何共享基准)。
|
|
238
|
+
fs.writeFileSync(file, `${JSON.stringify({ v: 4, seq: 1, note: '外部写入' })}\n`, 'utf8');
|
|
239
|
+
const warnings = [];
|
|
240
|
+
const originalWarn = console.warn;
|
|
241
|
+
console.warn = (message) => warnings.push(String(message));
|
|
242
|
+
try {
|
|
243
|
+
new Ledger(file);
|
|
244
|
+
} finally {
|
|
245
|
+
console.warn = originalWarn;
|
|
246
|
+
}
|
|
247
|
+
assert.ok(
|
|
248
|
+
warnings.some((message) => message.includes('最近 5 秒内被外部修改')),
|
|
249
|
+
`应有外部新写告警,实际:${JSON.stringify(warnings)}`,
|
|
250
|
+
);
|
|
251
|
+
cleanup(dir);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('N1:默认账本路径按实例区分(profile → .dsh-ledger-<profile>.jsonl)', () => {
|
|
255
|
+
const defaultHost = new LocalHost();
|
|
256
|
+
assert.ok(
|
|
257
|
+
defaultHost.baseLedgerPath.endsWith(path.join('.dsh-ledger-default.jsonl'))
|
|
258
|
+
|| defaultHost.baseLedgerPath.endsWith('.dsh-ledger-default.jsonl'),
|
|
259
|
+
defaultHost.baseLedgerPath,
|
|
260
|
+
);
|
|
261
|
+
const webHost = new LocalHost({ profile: 'web' });
|
|
262
|
+
assert.ok(webHost.baseLedgerPath.endsWith('.dsh-ledger-web.jsonl'), webHost.baseLedgerPath);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('CLI verify-ledger:脱离宿主进程验链,篡改行报非零退出', () => {
|
|
266
|
+
const dir = tempDir('audit-cli-');
|
|
267
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
268
|
+
const ledger = new Ledger(file);
|
|
269
|
+
ledger.append(record());
|
|
270
|
+
ledger.append(record({ decision: 'deny', ruleId: 'not_granted', reason: '宿主未授予' }));
|
|
271
|
+
|
|
272
|
+
const bin = path.join(__dirname, '..', 'bin', 'dsh-dashcam.js');
|
|
273
|
+
const good = spawnSync(process.execPath, [bin, 'verify-ledger', file], { encoding: 'utf8' });
|
|
274
|
+
assert.equal(good.status, 0, good.stderr);
|
|
275
|
+
const parsed = JSON.parse(good.stdout);
|
|
276
|
+
assert.equal(parsed.ok, true);
|
|
277
|
+
assert.equal(parsed.records, 2);
|
|
278
|
+
assert.equal(parsed.lastSeq, 2);
|
|
279
|
+
|
|
280
|
+
// 篡改中间行的 reason(不改 hash)→ 校验失败、退出码 1。
|
|
281
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
282
|
+
const forged = JSON.parse(lines[0]);
|
|
283
|
+
forged.reason = '被篡改的理由';
|
|
284
|
+
lines[0] = stableStringify(forged);
|
|
285
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`, 'utf8');
|
|
286
|
+
const bad = spawnSync(process.execPath, [bin, 'verify-ledger', file], { encoding: 'utf8' });
|
|
287
|
+
assert.equal(bad.status, 1);
|
|
288
|
+
const badParsed = JSON.parse(bad.stdout);
|
|
289
|
+
assert.equal(badParsed.ok, false);
|
|
290
|
+
assert.ok(badParsed.errors.some((error) => error.code === 'hash_mismatch'));
|
|
291
|
+
cleanup(dir);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('N5:rotate 归档旧段、新段以 segment-rotate 链接记录开头,链可验', () => {
|
|
295
|
+
const dir = tempDir('audit-rotate-');
|
|
296
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
297
|
+
const ledger = new Ledger(file);
|
|
298
|
+
ledger.append(record());
|
|
299
|
+
ledger.append(record({ decision: 'deny', ruleId: 'not_granted', reason: '宿主未授予' }));
|
|
300
|
+
const before = new Ledger(file);
|
|
301
|
+
const firstHash = before.records()[0].hash;
|
|
302
|
+
const lastHash = before.records()[1].hash;
|
|
303
|
+
|
|
304
|
+
const result = ledger.rotate();
|
|
305
|
+
assert.ok(result.archivedPath.endsWith('.jsonl'), result.archivedPath);
|
|
306
|
+
assert.ok(fs.existsSync(result.archivedPath), '旧段应保留');
|
|
307
|
+
assert.equal(result.records, 2);
|
|
308
|
+
assert.equal(result.lastSeq, 2);
|
|
309
|
+
assert.equal(result.firstHash, firstHash);
|
|
310
|
+
assert.equal(result.lastHash, lastHash);
|
|
311
|
+
|
|
312
|
+
// 新段:链接记录 seq=1,scope 携带旧段首尾 hash(basename,不带绝对路径)。
|
|
313
|
+
const fresh = new Ledger(file);
|
|
314
|
+
const link = fresh.append({
|
|
315
|
+
plugin: 'dsh-dashcam',
|
|
316
|
+
pluginVersion: 'test',
|
|
317
|
+
capability: 'telemetry',
|
|
318
|
+
action: 'segment-rotate',
|
|
319
|
+
scope: {
|
|
320
|
+
archived: path.basename(result.archivedPath),
|
|
321
|
+
records: result.records,
|
|
322
|
+
lastSeq: result.lastSeq,
|
|
323
|
+
prevSegmentFirstHash: result.firstHash,
|
|
324
|
+
prevSegmentLastHash: result.lastHash,
|
|
325
|
+
},
|
|
326
|
+
decision: 'allow',
|
|
327
|
+
ruleId: 'rotate',
|
|
328
|
+
reason: '账本轮转:新段从此开始',
|
|
329
|
+
intercepted: false,
|
|
330
|
+
});
|
|
331
|
+
assert.equal(link.seq, 1);
|
|
332
|
+
assert.equal(new Ledger(file).verify().ok, true);
|
|
333
|
+
// 旧段归档后仍可独立验链。
|
|
334
|
+
assert.equal(new Ledger(result.archivedPath).verify().ok, true);
|
|
335
|
+
// 坏账拒绝轮转。
|
|
336
|
+
const brokenFile = path.join(dir, 'broken.jsonl');
|
|
337
|
+
const broken = new Ledger(brokenFile);
|
|
338
|
+
broken.append(record());
|
|
339
|
+
const lines = fs.readFileSync(brokenFile, 'utf8').split('\n').filter(Boolean);
|
|
340
|
+
const forged = JSON.parse(lines[0]);
|
|
341
|
+
forged.reason = '篡改';
|
|
342
|
+
fs.writeFileSync(brokenFile, `${stableStringify(forged)}\n`, 'utf8');
|
|
343
|
+
assert.throws(() => new Ledger(brokenFile).rotate(), /拒绝轮转/);
|
|
344
|
+
cleanup(dir);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('N5:_recover 尾读——超过尾窗的大账本仍能正确续写(seq/hash 接上)', () => {
|
|
348
|
+
const dir = tempDir('audit-tail-');
|
|
349
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
350
|
+
const ledger = new Ledger(file);
|
|
351
|
+
// >64KB 尾窗:每条约 200B,写 500 条。
|
|
352
|
+
for (let index = 0; index < 500; index += 1) {
|
|
353
|
+
ledger.append(record({ reason: `填充记录 ${index},padding`.padEnd(160, 'x') }));
|
|
354
|
+
}
|
|
355
|
+
const all = new Ledger(file).records();
|
|
356
|
+
assert.equal(all.length, 500);
|
|
357
|
+
assert.equal(all[499].seq, 500);
|
|
358
|
+
// 尾读之后继续追加:seq 与 prevHash 必须接上(append 内部断言 prevHash 一致性)。
|
|
359
|
+
const next = new Ledger(file).append(record({ reason: '尾读后续写' }));
|
|
360
|
+
assert.equal(next.seq, 501);
|
|
361
|
+
assert.equal(new Ledger(file).verify().ok, true);
|
|
362
|
+
cleanup(dir);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('N5:redact 折叠 home 为 $HOME、保留归属字段,输出为可验的新链', () => {
|
|
366
|
+
const dir = tempDir('audit-redact-');
|
|
367
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
368
|
+
const ledger = new Ledger(file);
|
|
369
|
+
const homePath = path.join(os.homedir(), 'secret-project', 'token.pem');
|
|
370
|
+
ledger.append(record({ plugin: 'p1', reason: `读取 ${homePath} 失败`, scope: { paths: [homePath] } }));
|
|
371
|
+
ledger.append(record({ plugin: 'p2', decision: 'deny', ruleId: 'not_declared', reason: '宿主未授予' }));
|
|
372
|
+
|
|
373
|
+
const bin = path.join(__dirname, '..', 'bin', 'dsh-dashcam.js');
|
|
374
|
+
const out = path.join(dir, 'redacted.jsonl');
|
|
375
|
+
const run = spawnSync(process.execPath, [bin, 'redact', file, '--out', out], { encoding: 'utf8' });
|
|
376
|
+
assert.equal(run.status, 0, run.stderr);
|
|
377
|
+
const summary = JSON.parse(run.stdout);
|
|
378
|
+
assert.equal(summary.records, 2);
|
|
379
|
+
assert.ok(summary.replacements >= 2, `至少折叠 home 两次:${JSON.stringify(summary)}`);
|
|
380
|
+
|
|
381
|
+
const redacted = new Ledger(out);
|
|
382
|
+
assert.equal(redacted.verify().ok, true, '脱敏副本自身应是完整可验的新链');
|
|
383
|
+
const rows = redacted.records();
|
|
384
|
+
assert.ok(!JSON.stringify(rows).includes(os.homedir()), '不得残留 home 前缀');
|
|
385
|
+
assert.ok(rows[0].scope.paths[0].includes('$HOME'));
|
|
386
|
+
// 归属字段原样保留:谁干的不能被脱敏抹掉。
|
|
387
|
+
assert.equal(rows[0].plugin, 'p1');
|
|
388
|
+
assert.equal(rows[1].plugin, 'p2');
|
|
389
|
+
assert.equal(typeof rows[0].profiled, 'boolean');
|
|
390
|
+
assert.equal(rows[1].manifestHash, rows[1].profiled ? rows[1].manifestHash : null);
|
|
391
|
+
cleanup(dir);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('One-Seal:成功放行后 commit,第二次必然失败', () => {
|
|
395
|
+
const dir = tempDir('audit-seal-ok-');
|
|
396
|
+
const scope = { paths: [path.join(dir, '**')] };
|
|
397
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
398
|
+
const seal = new OneSealStore({ ttlMs: 60_000 });
|
|
399
|
+
seal.issue({ id: 'seal-1', oneSeal: true, sealTtlMs: 60_000 });
|
|
400
|
+
const guard = createGuardChain({
|
|
401
|
+
manifest: {
|
|
402
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
403
|
+
dashcam: {
|
|
404
|
+
spec: 'dsh-dashcam/1',
|
|
405
|
+
content: null,
|
|
406
|
+
requests: [{ capability: 'fs.read', scope, reason: '读取缓存' }],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
grants: [{
|
|
410
|
+
id: 'seal-1',
|
|
411
|
+
capability: 'fs.read',
|
|
412
|
+
scope,
|
|
413
|
+
enabled: true,
|
|
414
|
+
oneSeal: true,
|
|
415
|
+
expiresAt: null,
|
|
416
|
+
sealTtlMs: 60_000,
|
|
417
|
+
}],
|
|
418
|
+
ledger: new Ledger(file),
|
|
419
|
+
seal,
|
|
420
|
+
});
|
|
421
|
+
guard.enforce('fs.read', { path: path.join(dir, 'a.txt') });
|
|
422
|
+
assert.equal(seal.isAvailable('seal-1'), false);
|
|
423
|
+
assert.throws(() => guard.enforce('fs.read', { path: path.join(dir, 'b.txt') }), /一次性授予/);
|
|
424
|
+
cleanup(dir);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('审计归因:reason 来自真正覆盖请求的那条声明', () => {
|
|
428
|
+
const dir = tempDir('audit-reason-');
|
|
429
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
430
|
+
const guard = createGuardChain({
|
|
431
|
+
manifest: {
|
|
432
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
433
|
+
dashcam: {
|
|
434
|
+
spec: 'dsh-dashcam/1',
|
|
435
|
+
content: null,
|
|
436
|
+
requests: [
|
|
437
|
+
{ capability: 'net.outbound', scope: { hosts: ['metrics.example:443'] }, reason: '上报指标' },
|
|
438
|
+
{ capability: 'net.outbound', scope: { hosts: ['cdn.example:443'] }, reason: '下载依赖' },
|
|
439
|
+
],
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
grants: [
|
|
443
|
+
{ id: 'g1', capability: 'net.outbound', scope: { hosts: ['metrics.example:443'] }, enabled: true },
|
|
444
|
+
{ id: 'g2', capability: 'net.outbound', scope: { hosts: ['cdn.example:443'] }, enabled: true },
|
|
445
|
+
],
|
|
446
|
+
ledger: new Ledger(file),
|
|
447
|
+
});
|
|
448
|
+
guard.enforce('net.outbound', { host: 'cdn.example', port: 443 });
|
|
449
|
+
const last = new Ledger(file).records().at(-1);
|
|
450
|
+
assert.equal(last.decision, 'allow');
|
|
451
|
+
assert.equal(last.reason, '下载依赖');
|
|
452
|
+
cleanup(dir);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('Ledger:intercepted 标记未声明调用,interceptions() 按该口径统计', () => {
|
|
456
|
+
const dir = tempDir('audit-intercept-');
|
|
457
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
458
|
+
const guard = createGuardChain({
|
|
459
|
+
manifest: {
|
|
460
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
461
|
+
dashcam: {
|
|
462
|
+
spec: 'dsh-dashcam/1',
|
|
463
|
+
content: null,
|
|
464
|
+
requests: [{ capability: 'fs.read', scope: { paths: ['$PLUGIN/**'] }, reason: '读取自身文件' }],
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
grants: [{ id: 'g1', capability: 'fs.read', scope: { paths: ['$PLUGIN/**'] }, enabled: true }],
|
|
468
|
+
ledger: new Ledger(file),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const denied = guard.check('net.outbound', { host: 'evil.example', port: 443 });
|
|
472
|
+
assert.equal(denied.allowed, false);
|
|
473
|
+
assert.equal(denied.code, 'not_declared');
|
|
474
|
+
assert.equal(denied.intercepted, true);
|
|
475
|
+
|
|
476
|
+
const ledger = new Ledger(file);
|
|
477
|
+
assert.equal(ledger.verify().ok, true);
|
|
478
|
+
const record = ledger.records().at(-1);
|
|
479
|
+
assert.equal(record.intercepted, true);
|
|
480
|
+
assert.equal(record.ruleId, 'not_declared');
|
|
481
|
+
assert.equal(ledger.interceptions().length, 1);
|
|
482
|
+
assert.equal(guard.stats.intercepted, 1);
|
|
483
|
+
cleanup(dir);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('守卫入口:installNativeHooks 把 fs 直调接入同一条守卫链', () => {
|
|
487
|
+
const dir = tempDir('audit-hook-');
|
|
488
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
489
|
+
const outsideDir = tempDir('audit-hook-out-');
|
|
490
|
+
const outside = path.join(outsideDir, 'secret.txt');
|
|
491
|
+
fs.writeFileSync(outside, 'secret');
|
|
492
|
+
const guard = createGuardChain({
|
|
493
|
+
manifest: {
|
|
494
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
495
|
+
dashcam: {
|
|
496
|
+
spec: 'dsh-dashcam/1',
|
|
497
|
+
content: null,
|
|
498
|
+
requests: [],
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
grants: [],
|
|
502
|
+
ledger: new Ledger(file),
|
|
503
|
+
});
|
|
504
|
+
const hooks = guard.installNativeHooks();
|
|
505
|
+
assert.ok(hooks);
|
|
506
|
+
assert.equal(guard.hooks, hooks);
|
|
507
|
+
try {
|
|
508
|
+
assert.throws(() => fs.readFileSync(outside, 'utf8'), /未在 manifest 中声明|未授予/);
|
|
509
|
+
} finally {
|
|
510
|
+
guard.uninstallNativeHooks();
|
|
511
|
+
}
|
|
512
|
+
assert.equal(guard.hooks, null);
|
|
513
|
+
assert.equal(new Ledger(file).interceptions().length, 1);
|
|
514
|
+
cleanup(dir);
|
|
515
|
+
cleanup(outsideDir);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('Ledger MAC:有密钥的账本无法被无密钥者重算', () => {
|
|
519
|
+
const dir = tempDir('audit-mac-');
|
|
520
|
+
const file = path.join(dir, 'ledger.jsonl');
|
|
521
|
+
const key = 'test-mac-key-32bytes-padded-000000';
|
|
522
|
+
const ledger = new Ledger(file, { macKey: key });
|
|
523
|
+
ledger.append(record());
|
|
524
|
+
ledger.append(record({ decision: 'deny', ruleId: 'not_declared', intercepted: true }));
|
|
525
|
+
assert.equal(new Ledger(file, { macKey: key }).verify().ok, true);
|
|
526
|
+
assert.equal(new Ledger(file).records()[0].v, 4);
|
|
527
|
+
|
|
528
|
+
// 篡改者重算了哈希链但没有密钥:mac 校验必须抓出来。
|
|
529
|
+
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
530
|
+
const forged = JSON.parse(lines[1]);
|
|
531
|
+
forged.reason = '伪造原因';
|
|
532
|
+
delete forged.hash;
|
|
533
|
+
forged.hash = sha256(stableStringify({ ...forged, mac: undefined }));
|
|
534
|
+
forged.mac = '0'.repeat(64); // 没有密钥,只能瞎编
|
|
535
|
+
lines[1] = stableStringify(forged);
|
|
536
|
+
fs.writeFileSync(file, lines.join('\n') + '\n');
|
|
537
|
+
const result = new Ledger(file, { macKey: key }).verify();
|
|
538
|
+
assert.equal(result.ok, false);
|
|
539
|
+
assert.ok(result.errors.some((error) => error.code === 'mac_mismatch'));
|
|
540
|
+
|
|
541
|
+
// 错误的密钥同样全部失败。
|
|
542
|
+
fs.writeFileSync(file, '');
|
|
543
|
+
const other = new Ledger(file, { macKey: 'wrong-key' });
|
|
544
|
+
other.append(record());
|
|
545
|
+
assert.equal(new Ledger(file, { macKey: key }).verify().ok, false);
|
|
546
|
+
cleanup(dir);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('TOCTOU:open 后按 fd 复核,符号链接替换落点被二次判定拦截', (t) => {
|
|
550
|
+
const dir = tempDir('audit-toctou-');
|
|
551
|
+
const outsideDir = tempDir('audit-toctou-out-');
|
|
552
|
+
const outside = path.join(outsideDir, 'secret.txt');
|
|
553
|
+
fs.writeFileSync(outside, 'secret');
|
|
554
|
+
const linkInside = path.join(dir, 'alias.txt');
|
|
555
|
+
try {
|
|
556
|
+
fs.symlinkSync(outside, linkInside);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
t.skip(`当前环境不允许创建符号链接:${error.code}`);
|
|
559
|
+
cleanup(dir);
|
|
560
|
+
cleanup(outsideDir);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const guard = createGuardChain({
|
|
564
|
+
manifest: {
|
|
565
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
566
|
+
dashcam: {
|
|
567
|
+
spec: 'dsh-dashcam/1',
|
|
568
|
+
content: null,
|
|
569
|
+
requests: [{ capability: 'fs.read', scope: { paths: [`${dir.replace(/\\/g, '/')}/**`] }, reason: '读取插件目录' }],
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
grants: [{ id: 'g1', capability: 'fs.read', scope: { paths: [`${dir.replace(/\\/g, '/')}/**`] }, enabled: true }],
|
|
573
|
+
ledger: new Ledger(path.join(dir, 'ledger.jsonl')),
|
|
574
|
+
});
|
|
575
|
+
const hooks = guard.installNativeHooks();
|
|
576
|
+
try {
|
|
577
|
+
// 链接本体在插件目录内(判定通过),落点在目录外(fd 复核必须拦下)。
|
|
578
|
+
// Linux 上 fd 复核走另一分支,文案是「未声明的能力或范围」;Windows 上是「未授予/拒绝」。
|
|
579
|
+
assert.throws(() => fs.readFileSync(linkInside, 'utf8'), /未在 manifest 中声明|未授予|未声明的能力或范围|拒绝/);
|
|
580
|
+
} finally {
|
|
581
|
+
guard.uninstallNativeHooks();
|
|
582
|
+
cleanup(dir);
|
|
583
|
+
cleanup(outsideDir);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test('globSync:路径在返回值里,逐个结果都要过守卫', (t) => {
|
|
588
|
+
if (typeof fs.globSync !== 'function') {
|
|
589
|
+
t.skip('当前 Node 版本没有 fs.globSync');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const dir = tempDir('audit-glob-');
|
|
593
|
+
const outsideDir = tempDir('audit-glob-out-');
|
|
594
|
+
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret');
|
|
595
|
+
const guard = createGuardChain({
|
|
596
|
+
manifest: {
|
|
597
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
598
|
+
dashcam: { spec: 'dsh-dashcam/1', content: null, requests: [] },
|
|
599
|
+
},
|
|
600
|
+
grants: [],
|
|
601
|
+
ledger: new Ledger(path.join(dir, 'ledger.jsonl')),
|
|
602
|
+
});
|
|
603
|
+
guard.installNativeHooks();
|
|
604
|
+
try {
|
|
605
|
+
assert.throws(
|
|
606
|
+
() => fs.globSync('*.txt', { cwd: outsideDir }),
|
|
607
|
+
/未在 manifest 中声明|未授予/,
|
|
608
|
+
);
|
|
609
|
+
} finally {
|
|
610
|
+
guard.uninstallNativeHooks();
|
|
611
|
+
cleanup(dir);
|
|
612
|
+
cleanup(outsideDir);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test('worker_threads:Worker 创建收敛到 proc.spawn 判定', (t) => {
|
|
617
|
+
const dir = tempDir('audit-worker-');
|
|
618
|
+
const workerFile = path.join(dir, 'worker.js');
|
|
619
|
+
fs.writeFileSync(workerFile, '/* worker */');
|
|
620
|
+
const guard = createGuardChain({
|
|
621
|
+
manifest: {
|
|
622
|
+
id: 'p', name: 'P', version: '1.0.0', entry: 'index.js', kind: 'dashcam',
|
|
623
|
+
dashcam: { spec: 'dsh-dashcam/1', content: null, requests: [] },
|
|
624
|
+
},
|
|
625
|
+
grants: [],
|
|
626
|
+
ledger: new Ledger(path.join(dir, 'ledger.jsonl')),
|
|
627
|
+
});
|
|
628
|
+
guard.installNativeHooks();
|
|
629
|
+
try {
|
|
630
|
+
const { Worker } = require('node:worker_threads');
|
|
631
|
+
assert.throws(() => new Worker(workerFile), /未在 manifest 中声明|未授予/);
|
|
632
|
+
} finally {
|
|
633
|
+
guard.uninstallNativeHooks();
|
|
634
|
+
cleanup(dir);
|
|
635
|
+
}
|
|
636
|
+
});
|