@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-security",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Unified security check framework for DeepSeek Harness ecosystem — covers the full plugin lifecycle (discovery → install → runtime → update → retirement)",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -9,6 +9,9 @@ export { sp7Check } from './sp7-client-syntax.mjs';
9
9
  export { sp8Check } from './sp8-dist-tag-health.mjs';
10
10
  export { sp9Check } from './sp9-dual-instance-guard.mjs';
11
11
  export { sp10Check } from './sp10-poison-pattern.mjs';
12
+ export { sp11Check } from './sp11-patch-security-override.mjs';
13
+ export { sp12Check } from './sp12-config-as-code-tag.mjs';
14
+ export { sp13Check } from './sp13-tools-mode-sandbox.mjs';
12
15
 
13
16
  // Layer 2: Runtime Checks
14
17
  export { sr1Check } from './sr1-sandbox-violation.mjs';
@@ -62,6 +62,7 @@ export function compareVersions(localPkg, registryInfo, localLockIntegrity = nul
62
62
  issues.push({
63
63
  type: 'version-mismatch',
64
64
  severity: 'medium',
65
+ informational: true, // 只是"不新鲜",不是完整性/供应链风险 —— 不参与判定(2026-09 审计结论)
65
66
  detail: `本地版本 ${localVersion} ≠ registry latest ${latestVersion}`,
66
67
  });
67
68
  }
@@ -152,25 +153,29 @@ export async function run(profileDir) {
152
153
  return skip(id, Severity.HIGH, `${errors} 个包 registry 查询失败(离线或网络受限),跳过供应链验证`);
153
154
  }
154
155
 
155
- if (allIssues.length === 0) {
156
+ const material = allIssues.filter(i => !i.informational); // 完整性类才是"问题"
157
+ const outdated = allIssues.filter(i => i.informational);
158
+
159
+ if (material.length === 0) {
156
160
  return pass(id, Severity.HIGH,
157
161
  `验证 ${checked} 个包的供应链完整性,未发现异常${errors > 0 ? `(${errors} 个包查询失败)` : ''}`
162
+ + (outdated.length ? `;另有 ${outdated.length} 个包版本落后于 registry latest(不新鲜,非完整性问题)` : '')
158
163
  );
159
164
  }
160
165
 
161
- const criticalIssues = allIssues.filter(i => i.severity === 'critical');
162
- const overallSeverity = criticalIssues.length > 0 ? Severity.CRITICAL : maxSeverity(allIssues.map(i => i.severity === 'critical' ? Severity.CRITICAL : i.severity === 'medium' ? Severity.MEDIUM : Severity.LOW));
166
+ const criticalIssues = material.filter(i => i.severity === 'critical');
167
+ const overallSeverity = criticalIssues.length > 0 ? Severity.CRITICAL : maxSeverity(material.map(i => i.severity === 'critical' ? Severity.CRITICAL : i.severity === 'medium' ? Severity.MEDIUM : Severity.LOW));
163
168
 
164
- const details = allIssues
169
+ const details = material
165
170
  .map(i => `[${i.severity}] ${i.package} — ${i.type}: ${i.detail}`)
166
- .join('\n');
171
+ .join('\n') + (outdated.length ? `\n(另有 ${outdated.length} 个包版本落后,未计入问题)` : '');
167
172
 
168
173
  const fix = criticalIssues.length > 0
169
174
  ? '检测到 integrity hash 不匹配,可能是包被篡改。立即重新安装受影响的包'
170
175
  : '部分包版本落后于 registry latest,建议更新';
171
176
 
172
177
  return fail(id, overallSeverity,
173
- `检测到 ${allIssues.length} 个供应链问题(${checked} 个包已验证${errors > 0 ? `,${errors} 个查询失败` : ''}):\n${details}`,
178
+ `检测到 ${material.length} 个供应链问题(${checked} 个包已验证${errors > 0 ? `,${errors} 个查询失败` : ''}):\n${details}`,
174
179
  fix
175
180
  );
176
181
  }
@@ -57,6 +57,7 @@ function checkCompatibility(localVersion, distTags) {
57
57
  issues.push({
58
58
  type: 'latest-is-prerelease',
59
59
  severity: 'medium',
60
+ informational: true, // 全生态都在 rc 线上,这属常态描述而非兼容性问题(2026-09 审计结论)
60
61
  detail: `latest 标签指向预发布版本 ${distTags.latest},可能不稳定`,
61
62
  });
62
63
  }
