@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
|
@@ -2,68 +2,218 @@
|
|
|
2
2
|
* dsh-plugin-reducer 集成
|
|
3
3
|
*
|
|
4
4
|
* 在检测到故障时,提供最小化故障插件集的能力。
|
|
5
|
-
* 如果 dsh-plugin-reducer 已安装(PATH
|
|
5
|
+
* 如果 dsh-plugin-reducer 已安装(PATH 中可执行),自动集成到安全检查流程。
|
|
6
6
|
* 复审修复:不再用 `npx 包名` 探测/执行;执行失败返回 skip 而不是伪装通过。
|
|
7
|
+
*
|
|
8
|
+
* 复审修复(docs/ecosystem-audit-2026-09.md §3(c) EXT-RED-1 / §4.4)——**调用方式与结果路径都不对**:
|
|
9
|
+
*
|
|
10
|
+
* 1. `--profile` 收的是 profile **名**,不是路径(上游 `src/args.js` HELP:`--profile <name> (default: web)`、
|
|
11
|
+
* `--dsh-home <path> Source DSH_HOME`)。旧代码传的是 profile 目录的绝对路径,
|
|
12
|
+
* 工具会把整条路径当成 profile 名 → 归约不了任何东西。现在从 registry 交付的 profile 目录
|
|
13
|
+
* (`<dshHome>/profiles/<name>`)反推名字与 dsh home,两个参数分别传。
|
|
14
|
+
*
|
|
15
|
+
* 2. 结果路径。机器契约是 stdout 上**一个** envelope:
|
|
16
|
+
* `{ schemaVersion, tool: {name, version}, operation, ok, report | candidates | error }`
|
|
17
|
+
* (上游 `src/machine-output.js`),答案在 `report.result.minimalFailingSet`
|
|
18
|
+
* (上游 `src/index.js`:`result: { status: 'minimal-failure-set-found', minimalFailingSet: reduced.minimal, … }`)。
|
|
19
|
+
* 旧代码读 `result.minimalSet` —— 永远 undefined,于是**找到故障集也判为通过**。
|
|
20
|
+
*
|
|
21
|
+
* 3. 便携性。旧代码把 `--report` 硬编码到 `/tmp`(Windows 无此路径)并用 `which` 探测
|
|
22
|
+
* (Windows 无此命令 → 永久「未安装」)。现在 `--report` 落在 `os.tmpdir()` 下的一次性目录并在
|
|
23
|
+
* finally 中清理;探测改用 tool-exec.mjs 的 PATH 扫描。
|
|
24
|
+
*
|
|
25
|
+
* 4. 语义收紧:`ok:true` 只可能出现在「确实归约出了最小故障集」的路径上
|
|
26
|
+
* (上游 index.js 在 FULL_SET_PASSES / BASELINE_FAILS / FINAL_NOT_REPRODUCIBLE 等情况下抛 ReducerError
|
|
27
|
+
* → envelope `ok:false` + `error.code` + exit 1)。因此 `ok:true` 但 `minimalFailingSet` 缺失/为空
|
|
28
|
+
* 属于契约矛盾 → 显式 skip,绝不判 pass。
|
|
7
29
|
*/
|
|
8
30
|
|
|
9
|
-
import {
|
|
31
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { join } from 'node:path';
|
|
10
34
|
import { Severity } from '../protocol/severity.mjs';
|
|
11
35
|
import { CheckPhase } from '../protocol/phase.mjs';
|
|
12
|
-
import { skip } from '../protocol/check.mjs';
|
|
36
|
+
import { skip, fail } from '../protocol/check.mjs';
|
|
37
|
+
import { findExecutable, execCapture, parseJson, describeFailure, typeName } from './tool-exec.mjs';
|
|
38
|
+
|
|
39
|
+
export const REDUCER_ID = 'EXT-RED-1';
|
|
40
|
+
const BIN = 'dsh-plugin-reducer';
|
|
41
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
42
|
+
const PROBE_KINDS = new Set(['config', 'web', 'command']);
|
|
43
|
+
/** 沿用旧实现的默认探针;工具自身的默认是 config(见 src/args.js HELP) */
|
|
44
|
+
const DEFAULT_PROBE = 'web';
|
|
13
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 检测 dsh-plugin-reducer 是否可用(跨平台 PATH 扫描,不启动子进程)
|
|
48
|
+
*/
|
|
14
49
|
export function isAvailable() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
50
|
+
return findExecutable(BIN) !== null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 从 profile 目录反推工具需要的 `{ dshHome, profile }`。
|
|
55
|
+
*
|
|
56
|
+
* registry 交给 EXT-* 的是 profile 目录(如 `~/.dsh/profiles/web`),而 CLI 要的是
|
|
57
|
+
* `--dsh-home <path> --profile <name>`。规则:
|
|
58
|
+
* `<home>/profiles/<name>` → { dshHome: '<home>', profile: '<name>' }
|
|
59
|
+
* 纯名字(不含路径分隔符,如 `web`)→ { dshHome: null, profile: 'web' }(由工具走 DSH_HOME/~/.dsh)
|
|
60
|
+
* 其它任意目录 → null(无法安全推断,调用方应 skip)
|
|
61
|
+
*
|
|
62
|
+
* @param {string} profileDir
|
|
63
|
+
* @returns {{dshHome: string|null, profile: string}|null}
|
|
64
|
+
*/
|
|
65
|
+
export function deriveProfileArgs(profileDir) {
|
|
66
|
+
if (!profileDir || typeof profileDir !== 'string') return null;
|
|
67
|
+
const normalized = profileDir.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
68
|
+
if (normalized === '') return null;
|
|
69
|
+
|
|
70
|
+
const matched = normalized.match(/^(.*)\/profiles\/([^/]+)$/);
|
|
71
|
+
if (matched) {
|
|
72
|
+
const [, home, name] = matched;
|
|
73
|
+
return { dshHome: home || null, profile: name };
|
|
20
74
|
}
|
|
75
|
+
// 不含路径分隔符 → 视为 profile 名本身
|
|
76
|
+
if (!normalized.includes('/')) return { dshHome: null, profile: normalized };
|
|
77
|
+
return null;
|
|
21
78
|
}
|
|
22
79
|
|
|
23
|
-
|
|
24
|
-
|
|
80
|
+
/**
|
|
81
|
+
* 解释 dsh-plugin-reducer 的 stdout envelope(纯函数,便于单测)。
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} raw
|
|
84
|
+
* @returns {import('../protocol/check.mjs').SecurityCheckResult}
|
|
85
|
+
*/
|
|
86
|
+
export function interpretEnvelope(raw) {
|
|
87
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
88
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
89
|
+
`dsh-plugin-reducer 输出的 JSON 不是 envelope 对象(实际:${typeName(raw)}),跳过故障最小化`);
|
|
90
|
+
}
|
|
25
91
|
|
|
26
|
-
if (
|
|
27
|
-
return skip(
|
|
92
|
+
if (raw.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
|
|
93
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
94
|
+
`dsh-plugin-reducer envelope 的 schemaVersion=${JSON.stringify(raw.schemaVersion ?? null)} 不是受支持版本 ${SUPPORTED_SCHEMA_VERSION},契约已变化,跳过故障最小化`);
|
|
28
95
|
}
|
|
29
96
|
|
|
97
|
+
const operation = typeof raw.operation === 'string' ? raw.operation : 'unknown';
|
|
98
|
+
|
|
99
|
+
if (raw.ok === false) {
|
|
100
|
+
const code = typeof raw.error?.code === 'string' ? raw.error.code : 'UNKNOWN';
|
|
101
|
+
const message = typeof raw.error?.message === 'string' ? raw.error.message.slice(0, 120) : '';
|
|
102
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
103
|
+
`dsh-plugin-reducer 未能完成归约(operation=${operation}, ok=false, code=${code}${message ? `:${message}` : ''}),跳过故障最小化`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (raw.ok !== true) {
|
|
107
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
108
|
+
`dsh-plugin-reducer envelope 缺少布尔 ok(operation=${operation}),契约已变化,跳过故障最小化`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const set = raw.report?.result?.minimalFailingSet;
|
|
112
|
+
if (!Array.isArray(set)) {
|
|
113
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
114
|
+
`dsh-plugin-reducer envelope ok=true 但 report.result.minimalFailingSet 缺失或不是数组(实际:${typeName(set)}),契约已变化,跳过故障最小化`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (set.length === 0) {
|
|
118
|
+
// ok:true 在本工具语义下意味着「确实找到了最小故障集」(见文件头第 4 点),空集与契约矛盾
|
|
119
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
120
|
+
'dsh-plugin-reducer envelope ok=true 但最小故障集为空——与「ok=true 即已找到最小故障集」的契约矛盾,跳过故障最小化(不外推为通过)');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const plugins = set.map(entry => (typeof entry === 'string' ? entry : JSON.stringify(entry)));
|
|
124
|
+
const result = fail(
|
|
125
|
+
REDUCER_ID,
|
|
126
|
+
Severity.MEDIUM,
|
|
127
|
+
`dsh-plugin-reducer 找到最小故障插件集(${plugins.length} 个):${plugins.join(', ')}`,
|
|
128
|
+
'移除或禁用故障插件集中的插件;该集合是 1-minimal 的,逐个排查会掩盖交互故障(A+B 组合失败)',
|
|
129
|
+
);
|
|
130
|
+
result.evidence = {
|
|
131
|
+
schemaVersion: raw.schemaVersion,
|
|
132
|
+
tool: raw.tool,
|
|
133
|
+
operation,
|
|
134
|
+
minimalFailingSet: plugins,
|
|
135
|
+
};
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 运行 dsh-plugin-reducer
|
|
141
|
+
*
|
|
142
|
+
* @param {string} profileDir - profile 目录(`<dshHome>/profiles/<name>`)或 profile 名
|
|
143
|
+
* @param {'config'|'web'|'command'} [probeType] - 探针类型
|
|
144
|
+
* @param {{isAvailable?: () => boolean, exec?: typeof execCapture, tmpRoot?: string}} [deps] - 测试注入点
|
|
145
|
+
* @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
|
|
146
|
+
*/
|
|
147
|
+
export async function runReducer(profileDir, probeType = DEFAULT_PROBE, deps = {}) {
|
|
148
|
+
const available = deps.isAvailable ? deps.isAvailable() : isAvailable();
|
|
149
|
+
if (!available) {
|
|
150
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
151
|
+
`dsh-plugin-reducer 未安装(PATH 中找不到 ${BIN}),跳过故障最小化;安装后可在启动故障时定位到最小插件集`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const profile = deriveProfileArgs(profileDir);
|
|
155
|
+
if (!profile) {
|
|
156
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
157
|
+
`无法从 "${String(profileDir)}" 推断 profile 名(期望 <dshHome>/profiles/<name>)——dsh-plugin-reducer 的 --profile 收名字而非路径,跳过故障最小化`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!PROBE_KINDS.has(probeType)) {
|
|
161
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
162
|
+
`未知的探针类型 "${String(probeType)}"(支持:${[...PROBE_KINDS].join('/')}),跳过故障最小化`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --report 落在 os.tmpdir() 下的一次性目录(不再硬编码 /tmp),执行后清理
|
|
166
|
+
let reportDir = null;
|
|
167
|
+
let reportPath = null;
|
|
30
168
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
timeout: 120000,
|
|
37
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
-
}
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
const result = JSON.parse(output);
|
|
42
|
-
|
|
43
|
-
if (result.minimalSet && result.minimalSet.length > 0) {
|
|
44
|
-
return {
|
|
45
|
-
id,
|
|
46
|
-
ok: false,
|
|
47
|
-
severity: Severity.MEDIUM,
|
|
48
|
-
detail: `dsh-plugin-reducer 找到最小故障插件集:${result.minimalSet.join(', ')}`,
|
|
49
|
-
fix: '移除或禁用故障插件集中的插件',
|
|
50
|
-
evidence: result,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return { id, ok: true, severity: Severity.LOW, detail: 'dsh-plugin-reducer 未发现故障插件集' };
|
|
55
|
-
} catch (e) {
|
|
56
|
-
return skip(id, Severity.LOW, `dsh-plugin-reducer 执行失败,跳过:${e.message.slice(0, 80)}`);
|
|
169
|
+
reportDir = mkdtempSync(join(deps.tmpRoot ?? tmpdir(), 'dsh-security-reducer-'));
|
|
170
|
+
reportPath = join(reportDir, 'reducer-report.json');
|
|
171
|
+
} catch {
|
|
172
|
+
reportDir = null; // 建不了临时目录就不传 --report:stdout 的 envelope 才是机器契约
|
|
173
|
+
reportPath = null;
|
|
57
174
|
}
|
|
175
|
+
|
|
176
|
+
const args = [];
|
|
177
|
+
if (profile.dshHome) args.push('--dsh-home', profile.dshHome);
|
|
178
|
+
args.push('--profile', profile.profile, '--probe', probeType, '--json');
|
|
179
|
+
if (reportPath) args.push('--report', reportPath, '--force');
|
|
180
|
+
|
|
181
|
+
let res;
|
|
182
|
+
try {
|
|
183
|
+
const exec = deps.exec ?? execCapture;
|
|
184
|
+
res = exec(BIN, args, { timeoutMs: 120000 });
|
|
185
|
+
} finally {
|
|
186
|
+
if (reportDir) rmSync(reportDir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 上游退出码契约:0 = 归约完成;1 = 执行/归约失败;2 = 参数非法。
|
|
190
|
+
// 后两者**仍然输出可解析的 envelope**(ok:false + error.code),所以先按契约判定退出码,
|
|
191
|
+
// 再解析 stdout —— 只要 envelope 能解析出来,就走 interpretEnvelope 给出带原因的判断。
|
|
192
|
+
const expectedExit = res.status === 0 || res.status === 1 || res.status === 2;
|
|
193
|
+
const parsed = parseJson(res.stdout);
|
|
194
|
+
|
|
195
|
+
if (!parsed.ok) {
|
|
196
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
197
|
+
`dsh-plugin-reducer 的 stdout 无法解析为 JSON envelope(${describeFailure(res)}),跳过故障最小化`);
|
|
198
|
+
}
|
|
199
|
+
if (!expectedExit) {
|
|
200
|
+
return skip(REDUCER_ID, Severity.MEDIUM,
|
|
201
|
+
`dsh-plugin-reducer 以非预期退出码结束(${describeFailure(res)}),跳过故障最小化`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return interpretEnvelope(parsed.value);
|
|
58
205
|
}
|
|
59
206
|
|
|
207
|
+
/**
|
|
208
|
+
* dsh-plugin-reducer 集成检查对象
|
|
209
|
+
*/
|
|
60
210
|
export const pluginReducerCheck = {
|
|
61
|
-
id:
|
|
211
|
+
id: REDUCER_ID,
|
|
62
212
|
name: 'plugin-reducer',
|
|
63
213
|
severity: Severity.MEDIUM,
|
|
64
214
|
phase: CheckPhase.LIFECYCLE,
|
|
65
215
|
description: 'dsh-plugin-reducer 故障最小化',
|
|
66
216
|
src: 'external',
|
|
67
|
-
source:
|
|
217
|
+
source: BIN,
|
|
68
218
|
runner: (profileDir) => runReducer(profileDir),
|
|
69
219
|
};
|
|
@@ -3,75 +3,198 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 如果 dsh-poison-guard 已安装(PATH 中可执行),自动集成到安全检查流程。
|
|
5
5
|
* 复审修复:不再用 `npx 包名` 探测/执行;执行失败返回 skip 而不是伪装通过。
|
|
6
|
+
*
|
|
7
|
+
* 复审修复(docs/ecosystem-audit-2026-09.md §3(c) EXT-PG-1 / §4.4)——**真实发现曾被判为通过**:
|
|
8
|
+
*
|
|
9
|
+
* 1. 字段读错。工具的真实报告是
|
|
10
|
+
* `{ verdict: 'MALICIOUS'|'SUSPICIOUS'|'CLEAN', findings[{rule,severity,file,line,hint}], summary, stats }`
|
|
11
|
+
* (已核对上游 zoahdev/dsh-poison-guard@master `lib/index.js`:
|
|
12
|
+
* `const verdict = high.length > 0 ? 'MALICIOUS' : medium.length > 0 ? 'SUSPICIOUS' : 'CLEAN'`),
|
|
13
|
+
* 旧代码读的是 `result.clean || result.vulnerabilities?.length === 0` —— 这两个字段工具都不产出,
|
|
14
|
+
* 所以 `findings[]` 从未被使用。两种实际后果(均不会把发现报成 fail):
|
|
15
|
+
* · 有发现 → 工具 exit 1 → execFileSync 抛异常 → catch 变成一句无信息的 skip
|
|
16
|
+
* (我们的协议里 skip 带 ok:true,只计入「跳过」,所以真发现在报告里彻底消失);
|
|
17
|
+
* · 若同一份报告带 exit 0 返回 → 落到 `vulns = result.vulnerabilities || []` 分支,
|
|
18
|
+
* 输出「检测到 0 个投毒模式:」——一个计数为空、毫无线索的 fail。
|
|
19
|
+
* 现在一律以 `findings[]` 为准,`verdict` 只做交叉校验。
|
|
20
|
+
*
|
|
21
|
+
* 2. 退出码即契约。上游 `bin/poison-guard.mjs` 末行是
|
|
22
|
+
* `process.exit(report.verdict === 'CLEAN' ? 0 : 1)`
|
|
23
|
+
* 也就是说**有发现时退出码是 1**。旧代码用 execFileSync,exit 1 会抛出异常,
|
|
24
|
+
* 结构化 stdout 一起丢掉 → 真发现又退化成一句「执行失败,跳过」。
|
|
25
|
+
* 现在 exit 0/1 都解析 stdout;其余退出码视为未知 → 显式 skip。
|
|
26
|
+
*
|
|
27
|
+
* 3. 「形状变了」必须 skip。verdict 缺失/未知、findings 存在但不是数组 —— 都是契约漂移,
|
|
28
|
+
* 一律 skip 并带上原因,绝不外推成 pass。
|
|
6
29
|
*/
|
|
7
30
|
|
|
8
|
-
import { execFileSync } from 'node:child_process';
|
|
9
31
|
import { Severity } from '../protocol/severity.mjs';
|
|
10
32
|
import { CheckPhase } from '../protocol/phase.mjs';
|
|
11
|
-
import { skip } from '../protocol/check.mjs';
|
|
33
|
+
import { skip, fail, pass } from '../protocol/check.mjs';
|
|
34
|
+
import { findExecutable, execCapture, parseJson, describeFailure, typeName } from './tool-exec.mjs';
|
|
35
|
+
|
|
36
|
+
export const POISON_GUARD_ID = 'EXT-PG-1';
|
|
37
|
+
const BIN = 'dsh-poison-guard';
|
|
38
|
+
|
|
39
|
+
/** 工具自报「干净」的 verdict(上游仅产出 'CLEAN';大小写不敏感以容忍 shape 微调) */
|
|
40
|
+
const CLEAN_VERDICTS = new Set(['clean']);
|
|
41
|
+
/** 工具自报「有问题」的 verdict(上游:HIGH→MALICIOUS,MEDIUM→SUSPICIOUS) */
|
|
42
|
+
const BAD_VERDICTS = new Set(['malicious', 'suspicious']);
|
|
12
43
|
|
|
13
44
|
/**
|
|
14
|
-
* 检测 dsh-poison-guard
|
|
45
|
+
* 检测 dsh-poison-guard 是否可用(跨平台 PATH 扫描,不启动子进程)
|
|
15
46
|
*/
|
|
16
47
|
export function isAvailable() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
48
|
+
return findExecutable(BIN) !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 把工具 severity(HIGH/MEDIUM/LOW)映射到我们的词汇表 */
|
|
52
|
+
function normalizeSeverity(value) {
|
|
53
|
+
const v = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
54
|
+
return v === 'high' || v === 'medium' || v === 'low' ? v : 'unknown';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 把一条工具 finding 映射到我们的结果形状。
|
|
59
|
+
* 工具 finding:`{ rule, severity, file, line, hint }`
|
|
60
|
+
*/
|
|
61
|
+
export function mapFinding(finding, index = 0) {
|
|
62
|
+
const f = finding && typeof finding === 'object' && !Array.isArray(finding) ? finding : {};
|
|
63
|
+
const line = Number.isFinite(f.line)
|
|
64
|
+
? f.line
|
|
65
|
+
: (typeof f.line === 'string' && /^\d+$/.test(f.line) ? Number(f.line) : null);
|
|
66
|
+
return {
|
|
67
|
+
rule: typeof f.rule === 'string' && f.rule ? f.rule : `unknown-rule-${index + 1}`,
|
|
68
|
+
severity: normalizeSeverity(f.severity),
|
|
69
|
+
file: typeof f.file === 'string' && f.file ? f.file : null,
|
|
70
|
+
line,
|
|
71
|
+
hint: typeof f.hint === 'string' ? f.hint : '',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 渲染一条已映射的 finding */
|
|
76
|
+
function renderFinding(f) {
|
|
77
|
+
const where = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ''})` : '';
|
|
78
|
+
return `[${f.severity}] ${f.rule}: ${f.hint || '(无 hint)'}${where}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 解释 dsh-poison-guard 的 JSON 报告(纯函数,便于单测)。
|
|
83
|
+
*
|
|
84
|
+
* 判定优先级:
|
|
85
|
+
* findings 非空 → FAIL(不管 verdict 说什么)
|
|
86
|
+
* findings 为空/缺失 + verdict=CLEAN → PASS
|
|
87
|
+
* findings 为空 + verdict=MALICIOUS/SUSPICIOUS → FAIL(非 CLEAN 的 verdict 永不判 pass)
|
|
88
|
+
* 其余(verdict 缺失/未知、findings 类型异常、不是对象)→ skip + 原因
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} raw
|
|
91
|
+
* @returns {import('../protocol/check.mjs').SecurityCheckResult}
|
|
92
|
+
*/
|
|
93
|
+
export function interpretReport(raw) {
|
|
94
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
95
|
+
return skip(POISON_GUARD_ID, Severity.HIGH,
|
|
96
|
+
`dsh-poison-guard 输出的 JSON 不是对象(实际:${typeName(raw)}),无法解释报告,跳过投毒扫描`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const verdict = typeof raw.verdict === 'string' ? raw.verdict.trim() : null;
|
|
100
|
+
const hasFindingsKey = raw.findings !== undefined;
|
|
101
|
+
|
|
102
|
+
if (hasFindingsKey && !Array.isArray(raw.findings)) {
|
|
103
|
+
return skip(POISON_GUARD_ID, Severity.HIGH,
|
|
104
|
+
`dsh-poison-guard 输出的 findings 不是数组(实际:${typeName(raw.findings)}),契约已变化,跳过投毒扫描`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const findings = Array.isArray(raw.findings) ? raw.findings : [];
|
|
108
|
+
|
|
109
|
+
if (findings.length > 0) {
|
|
110
|
+
const mapped = findings.map((f, i) => mapFinding(f, i));
|
|
111
|
+
const shown = mapped.slice(0, 10).map(renderFinding).join('\n');
|
|
112
|
+
const more = mapped.length > 10 ? `\n…另有 ${mapped.length - 10} 条未展开` : '';
|
|
113
|
+
const verdictNote = verdict ? `,verdict=${verdict}` : '';
|
|
114
|
+
const result = fail(
|
|
115
|
+
POISON_GUARD_ID,
|
|
116
|
+
Severity.HIGH,
|
|
117
|
+
`dsh-poison-guard 检测到 ${mapped.length} 个投毒发现${verdictNote}:\n${shown}${more}`,
|
|
118
|
+
'检查相关插件源码,移除恶意/混淆代码;保留安装脚本审计结果作为 pre-install 阻断依据',
|
|
119
|
+
['#2961', '#5498'],
|
|
120
|
+
);
|
|
121
|
+
result.evidence = { verdict, findings: mapped };
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// findings 为空 → 只能靠 verdict 判定
|
|
126
|
+
if (verdict === null) {
|
|
127
|
+
return skip(POISON_GUARD_ID, Severity.HIGH,
|
|
128
|
+
'dsh-poison-guard 输出既无 findings 也无 verdict(契约已变化),跳过投毒扫描(不外推为通过)');
|
|
22
129
|
}
|
|
130
|
+
|
|
131
|
+
const normalized = verdict.toLowerCase();
|
|
132
|
+
if (CLEAN_VERDICTS.has(normalized)) {
|
|
133
|
+
const result = pass(POISON_GUARD_ID, Severity.HIGH,
|
|
134
|
+
`dsh-poison-guard 扫描通过:verdict=${verdict},无 findings`);
|
|
135
|
+
result.evidence = { verdict, findings: [] };
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
if (BAD_VERDICTS.has(normalized)) {
|
|
139
|
+
const result = fail(
|
|
140
|
+
POISON_GUARD_ID,
|
|
141
|
+
Severity.HIGH,
|
|
142
|
+
`dsh-poison-guard 报告 verdict=${verdict} 但未给出 findings,按非 CLEAN 判定为发现`,
|
|
143
|
+
'重新运行 dsh-poison-guard 获取完整 findings,或人工审查该插件源码',
|
|
144
|
+
);
|
|
145
|
+
result.evidence = { verdict, findings: [] };
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return skip(POISON_GUARD_ID, Severity.HIGH,
|
|
150
|
+
`dsh-poison-guard 输出了无法识别的 verdict=${verdict}(已知:CLEAN/MALICIOUS/SUSPICIOUS),跳过投毒扫描`);
|
|
23
151
|
}
|
|
24
152
|
|
|
25
153
|
/**
|
|
26
154
|
* 运行 dsh-poison-guard 扫描
|
|
27
155
|
* @param {string} targetPath - 要扫描的目录或包路径
|
|
156
|
+
* @param {{isAvailable?: () => boolean, exec?: typeof execCapture}} [deps] - 测试注入点
|
|
28
157
|
* @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
|
|
29
158
|
*/
|
|
30
|
-
export async function runScan(targetPath) {
|
|
31
|
-
const
|
|
159
|
+
export async function runScan(targetPath, deps = {}) {
|
|
160
|
+
const available = deps.isAvailable ? deps.isAvailable() : isAvailable();
|
|
161
|
+
if (!available) {
|
|
162
|
+
return skip(POISON_GUARD_ID, Severity.HIGH,
|
|
163
|
+
`dsh-poison-guard 未安装(PATH 中找不到 ${BIN}),跳过投毒扫描;安装后可覆盖 pre-install 投毒检测`);
|
|
164
|
+
}
|
|
32
165
|
|
|
33
|
-
if (!
|
|
34
|
-
return skip(
|
|
166
|
+
if (!targetPath) {
|
|
167
|
+
return skip(POISON_GUARD_ID, Severity.HIGH, '未提供待扫描的插件目录,跳过投毒扫描');
|
|
35
168
|
}
|
|
36
169
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const vulns = result.vulnerabilities || [];
|
|
51
|
-
const details = vulns.slice(0, 10).map(v => `[${v.severity}] ${v.type}: ${v.message}`).join('\n');
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
id,
|
|
55
|
-
ok: false,
|
|
56
|
-
severity: Severity.HIGH,
|
|
57
|
-
detail: `dsh-poison-guard 检测到 ${vulns.length} 个投毒模式:\n${details}`,
|
|
58
|
-
fix: '检查相关插件的源码,移除恶意代码',
|
|
59
|
-
};
|
|
60
|
-
} catch (e) {
|
|
61
|
-
return skip(id, Severity.HIGH, `dsh-poison-guard 执行失败,跳过:${e.message.slice(0, 80)}`);
|
|
170
|
+
const exec = deps.exec ?? execCapture;
|
|
171
|
+
const res = exec(BIN, ['scan', String(targetPath), '--json'], { timeoutMs: 60000 });
|
|
172
|
+
|
|
173
|
+
// 上游退出码契约:0 = CLEAN,1 = 至少一个发现;其余(2 = 用法错误)为未知
|
|
174
|
+
if (res.status !== 0 && res.status !== 1) {
|
|
175
|
+
return skip(POISON_GUARD_ID, Severity.HIGH,
|
|
176
|
+
`dsh-poison-guard 以非预期退出码结束(${describeFailure(res)}),跳过投毒扫描`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const parsed = parseJson(res.stdout);
|
|
180
|
+
if (!parsed.ok) {
|
|
181
|
+
return skip(POISON_GUARD_ID, Severity.HIGH,
|
|
182
|
+
`dsh-poison-guard 的 stdout 无法解析为 JSON(${describeFailure(res)}),跳过投毒扫描`);
|
|
62
183
|
}
|
|
184
|
+
|
|
185
|
+
return interpretReport(parsed.value);
|
|
63
186
|
}
|
|
64
187
|
|
|
65
188
|
/**
|
|
66
189
|
* dsh-poison-guard 集成检查对象
|
|
67
190
|
*/
|
|
68
191
|
export const poisonGuardCheck = {
|
|
69
|
-
id:
|
|
192
|
+
id: POISON_GUARD_ID,
|
|
70
193
|
name: 'poison-scan',
|
|
71
194
|
severity: Severity.HIGH,
|
|
72
195
|
phase: CheckPhase.POST_INSTALL,
|
|
73
196
|
description: 'dsh-poison-guard 投毒扫描(AST + 反混淆)',
|
|
74
197
|
src: 'external',
|
|
75
|
-
source:
|
|
198
|
+
source: BIN,
|
|
76
199
|
runner: (targetPath) => runScan(targetPath),
|
|
77
200
|
};
|
|
@@ -1,67 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-sandbox-audit 集成
|
|
2
|
+
* dsh-sandbox-audit 集成 —— **已退役(RETIRED)**
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 决策(docs/ecosystem-audit-2026-09.md §3(c) 首行 / §4.4):审计给了两个选项——
|
|
5
|
+
* (a) 按真实 CLI 契约重写为「逐个 YAML 文件调用」,或 (b) 退役该集成并记录原因。
|
|
6
|
+
* 本项目选择 **(b) 退役**,理由:
|
|
7
|
+
*
|
|
8
|
+
* 1. 这个二进制从未发布到 npm:`registry.npmjs.org/dsh-sandbox-audit` → HTTP 404,
|
|
9
|
+
* 仓库(zoahdev/dsh-sandbox-audit)无 tag、无 release,未声明 license,最后推送 2026-08-16。
|
|
10
|
+
* `isAvailable()` 只有在用户手工 clone + link 之后才可能为 true —— 对任何正常安装的用户,
|
|
11
|
+
* 这个检查都是死代码,而它同时把「工具不在」伪装成「无需检查」。
|
|
12
|
+
* 2. 契约本身也是错的:真实 CLI 收的是 YAML **文件路径**(cordis.patch.yml / agent.cordis.yml),
|
|
13
|
+
* 不是 profile 目录;报告形状是 `{source, defaultMode, tools[], findings[{severity,title}]}`,
|
|
14
|
+
* 旧代码读的 `findings[].tool` / `findings[].finding` 根本不存在 → 即便手工装上了也只会输出空列表,
|
|
15
|
+
* 即「装上了也读不出东西」。
|
|
16
|
+
* 3. 覆盖面无损失:sp3(`src/checks/sp3-sandbox-consistency.mjs`)已经离线、静态地覆盖了同一片
|
|
17
|
+
* 「沙箱接线与策略声明不一致」的检查域(其文件头即写明「轻量版 dsh-sandbox-audit」,含
|
|
18
|
+
* HIGH: 变文件系统工具共享 bare fs-local backend / MEDIUM: 搜索工具越权读取 / LOW: 多余权限),
|
|
19
|
+
* 且不依赖任何第三方二进制或网络。与其维护一个永远跑不起来的集成,不如让 sp3 作为唯一 owner。
|
|
20
|
+
*
|
|
21
|
+
* 保留导出只是为了不破坏公开 API:`src/index.mjs` 仍然 `export { sandboxAuditCheck }`,
|
|
22
|
+
* 而 `sandboxAuditCheck` 是 src/** 之外的消费者可能 import 的名字。所以这里不删文件,
|
|
23
|
+
* 而是把它变成「永不判 pass、也永不判 fail」的退役桩:
|
|
24
|
+
* · `isAvailable()` 恒为 false —— 它不会再被注册,也就不会再出现在任何报告里;
|
|
25
|
+
* · 直接调用 runner 时返回带原因的 skip,而不是 throw 或静默通过。
|
|
26
|
+
* `getAvailableIntegrations()` 已不再注册本检查(EXT-SA-1 从 dsh-doctor 输出中消失)。
|
|
7
27
|
*/
|
|
8
28
|
|
|
9
|
-
import { execFileSync } from 'node:child_process';
|
|
10
29
|
import { Severity } from '../protocol/severity.mjs';
|
|
11
30
|
import { CheckPhase } from '../protocol/phase.mjs';
|
|
12
31
|
import { skip } from '../protocol/check.mjs';
|
|
13
32
|
|
|
14
|
-
export
|
|
15
|
-
try {
|
|
16
|
-
execFileSync('which', ['dsh-sandbox-audit'], { encoding: 'utf8', stdio: 'pipe' });
|
|
17
|
-
return true;
|
|
18
|
-
} catch {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function runAudit(profileDir) {
|
|
24
|
-
const id = 'EXT-SA-1';
|
|
25
|
-
|
|
26
|
-
if (!isAvailable()) {
|
|
27
|
-
return skip(id, Severity.MEDIUM, 'dsh-sandbox-audit 未安装,跳过沙箱策略审计');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
const output = execFileSync('dsh-sandbox-audit', [String(profileDir), '--json'], {
|
|
32
|
-
encoding: 'utf8',
|
|
33
|
-
timeout: 60000,
|
|
34
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const result = JSON.parse(output);
|
|
33
|
+
export const SANDBOX_AUDIT_ID = 'EXT-SA-1';
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
/** 退役原因(同时用于 skip 的 reason,便于报告里说明「为什么这行不见了」) */
|
|
36
|
+
export const RETIRED_REASON =
|
|
37
|
+
'dsh-sandbox-audit 集成已退役:该工具从未发布到 npm(registry 404)、仓库无 tag/release、' +
|
|
38
|
+
'自 2026-08-16 停更且未声明 license,契约(YAML 文件入参 + findings[].title)也与旧实现不符;' +
|
|
39
|
+
'沙箱策略一致性现由离线检查 SP3 覆盖,不再运行 EXT-SA-1';
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
/**
|
|
42
|
+
* 恒为 false:退役后不再探测、不再注册。
|
|
43
|
+
* (保留函数签名是为了兼容既有调用方,避免它们从「检查被跳过」变成 TypeError。)
|
|
44
|
+
*/
|
|
45
|
+
export function isAvailable() {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
53
|
-
} catch (e) {
|
|
54
|
-
return skip(id, Severity.MEDIUM, `dsh-sandbox-audit 执行失败,跳过:${e.message.slice(0, 80)}`);
|
|
55
|
-
}
|
|
49
|
+
/**
|
|
50
|
+
* 退役桩:任何调用都返回带原因的 skip,绝不 pass / fail / throw。
|
|
51
|
+
* @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
|
|
52
|
+
*/
|
|
53
|
+
export async function runAudit() {
|
|
54
|
+
return skip(SANDBOX_AUDIT_ID, Severity.MEDIUM, RETIRED_REASON);
|
|
56
55
|
}
|
|
57
56
|
|
|
57
|
+
/**
|
|
58
|
+
* @deprecated 已退役,仅保留以兼容 `src/index.mjs` 的 re-export。
|
|
59
|
+
* 该对象不会被 getAvailableIntegrations() 注册。
|
|
60
|
+
*/
|
|
58
61
|
export const sandboxAuditCheck = {
|
|
59
|
-
id:
|
|
62
|
+
id: SANDBOX_AUDIT_ID,
|
|
60
63
|
name: 'sandbox-audit',
|
|
61
64
|
severity: Severity.MEDIUM,
|
|
62
65
|
phase: CheckPhase.POST_INSTALL,
|
|
63
|
-
description: 'dsh-sandbox-audit 沙箱策略审计',
|
|
66
|
+
description: '(已退役)dsh-sandbox-audit 沙箱策略审计 —— 由离线检查 SP3 覆盖',
|
|
64
67
|
src: 'external',
|
|
65
68
|
source: 'dsh-sandbox-audit',
|
|
66
|
-
|
|
69
|
+
retired: true,
|
|
70
|
+
runner: () => runAudit(),
|
|
67
71
|
};
|