@moonquake2004/dsh-security 0.1.7 → 0.2.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/package.json +1 -1
- package/src/checks/index.mjs +3 -0
- package/src/checks/sl1-supply-chain.mjs +11 -6
- package/src/checks/sl4-release-compat.mjs +11 -3
- package/src/checks/sp1-dependency-audit.mjs +155 -65
- package/src/checks/sp11-patch-security-override.mjs +132 -0
- package/src/checks/sp12-config-as-code-tag.mjs +125 -0
- package/src/checks/sp13-tools-mode-sandbox.mjs +631 -0
- package/src/checks/sp3-sandbox-consistency.mjs +293 -147
- package/src/checks/sp5-permission-model.mjs +267 -53
- package/src/checks/sp8-dist-tag-health.mjs +16 -0
- package/src/checks/sp9-dual-instance-guard.mjs +241 -57
- package/src/checks/sr1-sandbox-violation.mjs +22 -23
- package/src/checks/sr2-privilege-escalation.mjs +26 -11
- package/src/checks/sr3-data-exfiltration.mjs +6 -14
- package/src/checks/sr4-isolation-verify.mjs +4 -5
- package/src/checks/ss2-pii-exposure.mjs +8 -3
- package/src/checks/ss3-sensitive-output.mjs +9 -8
- package/src/checks/ss4-session-integrity.mjs +20 -18
- package/src/dsh-config.mjs +204 -0
- package/src/index.mjs +3 -0
- package/src/install-tree.mjs +293 -0
- package/src/integrations/ecosystem.mjs +232 -48
- package/src/integrations/index.mjs +72 -24
- package/src/integrations/plugin-reducer.mjs +190 -40
- package/src/integrations/poison-guard.mjs +162 -39
- package/src/integrations/sandbox-audit.mjs +51 -47
- package/src/integrations/tool-exec.mjs +130 -0
- package/src/registry.mjs +3 -0
- package/src/session-reader.mjs +134 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部工具调用助手(跨平台)——供 EXT-* 集成共用
|
|
3
|
+
*
|
|
4
|
+
* 复审修复(docs/ecosystem-audit-2026-09.md §3(c)/§4.4),两处共性问题:
|
|
5
|
+
*
|
|
6
|
+
* 1. `which` 探测不可移植:Windows 没有 `which`,旧实现的 `isAvailable()` 在 Windows 上
|
|
7
|
+
* 永远返回 false(连带把「工具未安装」和「平台不支持」混为一谈)。
|
|
8
|
+
* 这里改为纯 Node 的 PATH 扫描:POSIX 校验可执行位,Windows 额外按 PATHEXT 匹配
|
|
9
|
+
* .COM/.EXE/.BAT/.CMD。不启动任何子进程,因此探测本身不会执行第三方代码。
|
|
10
|
+
*
|
|
11
|
+
* 2. `execFileSync` 会把「非零退出码」变成异常:poison-guard 用 exit 1 表示「发现投毒」、
|
|
12
|
+
* reducer 用 exit 1 表示「归约失败并给出了 error envelope」,两者在非零退出时**都仍然
|
|
13
|
+
* 往 stdout 写结构化 JSON**。用 execFileSync 时这些 stdout 随异常一起丢掉,
|
|
14
|
+
* 真实发现退化成一句没有信息的 skip。
|
|
15
|
+
* 这里改用 `spawnSync` 包装,永不抛异常,把 stdout/stderr/退出码/signal 一并交回调用方判断。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
20
|
+
import { delimiter, extname, isAbsolute, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const WINDOWS = process.platform === 'win32';
|
|
23
|
+
|
|
24
|
+
/** 默认的 Windows 可执行扩展名(PATH 中没有 PATHEXT 时的回退) */
|
|
25
|
+
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 在 PATH 中查找可执行文件,返回绝对路径;找不到返回 null。
|
|
29
|
+
* 纯 fs 扫描,不 fork 子进程——探测第三方工具可用性时不应执行第三方代码。
|
|
30
|
+
*
|
|
31
|
+
* @param {string} bin 可执行文件名(也可传含路径分隔符的相对/绝对路径)
|
|
32
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
export function findExecutable(bin, env = process.env) {
|
|
36
|
+
if (!bin || typeof bin !== 'string') return null;
|
|
37
|
+
|
|
38
|
+
const explicitPath = isAbsolute(bin) || bin.includes('/') || bin.includes('\\');
|
|
39
|
+
const dirs = explicitPath
|
|
40
|
+
? ['']
|
|
41
|
+
: String(env.PATH ?? env.Path ?? '').split(delimiter).filter(Boolean);
|
|
42
|
+
|
|
43
|
+
const exts = WINDOWS
|
|
44
|
+
? String(env.PATHEXT ?? DEFAULT_PATHEXT).split(';').map(e => e.trim()).filter(Boolean)
|
|
45
|
+
: [''];
|
|
46
|
+
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
const base = dir ? join(dir, bin) : bin;
|
|
49
|
+
const candidates = [base];
|
|
50
|
+
// 已带扩展名(如 dsh-plugin-reducer.cmd)时不再叠加 PATHEXT
|
|
51
|
+
if (WINDOWS && !extname(base)) {
|
|
52
|
+
for (const ext of exts) candidates.push(base + ext.toLowerCase(), base + ext.toUpperCase());
|
|
53
|
+
}
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
try {
|
|
56
|
+
if (!statSync(candidate).isFile()) continue;
|
|
57
|
+
// POSIX:必须是可执行文件;Windows:X_OK 无实义,但 accessSync 仍可用于存在性确认
|
|
58
|
+
if (!WINDOWS) accessSync(candidate, constants.X_OK);
|
|
59
|
+
return candidate;
|
|
60
|
+
} catch { /* 试下一个候选 */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 执行外部命令并返回结构化结果——**永不抛异常**。
|
|
68
|
+
*
|
|
69
|
+
* @param {string} bin
|
|
70
|
+
* @param {string[]} args
|
|
71
|
+
* @param {{timeoutMs?: number, env?: object, cwd?: string, maxBuffer?: number}} [options]
|
|
72
|
+
* @returns {{status: number|null, signal: string|null, stdout: string, stderr: string, error: Error|null}}
|
|
73
|
+
*/
|
|
74
|
+
export function execCapture(bin, args, options = {}) {
|
|
75
|
+
const { timeoutMs = 60000, env, cwd, maxBuffer = 16 * 1024 * 1024 } = options;
|
|
76
|
+
const result = spawnSync(bin, args, {
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
timeout: timeoutMs,
|
|
79
|
+
maxBuffer,
|
|
80
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
81
|
+
...(env ? { env } : {}),
|
|
82
|
+
...(cwd ? { cwd } : {}),
|
|
83
|
+
// Windows 上 npm 安装的可执行文件是 .cmd/.bat 垫片,不经过 shell 无法直接 spawn
|
|
84
|
+
shell: WINDOWS,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
status: result.status ?? null,
|
|
89
|
+
signal: result.signal ?? null,
|
|
90
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
91
|
+
stderr: typeof result.stderr === 'string' ? result.stderr : '',
|
|
92
|
+
error: result.error ?? null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 把一次失败执行压缩成一行可读原因(用于 skip 的 reason)。
|
|
98
|
+
* @param {{status: number|null, signal: string|null, stderr: string, error: Error|null}} res
|
|
99
|
+
*/
|
|
100
|
+
export function describeFailure(res) {
|
|
101
|
+
const bits = [];
|
|
102
|
+
if (res?.error?.message) bits.push(String(res.error.message).slice(0, 120));
|
|
103
|
+
if (res?.signal) bits.push(`signal=${res.signal}`);
|
|
104
|
+
bits.push(`exit=${res?.status ?? 'null'}`);
|
|
105
|
+
const lastStderrLine = String(res?.stderr ?? '').trim().split('\n').filter(Boolean).pop();
|
|
106
|
+
if (lastStderrLine) bits.push(lastStderrLine.slice(0, 120));
|
|
107
|
+
return bits.join(', ');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 解析 stdout 上的 JSON,返回 {ok, value};不抛异常。
|
|
112
|
+
* 工具约定「一行 JSON envelope」或任意 JSON 文档,这里整段解析。
|
|
113
|
+
* @param {string} text
|
|
114
|
+
*/
|
|
115
|
+
export function parseJson(text) {
|
|
116
|
+
const trimmed = String(text ?? '').trim();
|
|
117
|
+
if (trimmed === '') return { ok: false, value: undefined };
|
|
118
|
+
try {
|
|
119
|
+
return { ok: true, value: JSON.parse(trimmed) };
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, value: undefined };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** JS 值的类型名,用于「契约已变化」类 skip 的原因描述 */
|
|
126
|
+
export function typeName(value) {
|
|
127
|
+
if (value === null) return 'null';
|
|
128
|
+
if (Array.isArray(value)) return 'array';
|
|
129
|
+
return typeof value;
|
|
130
|
+
}
|
package/src/registry.mjs
CHANGED
|
@@ -152,6 +152,9 @@ export async function createDefaultRegistry(loadExternal = true, options = {}) {
|
|
|
152
152
|
import('./checks/sp8-dist-tag-health.mjs'),
|
|
153
153
|
import('./checks/sp9-dual-instance-guard.mjs'),
|
|
154
154
|
import('./checks/sp10-poison-pattern.mjs'),
|
|
155
|
+
import('./checks/sp11-patch-security-override.mjs'),
|
|
156
|
+
import('./checks/sp12-config-as-code-tag.mjs'),
|
|
157
|
+
import('./checks/sp13-tools-mode-sandbox.mjs'),
|
|
155
158
|
]);
|
|
156
159
|
for (const mod of modules) {
|
|
157
160
|
for (const val of Object.values(mod)) {
|
package/src/session-reader.mjs
CHANGED
|
@@ -61,3 +61,137 @@ export async function scanSessionLines(sessionFile, onLine) {
|
|
|
61
61
|
}
|
|
62
62
|
return lineCount;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/* ================= 世代感知的会话定位 + 归一化载荷(形态规范见 docs/session-shape-v3.md) =================
|
|
66
|
+
* 2026-09 回归教训:DSH 起用 session.v<N>.jsonl.zstd(当前 v3),旧定位器只认 session.jsonl[.zstd],
|
|
67
|
+
* 导致整套 SR/SS 检查在分析 2 天前的旧世代日志。此后定位必须世代感知。
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
71
|
+
import { join } from 'node:path';
|
|
72
|
+
|
|
73
|
+
/** 会话文件名:session.jsonl[.zstd] / session.v<N>.jsonl[.zstd](无 .vN 视为世代 0)。 */
|
|
74
|
+
export const SESSION_FILE_RE = /^session(?:\.v(\d+))?\.jsonl(\.zstd|\.zst)?$/;
|
|
75
|
+
|
|
76
|
+
/** 从文件名解析世代号;非会话文件返回 -1。 */
|
|
77
|
+
export function sessionGeneration(fileName) {
|
|
78
|
+
const m = SESSION_FILE_RE.exec(fileName);
|
|
79
|
+
return m ? Number(m[1] ?? 0) : -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 同一会话目录内取最高世代的日志文件(.zstd 优先);无匹配返回 null。 */
|
|
83
|
+
function bestLogInDir(dir) {
|
|
84
|
+
let entries;
|
|
85
|
+
try { entries = readdirSync(dir); } catch { return null; }
|
|
86
|
+
let best = null;
|
|
87
|
+
for (const name of entries) {
|
|
88
|
+
const gen = sessionGeneration(name);
|
|
89
|
+
if (gen < 0) continue;
|
|
90
|
+
const f = join(dir, name);
|
|
91
|
+
let st;
|
|
92
|
+
try { st = statSync(f); } catch { continue; }
|
|
93
|
+
if (!st.isFile()) continue;
|
|
94
|
+
const isZstd = /\.zst(d)?$/.test(name) ? 1 : 0;
|
|
95
|
+
if (!best || gen > best.generation || (gen === best.generation && isZstd > best.isZstd)) {
|
|
96
|
+
best = { file: f, generation: gen, isZstd, mtimeMs: st.mtimeMs };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return best;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 定位最新会话日志(世代感知)。
|
|
104
|
+
* - 同一会话目录:取最高世代(而非文件名字典序)
|
|
105
|
+
* - 跨目录:按 mtime 取最新
|
|
106
|
+
* - 忽略 session.lock;兼容散落在 user 目录下的 session.jsonl
|
|
107
|
+
* @param {string} dshHome DSH home(含 sessions/)
|
|
108
|
+
* @returns {{file: string, generation: number, mtimeMs: number}|null}
|
|
109
|
+
*/
|
|
110
|
+
export function findLatestSession(dshHome) {
|
|
111
|
+
const root = join(dshHome, 'sessions');
|
|
112
|
+
if (!existsSync(root)) return null;
|
|
113
|
+
let best = null;
|
|
114
|
+
let projects;
|
|
115
|
+
try { projects = readdirSync(root); } catch { return null; }
|
|
116
|
+
for (const proj of projects) {
|
|
117
|
+
if (proj === 'session.lock') continue;
|
|
118
|
+
const pdir = join(root, proj);
|
|
119
|
+
let subs;
|
|
120
|
+
try { subs = readdirSync(pdir, { withFileTypes: true }); } catch { continue; }
|
|
121
|
+
for (const sub of subs) {
|
|
122
|
+
if (!sub.isDirectory()) {
|
|
123
|
+
// 散文件:sessions/<proj>/session*.jsonl*
|
|
124
|
+
if (sub.isFile() && sessionGeneration(sub.name) >= 0) {
|
|
125
|
+
const f = join(pdir, sub.name);
|
|
126
|
+
let st; try { st = statSync(f); } catch { continue; }
|
|
127
|
+
if (!best || st.mtimeMs > best.mtimeMs) best = { file: f, generation: sessionGeneration(sub.name), mtimeMs: st.mtimeMs };
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const cand = bestLogInDir(join(pdir, sub.name));
|
|
132
|
+
if (cand && (!best || cand.mtimeMs > best.mtimeMs)) best = cand;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return best;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 归一化事件载荷 —— SR/SS 检查**只应从这里取字段**,不要各自猜字段名。
|
|
140
|
+
* 兼容 v3(data.arguments 为 JSON 字符串、结果嵌在 data.message)与旧世代(扁平 args/input/output/result/text)。
|
|
141
|
+
* @returns {{kind:'call'|'result'|'other', type:string, name:string|null, argsText:string,
|
|
142
|
+
* callId:string|null, resultText:string, turn:number|null, step:number|null, seq:number|null}}
|
|
143
|
+
*/
|
|
144
|
+
export function extractEvent(event) {
|
|
145
|
+
const data = (event && typeof event.data === 'object' && event.data) || {};
|
|
146
|
+
const type = String(event?.type ?? '');
|
|
147
|
+
const kind = type === 'tool/call' ? 'call' : type === 'tool/result' ? 'result' : 'other';
|
|
148
|
+
|
|
149
|
+
let argsText = '';
|
|
150
|
+
const rawArgs = data.arguments ?? data.args ?? data.input;
|
|
151
|
+
if (typeof rawArgs === 'string') argsText = rawArgs;
|
|
152
|
+
else if (rawArgs != null) { try { argsText = JSON.stringify(rawArgs); } catch { argsText = String(rawArgs); } }
|
|
153
|
+
|
|
154
|
+
const msg = (typeof data.message === 'object' && data.message) || null;
|
|
155
|
+
const blocks = msg && Array.isArray(msg.content) ? msg.content : [];
|
|
156
|
+
const callId = data.callId
|
|
157
|
+
?? msg?.source?.callId
|
|
158
|
+
?? blocks.find((b) => b && typeof b.toolCallId === 'string')?.toolCallId
|
|
159
|
+
?? null;
|
|
160
|
+
|
|
161
|
+
const parts = [];
|
|
162
|
+
for (const blk of blocks) {
|
|
163
|
+
if (!blk || typeof blk !== 'object') continue;
|
|
164
|
+
if (Array.isArray(blk.content)) {
|
|
165
|
+
for (const inner of blk.content) if (inner && typeof inner.text === 'string') parts.push(inner.text);
|
|
166
|
+
} else if (typeof blk.text === 'string') parts.push(blk.text);
|
|
167
|
+
if (typeof blk.output === 'string') parts.push(blk.output);
|
|
168
|
+
}
|
|
169
|
+
let resultText = parts.join('\n');
|
|
170
|
+
if (!resultText) {
|
|
171
|
+
const flat = data.output ?? data.result ?? data.text;
|
|
172
|
+
if (typeof flat === 'string') resultText = flat;
|
|
173
|
+
else if (flat != null) { try { resultText = JSON.stringify(flat); } catch { /* 忽略 */ } }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const turn = typeof data.turn === 'number' ? data.turn : (typeof event?.turn === 'number' ? event.turn : null);
|
|
177
|
+
const step = typeof data.step === 'number' ? data.step : (typeof event?.step === 'number' ? event.step : null);
|
|
178
|
+
return { kind, type, name: data.name ?? data.tool ?? null, argsText, callId, resultText, turn, step, seq: typeof event?.seq === 'number' ? event.seq : null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 是否"命令执行类"工具 —— 静态沙箱/提权/外泄规则只应作用于这类工具。
|
|
183
|
+
* 否则文档工具(write/edit)里**引用**路径(如审计文档里写 `/etc/passwd`)会被误判为逃逸行为
|
|
184
|
+
* (2026-09 实测:解除失明后 SR1 在真实日志上报 361 处,绝大多数是这种"提到而非执行")。
|
|
185
|
+
* 判定:工具名像 shell,或参数 JSON 顶层含 command/script/cmd。
|
|
186
|
+
*/
|
|
187
|
+
export function isShellTool(name, argsText) {
|
|
188
|
+
if (typeof name === 'string' && /(^|[^a-z])(bash|sh|shell|zsh|exec|terminal|command|run|script|process)/i.test(name)) return true;
|
|
189
|
+
if (typeof argsText === 'string' && argsText) {
|
|
190
|
+
try {
|
|
191
|
+
const o = JSON.parse(argsText);
|
|
192
|
+
if (o && typeof o === 'object' && !Array.isArray(o)
|
|
193
|
+
&& (typeof o.command === 'string' || typeof o.script === 'string' || typeof o.cmd === 'string')) return true;
|
|
194
|
+
} catch { /* 非 JSON 参数:按非 shell 处理 */ }
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|