@@ -67,6 +68,7 @@ function checkCompatibility(localVersion, distTags) {
67
68
  issues.push({
68
69
  type: 'next-available',
69
70
  severity: 'low',
71
+ informational: true,
70
72
  detail: `有 next 标签 ${distTags.next} 可用(当前 latest: ${distTags.latest})`,
71
73
  });
72
74
  }
@@ -140,17 +142,23 @@ export async function run(profileDir) {
140
142
  );
141
143
  }
142
144
 
143
- const mediumIssues = allIssues.filter(i => i.severity === 'medium');
145
+ const material = allIssues.filter(i => !i.informational);
146
+ if (material.length === 0) {
147
+ return pass(id, Severity.LOW,
148
+ `验证 ${checked} 个包的发布兼容性,未发现兼容性问题`
149
+ + (allIssues.length ? `;另有 ${allIssues.length} 条常态提示(预发布 latest / next 可用)未计入` : ''));
150
+ }
151
+ const mediumIssues = material.filter(i => i.severity === 'medium');
144
152
  const overallSeverity = mediumIssues.length > 0 ? Severity.MEDIUM : Severity.LOW;
145
153
 
146
- const details = allIssues
154
+ const details = material
147
155
  .map(i => `[${i.severity}] ${i.package} — ${i.type}: ${i.detail}`)
148
156
  .join('\n');
149
157
 
150
158
  const fix = '检查是否有重要更新需要应用,或确认当前版本满足需求';
151
159
 
152
160
  return fail(id, overallSeverity,
153
- `检测到 ${allIssues.length} 个发布兼容性问题(${checked} 个包已验证):\n${details}`,
161
+ `检测到 ${material.length} 个发布兼容性问题(${checked} 个包已验证):\n${details}`,
154
162
  fix
155
163
  );
156
164
  }
@@ -1,82 +1,88 @@
1
1
  /**
2
2
  * SP1: Dependency Audit — 依赖链漏洞扫描
3
3
  *
4
- * 集成 npm audit 检查 profile 中已安装插件的已知漏洞。
5
- * 支持 npm audit osv-scanner 两种后端。
4
+ * profile 的依赖锁文件执行真实审计:
5
+ * - pnpm-lock.yaml → `pnpm audit --json --prod`
6
+ * - package-lock.json → `npm audit --json --omit=dev`
7
+ *
8
+ * 契约(2026-09 上游兼容审计 R9 修复):
9
+ * 任何"审计没有真正执行"的情况都必须返回 **skip + 原因**,
10
+ * 绝不允许静默 PASS。旧实现在 pnpm profile(无 package-lock.json)上
11
+ * 把 npm 的 ENOLOCK 错误当成"无漏洞",永久输出
12
+ * `依赖链无已知漏洞(扫描 ? 个依赖)` 的假 PASS。
6
13
  *
7
14
  * Severity: HIGH(有 critical/high 漏洞时)
8
15
  * Phase: POST_INSTALL
9
16
  */
10
17
 
11
- import { execSync } from 'node:child_process';
12
- import { existsSync, readFileSync } from 'node:fs';
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync } from 'node:fs';
13
20
  import { join } from 'node:path';
14
21
  import { Severity, maxSeverity } from '../protocol/severity.mjs';
15
22
  import { CheckPhase } from '../protocol/phase.mjs';
16
- import { pass, fail } from '../protocol/check.mjs';
23
+ import { pass, fail, skip } from '../protocol/check.mjs';
24
+
25
+ const AUDIT_TIMEOUT_MS = 60000;
26
+ const MAX_BUFFER = 32 * 1024 * 1024;
17
27
 
18
28
  /**
19
- * 运行 npm audit 并解析结果
29
+ * 选审计后端:锁文件决定包管理器。没有锁文件就没有可审计的依赖图。
30
+ * @returns {{pm: string, lock: string, args: string[]}|null}
20
31
  */
