@moonquake2004/dsh-security 0.1.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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * SP1: Dependency Audit — 依赖链漏洞扫描
3
+ *
4
+ * 集成 npm audit 检查 profile 中已安装插件的已知漏洞。
5
+ * 支持 npm audit 和 osv-scanner 两种后端。
6
+ *
7
+ * Severity: HIGH(有 critical/high 漏洞时)
8
+ * Phase: POST_INSTALL
9
+ */
10
+
11
+ import { execSync } from 'node:child_process';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { Severity, maxSeverity } from '../protocol/severity.mjs';
15
+ import { CheckPhase } from '../protocol/phase.mjs';
16
+ import { pass, fail } from '../protocol/check.mjs';
17
+
18
+ /**
19
+ * 运行 npm audit 并解析结果
20
+ */
21
+ function runNpmAudit(profileDir) {
22
+ const packageJsonPath = join(profileDir, 'package.json');
23
+ if (!existsSync(packageJsonPath)) {
24
+ return { vulns: [], error: 'package.json 不存在' };
25
+ }
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}` };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 解析 npm audit JSON 输出
52
+ */
53
+ function parseNpmAudit(audit) {
54
+ const vulns = [];
55
+ const advisory = audit.advisories || {};
56
+ const metadata = audit.metadata || {};
57
+
58
+ // npm v7+ 格式
59
+ if (audit.vulnerabilities) {
60
+ for (const [name, vuln] of Object.entries(audit.vulnerabilities)) {
61
+ if (vuln.severity === 'info') continue;
62
+ vulns.push({
63
+ package: name,
64
+ severity: vuln.severity,
65
+ title: vuln.via?.[0]?.title || 'Unknown vulnerability',
66
+ range: vuln.range || 'Unknown',
67
+ fixAvailable: !!vuln.fixAvailable,
68
+ url: vuln.via?.[0]?.url || '',
69
+ });
70
+ }
71
+ }
72
+
73
+ // npm v6 格式(advisories)
74
+ for (const [id, adv] of Object.entries(advisory)) {
75
+ vulns.push({
76
+ package: adv.module_name,
77
+ severity: adv.severity,
78
+ title: adv.title,
79
+ range: adv.vulnerable_versions,
80
+ fixAvailable: !!adv.patched_versions,
81
+ url: adv.url || '',
82
+ });
83
+ }
84
+
85
+ return {
86
+ vulns,
87
+ totalDependencies: metadata.totalDependencies || 0,
88
+ totalVulnerabilities: metadata.vulnerabilities?.total || vulns.length,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * SP1 检查:扫描 profile 依赖链中的已知漏洞
94
+ * @param {string} profileDir - profile 目录路径
95
+ * @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
96
+ */
97
+ export async function run(profileDir) {
98
+ const id = 'SP1';
99
+
100
+ if (!existsSync(join(profileDir, 'package.json'))) {
101
+ return pass(id, Severity.HIGH, 'profile 无 package.json,跳过依赖审计');
102
+ }
103
+
104
+ const { vulns, error, totalDependencies } = runNpmAudit(profileDir);
105
+
106
+ if (error) {
107
+ return pass(id, Severity.HIGH, `依赖审计跳过:${error}`);
108
+ }
109
+
110
+ if (vulns.length === 0) {
111
+ return pass(id, Severity.HIGH, `依赖链无已知漏洞(扫描 ${totalDependencies || '?'} 个依赖)`);
112
+ }
113
+
114
+ // 按 severity 分组
115
+ const bySeverity = {};
116
+ for (const v of vulns) {
117
+ if (!bySeverity[v.severity]) bySeverity[v.severity] = [];
118
+ bySeverity[v.severity].push(v);
119
+ }
120
+
121
+ const summary = Object.entries(bySeverity)
122
+ .map(([sev, items]) => `${sev}: ${items.length}`)
123
+ .join(', ');
124
+
125
+ const details = vulns
126
+ .slice(0, 10)
127
+ .map(v => `${v.package}(${v.severity})— ${v.title}`)
128
+ .join('\n');
129
+
130
+ const fixable = vulns.filter(v => v.fixAvailable).length;
131
+ const fixHint = fixable > 0
132
+ ? `${fixable} 个漏洞可通过 npm fix 修复`
133
+ : '部分漏洞可能需要升级主版本或更换依赖';
134
+
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
+ }));
141
+
142
+ return fail(id, overallSeverity,
143
+ `检测到 ${vulns.length} 个已知漏洞(${summary}):\n${details}\n${fixHint}`,
144
+ '运行 npm audit fix 修复可自动修复的漏洞;手动升级有破坏性变更的依赖',
145
+ vulns.filter(v => v.url).slice(0, 3).map(v => v.url)
146
+ );
147
+ }
148
+
149
+ export const sp1Check = {
150
+ id: 'SP1',
151
+ name: 'dependency-audit',
152
+ severity: Severity.HIGH,
153
+ phase: CheckPhase.POST_INSTALL,
154
+ description: '依赖链已知漏洞扫描(npm audit)',
155
+ src: 'builtin',
156
+ runner: (profileDir) => run(profileDir),
157
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * SP2: Secret Scan — 配置文件硬编码密钥检测
3
+ *
4
+ * 扫描 cordis.patch.yml、package.json 和插件源码中的硬编码密钥。
5
+ * 区分安全模式(环境变量引用)和不安全模式(硬编码值)。
6
+ *
7
+ * Severity: HIGH
8
+ * Phase: POST_INSTALL
9
+ */
10
+
11
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
12
+ import { join, relative } from 'node:path';
13
+ import { Severity, maxSeverity } from '../protocol/severity.mjs';
14
+ import { CheckPhase } from '../protocol/phase.mjs';
15
+ import { pass, fail } from '../protocol/check.mjs';
16
+
17
+ /** 密钥模式(正则) */
18
+ const SECRET_PATTERNS = [
19
+ { name: 'OpenAI API Key', regex: /sk-[a-zA-Z0-9]{20,}/g, severity: 'high' },
20
+ { name: 'GitHub PAT', regex: /ghp_[a-zA-Z0-9]{36}/g, severity: 'high' },
21
+ { name: 'GitHub Fine-grained PAT', regex: /github_pat_[a-zA-Z0-9_]{20,}/g, severity: 'high' },
22
+ { name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g, severity: 'high' },
23
+ { name: 'Slack Token', regex: /xox[baprs]-[a-zA-Z0-9-]+/g, severity: 'high' },
24
+ { name: 'Google API Key', regex: /AIza[0-9A-Za-z_-]{35}/g, severity: 'high' },
25
+ { name: 'PEM Private Key', regex: /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/g, severity: 'critical' },
26
+ { name: 'Generic Secret Assignment', regex: /(SECRET|TOKEN|API_KEY|PASSWORD|CREDENTIAL)\s*[:=]\s*['"][^'"]{8,}['"]/gi, severity: 'medium' },
27
+ ];
28
+
29
+ /** 安全模式(排除) */
30
+ const SAFE_PATTERNS = [
31
+ /\$\{[A-Z_]+\}/, // ${VAR}
32
+ /process\.env\.[A-Z_]+/, // process.env.VAR
33
+ /!!js\s+process\.env/, // !!js process.env.XXX (YAML safe)
34
+ /\$env:[A-Z_]+/, // $env:VAR (PowerShell)
35
+ /os\.environ\[[^]]+\]/, // os.environ['VAR'] (Python)
36
+ ];
37
+
38
+ function isSafePattern(line) {
39
+ return SAFE_PATTERNS.some(p => p.test(line));
40
+ }
41
+
42
+ function scanFile(filePath, profileDir) {
43
+ const findings = [];
44
+ let content;
45
+ try {
46
+ content = readFileSync(filePath, 'utf8');
47
+ } catch {
48
+ return findings;
49
+ }
50
+
51
+ const lines = content.split('\n');
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i];
54
+ if (isSafePattern(line)) continue;
55
+
56
+ for (const pattern of SECRET_PATTERNS) {
57
+ const matches = line.matchAll(pattern.regex);
58
+ for (const match of matches) {
59
+ findings.push({
60
+ file: relative(profileDir, filePath),
61
+ line: i + 1,
62
+ type: pattern.name,
63
+ severity: pattern.severity,
64
+ snippet: line.trim().slice(0, 100),
65
+ });
66
+ }
67
+ }
68
+ }
69
+ return findings;
70
+ }
71
+
72
+ function scanDirectory(dir, profileDir, extensions = ['.yml', '.yaml', '.json', '.js', '.mjs', '.ts', '.pem', '.key', '.env', '.toml']) {
73
+ const findings = [];
74
+ if (!existsSync(dir)) return findings;
75
+
76
+ const entries = readdirSync(dir, { withFileTypes: true });
77
+ for (const entry of entries) {
78
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
79
+ const fullPath = join(dir, entry.name);
80
+ if (entry.isDirectory()) {
81
+ findings.push(...scanDirectory(fullPath, profileDir, extensions));
82
+ } else if (extensions.some(ext => entry.name.endsWith(ext))) {
83
+ findings.push(...scanFile(fullPath, profileDir));
84
+ }
85
+ }
86
+ return findings;
87
+ }
88
+
89
+ /**
90
+ * SP2 检查:扫描 profile 目录中的硬编码密钥
91
+ * @param {string} profileDir - profile 目录路径
92
+ * @returns {import('../protocol/check.mjs').SecurityCheckResult}
93
+ */
94
+ export function run(profileDir) {
95
+ const id = 'SP2';
96
+ const findings = scanDirectory(profileDir, profileDir);
97
+
98
+ if (findings.length === 0) {
99
+ return pass(id, Severity.HIGH, '配置文件和插件源码中未检测到硬编码密钥');
100
+ }
101
+
102
+ const bySeverity = {};
103
+ for (const f of findings) {
104
+ if (!bySeverity[f.severity]) bySeverity[f.severity] = [];
105
+ bySeverity[f.severity].push(f);
106
+ }
107
+
108
+ const summary = Object.entries(bySeverity)
109
+ .map(([sev, items]) => `${sev}: ${items.length}`)
110
+ .join(', ');
111
+
112
+ const details = findings
113
+ .slice(0, 10) // 最多显示 10 条
114
+ .map(f => `${f.file}:${f.line} — ${f.type}(${f.severity})`)
115
+ .join('\n');
116
+
117
+ const fix = '将密钥移至环境变量或密钥管理器(如 1Password),从配置文件中删除硬编码值。使用 !!js process.env.VAR 引用环境变量。';
118
+ const overallSeverity = maxSeverity(findings.map(f => f.severity));
119
+
120
+ return fail(id, overallSeverity, `检测到 ${findings.length} 个硬编码密钥(${summary}):\n${details}`, fix, ['#962']);
121
+ }
122
+
123
+ export const sp2Check = {
124
+ id: 'SP2',
125
+ name: 'secret-scan',
126
+ severity: Severity.HIGH,
127
+ phase: CheckPhase.POST_INSTALL,
128
+ description: '配置文件和插件源码中的硬编码密钥检测',
129
+ src: 'builtin',
130
+ runner: (profileDir) => Promise.resolve(run(profileDir)),
131
+ };
@@ -0,0 +1,220 @@
1
+ /**
2
+ * SP3: Sandbox Consistency — 沙箱策略配置一致性审计
3
+ *
4
+ * 轻量版 dsh-sandbox-audit:检查 cordis.patch.yml 中的沙箱策略配置,
5
+ * 检测工具的沙箱接线与策略声明不一致。
6
+ *
7
+ * 三大类问题:
8
+ * HIGH: 变文件系统工具共享 bare fs-local backend(策略被静默忽略)
9
+ * MEDIUM: 搜索工具未挂载 fs 但读取了写策略外的路径
10
+ * LOW: 工具声明了不必要的沙箱权限
11
+ *
12
+ * Severity: MEDIUM(默认)
13
+ * 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 } from '../protocol/check.mjs';
21
+
22
+ /** 已知的变文件系统工具(需要 sandbox 接线) */
23
+ const MUTATING_FS_TOOLS = [
24
+ 'tool-fs',
25
+ 'str_replace_editor',
26
+ 'tool-fs-write',
27
+ ];
28
+
29
+ /** 已知的搜索工具(只读,但可能越权读取) */
30
+ const SEARCH_TOOLS = [
31
+ 'tool-fs-search',
32
+ 'tool-glob',
33
+ 'tool-grep',
34
+ ];
35
+
36
+ /**
37
+ * 从 cordis.patch.yml 内容中提取所有 entry id
38
+ */
39
+ function extractEntryIds(content) {
40
+ const ids = [];
41
+ const regex = /^\s*-?\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/gm;
42
+ let match;
43
+ while ((match = regex.exec(content)) !== null) {
44
+ ids.push(match[1]);
45
+ }
46
+ return ids;
47
+ }
48
+
49
+ /**
50
+ * 获取指定 entry 的配置块(从 - id: 到下一个 - id: 之间的所有内容)
51
+ */
52
+ function getEntryBlock(content, entryId) {
53
+ const entryRegex = new RegExp(`^\\s*-?\\s*id:\\s*['"]?${entryId}['"]?\\s*$`, 'gm');
54
+ const entryMatch = entryRegex.exec(content);
55
+ if (!entryMatch) return '';
56
+
57
+ const afterEntry = content.slice(entryMatch.index + entryMatch[0].length);
58
+ const nextEntryMatch = afterEntry.match(/^\s*-?\s*id:\s/m);
59
+ return nextEntryMatch
60
+ ? afterEntry.slice(0, nextEntryMatch.index)
61
+ : afterEntry;
62
+ }
63
+
64
+ /**
65
+ * 检查 entry 配置块中是否包含某个 key(任意层级)
66
+ */
67
+ function blockHasKey(block, key) {
68
+ return new RegExp(`^\\s+${key}:\\s`, 'm').test(block);
69
+ }
70
+
71
+ /**
72
+ * 检查 sandbox-policy 配置
73
+ */
74
+ function checkSandboxPolicy(content) {
75
+ const issues = [];
76
+
77
+ // 只检查显式声明了 sandbox-policy 的情况
78
+ if (!content.includes('sandbox-policy')) return issues;
79
+
80
+ // 检查 danger-full-access 模式
81
+ if (/mode:\s*['"]?danger-full-access['"]?/.test(content) &&
82
+ !/mode:\s*.*win32/.test(content)) {
83
+ issues.push({
84
+ severity: 'medium',
85
+ tool: 'sandbox-policy',
86
+ finding: 'sandbox-policy 使用 danger-full-access 模式(非 Windows),所有工具不受沙箱限制',
87
+ });
88
+ }
89
+
90
+ return issues;
91
+ }
92
+
93
+ /**
94
+ * 检查工具的沙箱接线
95
+ */
96
+ function checkToolSandbox(content) {
97
+ const issues = [];
98
+ const entryIds = extractEntryIds(content);
99
+
100
+ // 检查变文件系统工具是否有 sandbox 接线
101
+ for (const tool of MUTATING_FS_TOOLS) {
102
+ if (entryIds.includes(tool)) {
103
+ const block = getEntryBlock(content, tool);
104
+ const hasSandbox = blockHasKey(block, 'sandbox') || blockHasKey(block, 'sandbox-backend');
105
+ if (!hasSandbox) {
106
+ issues.push({
107
+ severity: 'medium',
108
+ tool,
109
+ finding: `${tool} 未声明沙箱后端配置,可能使用默认 bare fs-local(策略被静默忽略)`,
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ // 检查搜索工具是否挂载了 fs
116
+ for (const tool of SEARCH_TOOLS) {
117
+ if (entryIds.includes(tool)) {
118
+ const block = getEntryBlock(content, tool);
119
+ const hasFs = blockHasKey(block, 'fs');
120
+ if (!hasFs) {
121
+ issues.push({
122
+ severity: 'low',
123
+ tool,
124
+ finding: `${tool} 未显式挂载 fs,可能读取写策略外的路径`,
125
+ });
126
+ }
127
+ }
128
+ }
129
+
130
+ return issues;
131
+ }
132
+
133
+ /**
134
+ * SP3 检查:扫描 profile 中的沙箱策略一致性
135
+ * @param {string} profileDir - profile 目录路径
136
+ * @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
137
+ */
138
+ export async function run(profileDir) {
139
+ const id = 'SP3';
140
+
141
+ // 找所有 cordis.patch.yml 文件
142
+ const patchFiles = [];
143
+ const profilePatch = join(profileDir, 'cordis.patch.yml');
144
+ if (existsSync(profilePatch)) patchFiles.push(profilePatch);
145
+
146
+ // 扫描 node_modules 中的 bundle patch
147
+ const nmDir = join(profileDir, 'node_modules');
148
+ if (existsSync(nmDir)) {
149
+ const entries = readdirSync(nmDir, { withFileTypes: true });
150
+ for (const entry of entries) {
151
+ if (entry.name.startsWith('.') || entry.name === '.bin') continue;
152
+ if (entry.name.startsWith('@')) {
153
+ // scoped package
154
+ const scopeDir = join(nmDir, entry.name);
155
+ const pkgs = readdirSync(scopeDir, { withFileTypes: true });
156
+ for (const pkg of pkgs) {
157
+ const patchPath = join(scopeDir, pkg.name, 'cordis.patch.yml');
158
+ if (existsSync(patchPath)) patchFiles.push(patchPath);
159
+ }
160
+ } else {
161
+ const patchPath = join(nmDir, entry.name, 'cordis.patch.yml');
162
+ if (existsSync(patchPath)) patchFiles.push(patchPath);
163
+ }
164
+ }
165
+ }
166
+
167
+ if (patchFiles.length === 0) {
168
+ return pass(id, Severity.MEDIUM, '未找到 cordis.patch.yml 配置文件,跳过沙箱策略审计');
169
+ }
170
+
171
+ const allIssues = [];
172
+ for (const patchFile of patchFiles) {
173
+ try {
174
+ const content = readFileSync(patchFile, 'utf8');
175
+ allIssues.push(...checkSandboxPolicy(content));
176
+ allIssues.push(...checkToolSandbox(content));
177
+ } catch {
178
+ // 跳过无法解析的文件
179
+ }
180
+ }
181
+
182
+ if (allIssues.length === 0) {
183
+ return pass(id, Severity.MEDIUM, `扫描 ${patchFiles.length} 个 patch 文件,沙箱策略配置一致`);
184
+ }
185
+
186
+ const bySeverity = {};
187
+ for (const issue of allIssues) {
188
+ if (!bySeverity[issue.severity]) bySeverity[issue.severity] = [];
189
+ bySeverity[issue.severity].push(issue);
190
+ }
191
+
192
+ const summary = Object.entries(bySeverity)
193
+ .map(([sev, items]) => `${sev}: ${items.length}`)
194
+ .join(', ');
195
+
196
+ const details = allIssues
197
+ .slice(0, 10)
198
+ .map(i => `[${i.severity}] ${i.tool} — ${i.finding}`)
199
+ .join('\n');
200
+
201
+ const overallSeverity = allIssues.some(i => i.severity === 'high') ? Severity.HIGH
202
+ : allIssues.some(i => i.severity === 'medium') ? Severity.MEDIUM
203
+ : Severity.LOW;
204
+
205
+ return fail(id, overallSeverity,
206
+ `检测到 ${allIssues.length} 个沙箱策略不一致(${summary}):\n${details}`,
207
+ '参考 dsh-sandbox-audit 获取详细修复建议',
208
+ ['#2066']
209
+ );
210
+ }
211
+
212
+ export const sp3Check = {
213
+ id: 'SP3',
214
+ name: 'sandbox-consistency',
215
+ severity: Severity.MEDIUM,
216
+ phase: CheckPhase.POST_INSTALL,
217
+ description: '沙箱策略配置一致性审计',
218
+ src: 'builtin',
219
+ runner: (profileDir) => run(profileDir),
220
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * SP4: Entry Poison — 恶意 entry 注入检测
3
+ *
4
+ * 扫描 cordis.patch.yml 中的可疑 entry 注入模式:
5
+ * - 已知恶意 id 模式
6
+ * - 异常的 entry 配置
7
+ * - 可疑的 inject 依赖链
8
+ *
9
+ * Severity: HIGH
10
+ * Phase: POST_INSTALL
11
+ */
12
+
13
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { Severity } from '../protocol/severity.mjs';
16
+ import { CheckPhase } from '../protocol/phase.mjs';
17
+ import { pass, fail } from '../protocol/check.mjs';
18
+
19
+ /** 已知恶意 entry 模式 */
20
+ const MALICIOUS_PATTERNS = [
21
+ { name: 'hook injection', regex: /hook[s]?:\s*\n\s+-\s*id:\s*(hook|on|before|after)/gi, severity: 'high' },
22
+ { name: 'eval execution', regex: /eval\s*\(|Function\s*\(/g, severity: 'high' },
23
+ { name: 'child process', regex: /child_process|execSync|spawnSync/g, severity: 'medium' },
24
+ { name: 'network exfil', regex: /fetch\s*\(|http\.request|https\.request/g, severity: 'medium' },
25
+ ];
26
+
27
+ function scanPatchFile(filePath) {
28
+ const findings = [];
29
+ let content;
30
+ try { content = readFileSync(filePath, 'utf8'); } catch { return findings; }
31
+ for (const pattern of MALICIOUS_PATTERNS) {
32
+ const matches = content.matchAll(new RegExp(pattern.regex.source, 'gi'));
33
+ for (const match of matches) {
34
+ findings.push({ type: pattern.name, severity: pattern.severity, snippet: match[0].slice(0, 60) });
35
+ }
36
+ }
37
+ return findings;
38
+ }
39
+
40
+ export async function run(profileDir) {
41
+ const id = 'SP4';
42
+ const patchFiles = [];
43
+ const profilePatch = join(profileDir, 'cordis.patch.yml');
44
+ if (existsSync(profilePatch)) patchFiles.push(profilePatch);
45
+
46
+ const nmDir = join(profileDir, 'node_modules');
47
+ if (existsSync(nmDir)) {
48
+ for (const entry of readdirSync(nmDir, { withFileTypes: true })) {
49
+ if (entry.name.startsWith('.') || entry.name === '.bin') continue;
50
+ if (entry.name.startsWith('@')) {
51
+ const scopeDir = join(nmDir, entry.name);
52
+ for (const pkg of readdirSync(scopeDir, { withFileTypes: true })) {
53
+ const f = join(scopeDir, pkg.name, 'cordis.patch.yml');
54
+ if (existsSync(f)) patchFiles.push(f);
55
+ }
56
+ } else {
57
+ const f = join(nmDir, entry.name, 'cordis.patch.yml');
58
+ if (existsSync(f)) patchFiles.push(f);
59
+ }
60
+ }
61
+ }
62
+
63
+ if (patchFiles.length === 0) return pass(id, Severity.HIGH, '未找到 patch 文件,跳过恶意 entry 检测');
64
+
65
+ const allFindings = [];
66
+ for (const f of patchFiles) allFindings.push(...scanPatchFile(f));
67
+
68
+ if (allFindings.length === 0) return pass(id, Severity.HIGH, `扫描 ${patchFiles.length} 个 patch 文件,未检测到恶意 entry 模式`);
69
+
70
+ const details = allFindings.slice(0, 10).map(f => `[${f.severity}] ${f.type}: ${f.snippet}`).join('\n');
71
+ return fail(id, Severity.HIGH, `检测到 ${allFindings.length} 个可疑 entry 模式:\n${details}`, '检查相关插件的 cordis.patch.yml 内容');
72
+ }
73
+
74
+ export const sp4Check = { id: 'SP4', name: 'entry-poison', severity: Severity.HIGH, phase: CheckPhase.POST_INSTALL, description: '恶意 entry 注入检测', src: 'builtin', runner: (d) => run(d) };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * SP5: Permission Model — 插件权限声明验证
3
+ *
4
+ * 检查插件是否声明了所需权限(capability declarations):
5
+ * - 文件系统访问范围
6
+ * - 网络访问权限
7
+ * - 进程执行权限
8
+ *
9
+ * Severity: MEDIUM
10
+ * Phase: POST_INSTALL
11
+ */
12
+
13
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { Severity } from '../protocol/severity.mjs';
16
+ import { CheckPhase } from '../protocol/phase.mjs';
17
+ import { pass, fail } from '../protocol/check.mjs';
18
+
19
+ function scanForUndeclaredCapabilities(content, pkgName) {
20
+ const issues = [];
21
+ // 检查文件系统访问但无 sandbox 声明
22
+ if (/tool-fs|str_replace_editor/.test(content) && !/sandbox/.test(content)) {
23
+ issues.push({ type: 'fs-without-sandbox', severity: 'medium', detail: `${pkgName} 使用文件系统工具但未声明 sandbox 配置` });
24
+ }
25
+ // 检查网络访问但无声明
26
+ if (/fetch|http\.request|curl/.test(content) && !/network|http/.test(content)) {
27
+ issues.push({ type: 'network-undeclared', severity: 'low', detail: `${pkgName} 有网络访问但未显式声明` });
28
+ }
29
+ return issues;
30
+ }
31
+
32
+ export async function run(profileDir) {
33
+ const id = 'SP5';
34
+ const nmDir = join(profileDir, 'node_modules');
35
+ if (!existsSync(nmDir)) return pass(id, Severity.MEDIUM, '无 node_modules,跳过权限验证');
36
+
37
+ const issues = [];
38
+ for (const entry of readdirSync(nmDir, { withFileTypes: true })) {
39
+ if (entry.name.startsWith('.') || entry.name === '.bin') continue;
40
+ const pkgPath = join(nmDir, entry.name, 'package.json');
41
+ if (!existsSync(pkgPath)) continue;
42
+ try {
43
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
44
+ if (!pkg.dsh?.bundle) continue;
45
+ const patchPath = join(nmDir, entry.name, 'cordis.patch.yml');
46
+ if (existsSync(patchPath)) {
47
+ const content = readFileSync(patchPath, 'utf8');
48
+ issues.push(...scanForUndeclaredCapabilities(content, pkg.name));
49
+ }
50
+ } catch { /* skip */ }
51
+ }
52
+
53
+ if (issues.length === 0) return pass(id, Severity.MEDIUM, '插件权限声明一致');
54
+ const details = issues.slice(0, 10).map(i => `[${i.severity}] ${i.detail}`).join('\n');
55
+ return fail(id, Severity.MEDIUM, `检测到 ${issues.length} 个权限声明问题:\n${details}`, '为插件显式声明所需的权限范围');
56
+ }
57
+
58
+ export const sp5Check = { id: 'SP5', name: 'permission-model', severity: Severity.MEDIUM, phase: CheckPhase.POST_INSTALL, description: '插件权限声明验证', src: 'builtin', runner: (d) => run(d) };