21
- function runNpmAudit(profileDir) {
22
- const packageJsonPath = join(profileDir, 'package.json');
23
- if (!existsSync(packageJsonPath)) {
24
- return { vulns: [], error: 'package.json 不存在' };
32
+ export function detectBackend(profileDir) {
33
+ if (existsSync(join(profileDir, 'pnpm-lock.yaml'))) {
34
+ return { pm: 'pnpm', lock: 'pnpm-lock.yaml', args: ['audit', '--json', '--prod'] };
25
35
  }
26
-
27
- try {
28
- const output = execSync('npm audit --json --omit=dev', {
29
- cwd: profileDir,
30
- encoding: 'utf8',
31
- timeout: 60000,
32
- stdio: ['pipe', 'pipe', 'pipe'],
33
- });
34
- const audit = JSON.parse(output);
35
- return parseNpmAudit(audit);
36
- } catch (e) {
37
- // npm audit 在有漏洞时 exit code 1,这是正常的
38
- if (e.stdout) {
39
- try {
40
- const audit = JSON.parse(e.stdout);
41
- return parseNpmAudit(audit);
42
- } catch {
43
- return { vulns: [], error: `npm audit 解析失败: ${e.message}` };
44
- }
45
- }
46
- return { vulns: [], error: `npm audit 执行失败: ${e.message}` };
36
+ if (existsSync(join(profileDir, 'package-lock.json'))) {
37
+ return { pm: 'npm', lock: 'package-lock.json', args: ['audit', '--json', '--omit=dev'] };
47
38
  }
39
+ return null;
48
40
  }
49
41
 
50
42
  /**
51
- * 解析 npm audit JSON 输出
43
+ * 解析审计 JSON(同时兼容 npm v6 `advisories` 与 npm v7+/pnpm `vulnerabilities` 两种格式)。
44
+ * 返回 `{ vulns, totalDependencies, totalVulnerabilities }`;
45
+ * 若输出里带 `error`(如 ENOLOCK / ERR_PNPM_AUDIT_NO_LOCKFILE),返回 `{ auditError, auditErrorCode }`。
52
46
  */
53
- function parseNpmAudit(audit) {
47
+ export function parseAuditJson(audit) {
48
+ if (audit && typeof audit === 'object' && audit.error) {
49
+ const err = audit.error;
50
+ const code = err.code || 'AUDIT_ERROR';
51
+ return {
52
+ auditError: `${code}: ${err.summary || err.detail || '审计未执行'}`,
53
+ auditErrorCode: code,
54
+ };
55
+ }
56
+
54
57
  const vulns = [];
55
- const advisory = audit.advisories || {};
56
- const metadata = audit.metadata || {};
58
+ const metadata = (audit && audit.metadata) || {};
57
59
 
58
- // npm v7+ 格式
59
- if (audit.vulnerabilities) {
60
+ // npm v7+ / pnpm v9+:vulnerabilities 按包聚合
61
+ if (audit && audit.vulnerabilities && typeof audit.vulnerabilities === 'object') {
60
62
  for (const [name, vuln] of Object.entries(audit.vulnerabilities)) {
61
- if (vuln.severity === 'info') continue;
63
+ const severity = vuln && vuln.severity;
64
+ if (!severity || severity === 'info') continue;
65
+ const via = Array.isArray(vuln.via) ? vuln.via.filter(v => v && typeof v === 'object') : [];
62
66
  vulns.push({
63
67
  package: name,
64
- severity: vuln.severity,
65
- title: vuln.via?.[0]?.title || 'Unknown vulnerability',
68
+ severity,
69
+ title: via[0]?.title || (typeof vuln.via?.[0] === 'string' ? vuln.via[0] : 'Unknown vulnerability'),
66
70
  range: vuln.range || 'Unknown',
67
71
  fixAvailable: !!vuln.fixAvailable,
68
- url: vuln.via?.[0]?.url || '',
72
+ url: via[0]?.url || '',
69
73
  });
70
74
  }
71
75
  }
72
76
 
73
- // npm v6 格式(advisories)
74
- for (const [id, adv] of Object.entries(advisory)) {
77
+ // npm v6 / pnpm <= 9:advisories(keyed by id
78
+ const advisories = (audit && audit.advisories) || {};
79
+ for (const adv of Object.values(advisories)) {
80
+ if (!adv || adv.severity === 'info') continue;
75
81
  vulns.push({
76
- package: adv.module_name,
77
- severity: adv.severity,
78
- title: adv.title,
79
- range: adv.vulnerable_versions,
82
+ package: adv.module_name || 'unknown',
83
+ severity: adv.severity || 'unknown',
84
+ title: adv.title || 'Unknown vulnerability',
85
+ range: adv.vulnerable_versions || 'Unknown',
80
86
  fixAvailable: !!adv.patched_versions,
81
87
  url: adv.url || '',
82
88
  });
@@ -84,11 +90,89 @@ function parseNpmAudit(audit) {
84
90
 
85
91
  return {
86
92
  vulns,
87
- totalDependencies: metadata.totalDependencies || 0,
88
- totalVulnerabilities: metadata.vulnerabilities?.total || vulns.length,
93
+ totalDependencies: resolveDependencyCount(metadata),
94
+ totalVulnerabilities: metadata.vulnerabilities?.total ?? vulns.length,
89
95
  };
90
96
  }
91
97
 
98
+ /**
99
+ * 依赖总数:npm v6/pnpm 用 `metadata.totalDependencies`(数字);
100
+ * npm v7+ 用 `metadata.dependencies.total`;pnpm 某些版本直接给数字。
101
+ */
102
+ function resolveDependencyCount(metadata) {
103
+ if (typeof metadata.totalDependencies === 'number') return metadata.totalDependencies;
104
+ const deps = metadata.dependencies;
105
+ if (deps && typeof deps === 'object' && typeof deps.total === 'number') return deps.total;
106
+ if (typeof deps === 'number') return deps;
107
+ return null;
108
+ }
109
+
110
+ /** 判断 stderr 是否是"没有 lockfile 可审计" */
111
+ function isNoLockfileMessage(text) {
112
+ return /ERR_PNPM_AUDIT_NO_LOCKFILE|ENOLOCK|requires an existing lockfile|Cannot audit a project without a lockfile/i.test(text);
113
+ }
114
+
115
+ /**
116
+ * 执行审计。任何未真正产出审计结论的情况 → `{ error, code }`。
117
+ */
118
+ export function runAudit(backend, profileDir) {
119
+ let res;
120
+ try {
121
+ res = spawnSync(backend.pm, backend.args, {
122
+ cwd: profileDir,
123
+ encoding: 'utf8',
124
+ timeout: AUDIT_TIMEOUT_MS,
125
+ maxBuffer: MAX_BUFFER,
126
+ });
127
+ } catch (e) {
128
+ return { error: `执行 ${backend.pm} audit 失败:${e.message}`, code: 'SPAWN_FAILED' };
129
+ }
130
+
131
+ if (res.error) {
132
+ if (res.error.code === 'ENOENT') {
133
+ return { error: `未找到 ${backend.pm} 命令(ENOENT)`, code: 'PM_NOT_FOUND' };
134
+ }
135
+ if (res.error.code === 'ETIMEDOUT') {
136
+ return { error: `${backend.pm} audit 超时(${AUDIT_TIMEOUT_MS}ms)`, code: 'AUDIT_TIMEOUT' };
137
+ }
138
+ return { error: `${backend.pm} audit 执行失败:${res.error.message}`, code: res.error.code || 'SPAWN_FAILED' };
139
+ }
140
+
141
+ const stdout = (res.stdout || '').trim();
142
+ const stderr = (res.stderr || '').trim();
143
+
144
+ // 无 stdout 通常意味着包管理器拒绝了审计(无锁文件 / 无网络 / 未登录)。
145
+ // pnpm 12 的 ERR_PNPM_AUDIT_NO_LOCKFILE 就只写 stderr、stdout 为空。
146
+ if (!stdout) {
147
+ if (isNoLockfileMessage(stderr)) {
148
+ return { error: `无锁文件,无法审计:${firstLine(stderr)}`, code: 'NO_LOCKFILE' };
149
+ }
150
+ return { error: `audit 无 JSON 输出(exit ${res.status}):${firstLine(stderr) || 'stderr 为空'}`, code: 'AUDIT_NO_OUTPUT' };
151
+ }
152
+
153
+ let audit;
154
+ try {
155
+ audit = JSON.parse(stdout);
156
+ } catch {
157
+ return { error: `audit 输出不是合法 JSON(exit ${res.status})`, code: 'AUDIT_BAD_JSON' };
158
+ }
159
+
160
+ const parsed = parseAuditJson(audit);
161
+ if (parsed.auditError) return { error: parsed.auditError, code: parsed.auditErrorCode };
162
+ return parsed;
163
+ }
164
+
165
+ function firstLine(text) {
166
+ return (text || '').split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
167
+ }
168
+
169
+ function auditSeverityToEnum(sev) {
170
+ if (sev === 'critical') return Severity.CRITICAL;
171
+ if (sev === 'high') return Severity.HIGH;
172
+ if (sev === 'moderate' || sev === 'medium') return Severity.MEDIUM;
173
+ return Severity.LOW;
174
+ }
175
+
92
176
  /**
93
177
  * SP1 检查:扫描 profile 依赖链中的已知漏洞
94
178
  * @param {string} profileDir - profile 目录路径
@@ -98,20 +182,29 @@ export async function run(profileDir) {
98
182
  const id = 'SP1';
99
183
 
100
184
  if (!existsSync(join(profileDir, 'package.json'))) {
101
- return pass(id, Severity.HIGH, 'profile 无 package.json,跳过依赖审计');
185
+ return skip(id, Severity.HIGH, 'profile 无 package.json,没有可审计的依赖清单,跳过依赖审计');
102
186
  }
103
187
 
104
- const { vulns, error, totalDependencies } = runNpmAudit(profileDir);
188
+ const backend = detectBackend(profileDir);
189
+ if (!backend) {
190
+ return skip(id, Severity.HIGH,
191
+ 'profile 无 pnpm-lock.yaml / package-lock.json,npm/pnpm audit 均无锁文件可审计(NO_LOCKFILE),跳过依赖审计');
192
+ }
105
193
 
106
- if (error) {
107
- return pass(id, Severity.HIGH, `依赖审计跳过:${error}`);
194
+ const result = runAudit(backend, profileDir);
195
+ if (result.error) {
196
+ return skip(id, Severity.HIGH, `依赖审计未执行(${result.code}):${result.error}`);
108
197
  }
109
198
 
199
+ const { vulns } = result;
200
+ const depCount = result.totalDependencies;
201
+
110
202
  if (vulns.length === 0) {
111
- return pass(id, Severity.HIGH, `依赖链无已知漏洞(扫描 ${totalDependencies || '?'} 个依赖)`);
203
+ const countText = typeof depCount === 'number' ? `${depCount}` : '计数不可用';
204
+ return pass(id, Severity.HIGH,
205
+ `依赖链无已知漏洞(${backend.pm} audit --json,扫描 ${countText} 个依赖)`);
112
206
  }
113
207
 
114
- // 按 severity 分组
115
208
  const bySeverity = {};
116
209
  for (const v of vulns) {
117
210
  if (!bySeverity[v.severity]) bySeverity[v.severity] = [];
@@ -129,19 +222,16 @@ export async function run(profileDir) {
129
222
 
130
223
  const fixable = vulns.filter(v => v.fixAvailable).length;
131
224
  const fixHint = fixable > 0
132
- ? `${fixable} 个漏洞可通过 npm fix 修复`
225
+ ? `${fixable} 个漏洞有可用补丁版本,可运行 ${backend.pm} audit --fix / audit fix 修复`
133
226
  : '部分漏洞可能需要升级主版本或更换依赖';
134
227
 
135
- const overallSeverity = maxSeverity(vulns.map(v => {
136
- if (v.severity === 'critical') return Severity.CRITICAL;
137
- if (v.severity === 'high') return Severity.HIGH;
138
- if (v.severity === 'moderate') return Severity.MEDIUM;
139
- return Severity.LOW;
140
- }));
228
+ const overallSeverity = maxSeverity(vulns.map(v => auditSeverityToEnum(v.severity)));
229
+
230
+ const countText = typeof depCount === 'number' ? `,共扫描 ${depCount} 个依赖` : '';
141
231
 
142
232
  return fail(id, overallSeverity,
143
- `检测到 ${vulns.length} 个已知漏洞(${summary}):\n${details}\n${fixHint}`,
144
- '运行 npm audit fix 修复可自动修复的漏洞;手动升级有破坏性变更的依赖',
233
+ `检测到 ${vulns.length} 个已知漏洞(${summary}${countText},来源 ${backend.pm} audit --json):\n${details}\n${fixHint}`,
234
+ `运行 ${backend.pm} audit 查看详情并升级受影响依赖;优先修复 critical/high`,
145
235
  vulns.filter(v => v.url).slice(0, 3).map(v => v.url)
146
236
  );
147
237
  }
@@ -151,7 +241,7 @@ export const sp1Check = {
151
241
  name: 'dependency-audit',
152
242
  severity: Severity.HIGH,
153
243
  phase: CheckPhase.POST_INSTALL,
154
- description: '依赖链已知漏洞扫描(npm audit)',
244
+ description: '依赖链已知漏洞扫描(pnpm/npm audit)',
155
245
  src: 'builtin',
156
246
  runner: (profileDir) => run(profileDir),
157
247
  };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * SP11: Patch-layer security-row override —— 第三方 patch 层静默改写安全配置
3
+ *
4
+ * 威胁(#587 / #4094):Cordis patch 语义是"按 id 覆盖 + 后写获胜",且**没有受保护行**。
5
+ * 于是一个第三方 bundle 的 cordis.patch.yml 可以在 boot 时静默改写
6
+ * sandbox / approval / permission 这类安全行(例如把 sandbox 模式放宽到 danger-full-access、
7
+ * 把 approval 策略改成 never),而用户与既有检查都看不到——SP3/SP4 只做文本模式扫描,从不看**组合结果**。
8
+ *
9
+ * 本检查离线判定:把各层 patch 里触及安全行的条目找出来,并按**层来源**分级——
10
+ * - 第三方 bundle / profile 的 node_modules 内 → error(用户并未主动同意)
11
+ * - 用户自己的 profile patch → 只作提示(用户有权自己改)
12
+ *
13
+ * Severity: CRITICAL Phase: POST_INSTALL
14
+ */
15
+
16
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { Severity } from '../protocol/severity.mjs';
19
+ import { CheckPhase } from '../protocol/phase.mjs';
20
+ import { pass, fail, skip } from '../protocol/check.mjs';
21
+
22
+ /** 安全行 id / 配置键:被改写即影响宿主安全姿态 */
23
+ const SECURITY_ROW_IDS = ['sandbox-policy', 'approval', 'permission'];
24
+ const SECURITY_KEYS = ['sandbox-policy', 'sandboxPolicy', 'approval', 'permission', 'defaultPreset', 'presets', 'policy'];
25
+
26
+ /** 收集各层 patch 文件,并标注来源层(bundle=第三方 / user=用户自有) */
27
+ export function collectPatchLayers(profileDir) {
28
+ const layers = [];
29
+ const userPatch = join(profileDir, 'cordis.patch.yml');
30
+ if (existsSync(userPatch)) layers.push({ file: userPatch, layer: 'user' });
31
+
32
+ const nmDir = join(profileDir, 'node_modules');
33
+ if (existsSync(nmDir)) {
34
+ let entries = [];
35
+ try { entries = readdirSync(nmDir, { withFileTypes: true }); } catch { entries = []; }
36
+ for (const entry of entries) {
37
+ if (entry.name.startsWith('.')) continue;
38
+ if (entry.name.startsWith('@')) {
39
+ const scopeDir = join(nmDir, entry.name);
40
+ let pkgs = [];
41
+ try { pkgs = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
42
+ for (const pkg of pkgs) {
43
+ const f = join(scopeDir, pkg.name, 'cordis.patch.yml');
44
+ if (existsSync(f)) layers.push({ file: f, layer: 'bundle', pkg: `${entry.name}/${pkg.name}` });
45
+ }
46
+ } else {
47
+ const f = join(nmDir, entry.name, 'cordis.patch.yml');
48
+ if (existsSync(f)) layers.push({ file: f, layer: 'bundle', pkg: entry.name });
49
+ }
50
+ }
51
+ }
52
+ return layers;
53
+ }
54
+
55
+ /**
56
+ * 从 patch 文本里找出触及安全行的条目。
57
+ * 采用保守的行扫描(不引入 YAML 依赖):命中安全 id 或安全键即记录,并带上该行上下文。
58
+ */
59
+ export function securityTouches(text) {
60
+ const hits = [];
61
+ const lines = text.split('\n');
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i];
64
+ const trimmed = line.trim();
65
+ if (!trimmed || trimmed.startsWith('#')) continue; // 注释不算
66
+ for (const id of SECURITY_ROW_IDS) {
67
+ if (new RegExp(`(^|[-\\s'"])${id}(['"\\s:]|$)`).test(trimmed)) {
68
+ hits.push({ line: i + 1, kind: 'row-id', value: id, text: trimmed.slice(0, 120) });
69
+ break;
70
+ }
71
+ }
72
+ for (const key of SECURITY_KEYS) {
73
+ if (new RegExp(`^\\s*${key}\\s*:`).test(line) || new RegExp(`['"]?${key}['"]?\\s*:`).test(trimmed)) {
74
+ if (!hits.some((h) => h.line === i + 1)) hits.push({ line: i + 1, kind: 'key', value: key, text: trimmed.slice(0, 120) });
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ return hits;
80
+ }
81
+
82
+ export async function run(profileDir) {
83
+ const id = 'SP11';
84
+ if (!profileDir || !existsSync(profileDir)) {
85
+ return skip(id, Severity.CRITICAL, '无 profile 目录,跳过 patch 层安全行覆盖检测');
86
+ }
87
+
88
+ const layers = collectPatchLayers(profileDir);
89
+ if (layers.length === 0) {
90
+ return skip(id, Severity.CRITICAL, '未找到任何 cordis.patch.yml,无法判定 patch 层是否改写安全行');
91
+ }
92
+
93
+ const bundleHits = [];
94
+ const userHits = [];
95
+ for (const { file, layer, pkg } of layers) {
96
+ let text;
97
+ try { text = readFileSync(file, 'utf8'); } catch { continue; }
98
+ const hits = securityTouches(text);
99
+ if (hits.length === 0) continue;
100
+ const sample = hits.slice(0, 3).map((h) => `行${h.line} ${h.value}: ${h.text}`).join('; ');
101
+ if (layer === 'bundle') bundleHits.push(`${pkg || file}(${sample})`);
102
+ else userHits.push(`${file}(${hits.length} 处:${sample})`);
103
+ }
104
+
105
+ if (bundleHits.length > 0) {
106
+ return fail(id, Severity.CRITICAL,
107
+ `第三方 patch 层改写了安全配置行(sandbox / approval / permission)——patch 按 id 覆盖且后写获胜,`
108
+ + `宿主没有受保护行,因此这类改写会在 boot 时静默生效(#587/#4094):\n ${bundleHits.join('\n ')}`
109
+ + (userHits.length ? `\n(用户自有 patch 另有 ${userHits.length} 处,属用户自主配置)` : ''),
110
+ '审查上述插件的 cordis.patch.yml,确认其修改安全行是必要的;如非必要,向作者反馈或移除该插件;'
111
+ + '在宿主提供"受保护行"机制前,安全相关配置应只由用户层设置',
112
+ ['#587', '#4094']
113
+ );
114
+ }
115
+
116
+ if (userHits.length > 0) {
117
+ return pass(id, Severity.CRITICAL,
118
+ `安全行仅由用户自有 patch 触及(${userHits.length} 处),无第三方层改写:${userHits.join('; ')}`);
119
+ }
120
+
121
+ return pass(id, Severity.CRITICAL, `扫描 ${layers.length} 个 patch 层,无任何层改写 sandbox/approval/permission 安全行`);
122
+ }
123
+
124
+ export const sp11Check = {
125
+ id: 'SP11',
126
+ name: 'patch-security-override',
127
+ severity: Severity.CRITICAL,
128
+ phase: CheckPhase.POST_INSTALL,
129
+ description: '第三方 patch 层静默改写安全配置行(sandbox/approval/permission)——patch 无受保护行(#587/#4094)',
130
+ src: 'builtin',
131
+ runner: (profileDir) => run(profileDir),
132
+ };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * SP12: `!!js` 配置即代码标签 + `dsh.bundle.patch` 路径健全性
3
+ *
4
+ * 威胁(#454 / #587 / #3354):宿主用 js-yaml 解析 patch,并**支持 `!!js` 标签**——
5
+ * 该标签会在**加载期求值任意 JavaScript**。也就是说一个插件的 cordis.patch.yml 里写
6
+ * mode: !!js <任意表达式>
7
+ * 就能在 boot 时执行代码(已实测的宿主 RCE 面)。SP4 的正则只看投毒关键词,**不识别 `!!js` 标签本身**,
8
+ * 也不区分"第三方层"与"用户自己写的 patch"。
9
+ *
10
+ * 另查 `dsh.bundle.patch` 的路径健全性:声明的 patch 必须存在且不逃出包目录(`../` 逃逸 = 越权读宿主文件)。
11
+ *
12
+ * 分级:第三方 bundle → error(用户未同意);用户自有 patch → 提示(用户有权自己写 JS)。
13
+ * Severity: CRITICAL Phase: POST_INSTALL
14
+ */
15
+
16
+ import { readFileSync, existsSync, realpathSync, readdirSync } from 'node:fs';
17
+ import { join, resolve, relative } from 'node:path';
18
+ import { Severity } from '../protocol/severity.mjs';
19
+ import { CheckPhase } from '../protocol/phase.mjs';
20
+ import { pass, fail, skip } from '../protocol/check.mjs';
21
+ import { collectPatchLayers } from './sp11-patch-security-override.mjs';
22
+
23
+ /** 找出 `!!js` 标签出现处(行级,带上下文),排除注释行 */
24
+ export function jsTagHits(text) {
25
+ const hits = [];
26
+ const lines = text.split('\n');
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const t = lines[i].trim();
29
+ if (!t || t.startsWith('#')) continue;
30
+ if (/!!js\b/.test(t)) hits.push({ line: i + 1, text: t.slice(0, 120) });
31
+ }
32
+ return hits;
33
+ }
34
+
35
+ /** 检查每个已装 bundle 的 dsh.bundle.patch 是否健在且未逃出包目录。 */
36
+ export function bundlePatchIssues(profileDir) {
37
+ const issues = [];
38
+ const nmDir = join(profileDir, 'node_modules');
39
+ if (!existsSync(nmDir)) return issues;
40
+ let entries = [];
41
+ try { entries = readdirSync(nmDir, { withFileTypes: true }); } catch { return issues; }
42
+
43
+ const checkPkg = (dir, name) => {
44
+ const manifest = join(dir, 'package.json');
45
+ if (!existsSync(manifest)) return;
46
+ let pkg;
47
+ try { pkg = JSON.parse(readFileSync(manifest, 'utf8')); } catch { return; }
48
+ const declared = pkg?.dsh?.bundle?.patch ?? pkg?.dsh?.bundle;
49
+ const rel = typeof declared === 'string' ? declared : (declared && typeof declared.patch === 'string' ? declared.patch : null);
50
+ if (!rel) return;
51
+ const target = resolve(dir, rel);
52
+ if (!existsSync(target)) { issues.push(`${name}: dsh.bundle.patch 指向的 ${rel} 不存在(安装不完整)`); return; }
53
+ // 两侧都必须做 realpath:macOS 的 /var → /private/var 等 symlink 会让单侧解析出假的 `../..`
54
+ let realDir = resolve(dir);
55
+ let realTarget = target;
56
+ try { realDir = realpathSync(dir); } catch { /* 用 resolve 结果 */ }
57
+ try { realTarget = realpathSync(target); } catch { /* 用 resolve 结果 */ }
58
+ if (relative(realDir, realTarget).startsWith('..')) {
59
+ issues.push(`${name}: dsh.bundle.patch 指向包目录之外(${rel})——可读宿主任意文件`);
60
+ }
61
+ };
62
+
63
+ for (const entry of entries) {
64
+ if (entry.name.startsWith('.')) continue;
65
+ if (entry.name.startsWith('@')) {
66
+ const scopeDir = join(nmDir, entry.name);
67
+ let pkgs = [];
68
+ try { pkgs = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
69
+ for (const p of pkgs) checkPkg(join(scopeDir, p.name), `${entry.name}/${p.name}`);
70
+ } else {
71
+ checkPkg(join(nmDir, entry.name), entry.name);
72
+ }
73
+ }
74
+ return issues;
75
+ }
76
+
77
+ export async function run(profileDir) {
78
+ const id = 'SP12';
79
+ if (!profileDir || !existsSync(profileDir)) {
80
+ return skip(id, Severity.CRITICAL, '无 profile 目录,跳过 !!js 配置即代码检测');
81
+ }
82
+
83
+ const layers = collectPatchLayers(profileDir);
84
+ const bundleHits = [];
85
+ const userHits = [];
86
+ for (const { file, layer, pkg } of layers) {
87
+ let text;
88
+ try { text = readFileSync(file, 'utf8'); } catch { continue; }
89
+ const hits = jsTagHits(text);
90
+ if (!hits.length) continue;
91
+ const sample = hits.slice(0, 3).map((h) => `行${h.line}: ${h.text}`).join('; ');
92
+ if (layer === 'bundle') bundleHits.push(`${pkg || file}(${sample})`);
93
+ else userHits.push(`${file}(${sample})`);
94
+ }
95
+
96
+ const patchIssues = bundlePatchIssues(profileDir);
97
+
98
+ if (bundleHits.length > 0 || patchIssues.length > 0) {
99
+ const parts = [];
100
+ if (bundleHits.length) {
101
+ parts.push(`第三方 patch 使用 !!js 标签(**加载期执行任意 JavaScript**,#454/#587/#3354):\n ${bundleHits.join('\n ')}`);
102
+ }
103
+ if (patchIssues.length) parts.push(`dsh.bundle.patch 路径异常:\n ${patchIssues.join('\n ')}`);
104
+ return fail(id, Severity.CRITICAL,
105
+ parts.join('\n') + (userHits.length ? `\n(用户自有 patch 另有 !!js ${userHits.length} 处,属用户自主配置)` : ''),
106
+ '要求该插件移除 !!js 标签(改用静态配置值);若确需动态配置,应由用户在自己的 profile patch 里写。'
107
+ + '同时修正 dsh.bundle.patch 指向,使其存在于包内',
108
+ ['#454', '#587', '#3354']
109
+ );
110
+ }
111
+
112
+ const note = userHits.length ? `;用户自有 patch 含 ${userHits.length} 处 !!js(用户自主配置,仅提示)` : '';
113
+ return pass(id, Severity.CRITICAL,
114
+ `扫描 ${layers.length} 个 patch 层:第三方层无 !!js 标签;${patchIssues.length === 0 ? 'dsh.bundle.patch 路径均健全' : ''}${note}`);
115
+ }
116
+
117
+ export const sp12Check = {
118
+ id: 'SP12',
119
+ name: 'config-as-code-tag',
120
+ severity: Severity.CRITICAL,
121
+ phase: CheckPhase.POST_INSTALL,
122
+ description: '!!js 配置即代码(加载期执行 JS,实测宿主 RCE 面)+ dsh.bundle.patch 路径健全性(#454/#587/#3354)',
123
+ src: 'builtin',
124
+ runner: (profileDir) => run(profileDir),
125
+ };