dsh-code-security 0.3.8 → 0.4.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,242 @@
1
+ /** Authenticated operator UI: workspaces come from host sessions/tools, never browser paths. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import lockfile from 'proper-lockfile';
7
+ import { resolveConfig } from './config.js';
8
+ import { loadState } from './state.js';
9
+ import { splitByBaseline } from './state.js';
10
+ import { redactText } from './privacy.js';
11
+ export const SECURITY_WORKBENCH_ROUTE = '/api/dsh-code-security/workbench';
12
+ const hash = (text) => createHash('sha256').update(text).digest('hex');
13
+ const json = (status, value) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
14
+ export class SecurityWorkbench {
15
+ cfg;
16
+ automaticProblems = new Map();
17
+ jobs = new Map();
18
+ tools;
19
+ directory;
20
+ onAutoChanged;
21
+ constructor(cfg, options) {
22
+ this.cfg = cfg;
23
+ this.directory = options.directory ?? path.join(process.env.DSH_HOME || path.join(homedir(), '.dsh'), 'plugins', 'dsh-code-security');
24
+ this.tools = options.tools;
25
+ }
26
+ async catalog() {
27
+ try {
28
+ const file = path.join(this.directory, 'workspaces.json'), stat = await fs.lstat(file);
29
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024)
30
+ throw new Error('invalid index');
31
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
32
+ if (data.version !== 1 || !Array.isArray(data.workspaces) || data.workspaces.length > 100 || data.workspaces.some((w) => !w || typeof w.root !== 'string' || !path.isAbsolute(w.root) || w.id !== hash(w.root) || typeof w.label !== 'string' || typeof w.autoCheck !== 'boolean' || w.sessionId !== undefined && typeof w.sessionId !== 'string') || !data.settings || !Array.isArray(data.settings.engines) || !data.settings.enginePaths)
33
+ throw new Error('invalid index');
34
+ resolveConfig(data.settings);
35
+ return data;
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'ENOENT')
39
+ throw new Error('工作区索引损坏,原文件已保留 / Workspace index is invalid; the original was preserved');
40
+ return { version: 1, workspaces: [], settings: { engines: this.cfg.engines, enginePaths: { ...this.cfg.enginePaths }, ...(this.cfg.osvDatabaseDir ? { osvDatabaseDir: this.cfg.osvDatabaseDir } : {}) } };
41
+ }
42
+ }
43
+ async update(change) {
44
+ await fs.mkdir(this.directory, { recursive: true });
45
+ const unlock = await lockfile.lock(this.directory, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
46
+ try {
47
+ const data = await this.catalog();
48
+ change(data);
49
+ const file = path.join(this.directory, 'workspaces.json'), temp = file + '.tmp-' + randomUUID();
50
+ try {
51
+ await fs.writeFile(temp, JSON.stringify(data), { flag: 'wx', mode: 0o600 });
52
+ await fs.rename(temp, file);
53
+ }
54
+ finally {
55
+ await fs.unlink(temp).catch(() => { });
56
+ }
57
+ }
58
+ finally {
59
+ await unlock();
60
+ }
61
+ }
62
+ async remember(workspace, sessionId) {
63
+ if (!path.isAbsolute(workspace))
64
+ return;
65
+ const root = await fs.realpath(workspace);
66
+ if (!(await fs.lstat(root)).isDirectory())
67
+ return;
68
+ await this.update(data => {
69
+ const previous = data.workspaces.find(w => w.id === hash(root));
70
+ data.workspaces = [{ id: hash(root), root, label: path.basename(root) || root, autoCheck: previous?.autoCheck ?? false, ...(sessionId ? { sessionId } : previous?.sessionId ? { sessionId: previous.sessionId } : {}) }, ...data.workspaces.filter(w => w.id !== hash(root))].slice(0, 100);
71
+ });
72
+ }
73
+ async configFor(root) {
74
+ const { settings } = await this.catalog();
75
+ return { ...this.cfg, engines: [...settings.engines], enginePaths: { ...this.cfg.enginePaths, ...settings.enginePaths }, osvDatabaseDir: settings.osvDatabaseDir ?? this.cfg.osvDatabaseDir, stateDir: this.cfg.stateDirRelative ? path.resolve(root, this.cfg.stateDirRelative) : this.cfg.stateDir };
76
+ }
77
+ async autoEnabled(root) { return (await this.catalog()).workspaces.find(w => w.id === hash(root))?.autoCheck === true; }
78
+ automaticProblem(root, reason) { if (reason)
79
+ this.automaticProblems.set(root, reason);
80
+ else
81
+ this.automaticProblems.delete(root); }
82
+ async workspace(id) {
83
+ const row = (await this.catalog()).workspaces.find(w => typeof id === 'string' && w.id === id);
84
+ if (!row || await fs.realpath(row.root) !== row.root)
85
+ throw new Error('请选择已登记的工作区 / Choose a registered workspace');
86
+ return row;
87
+ }
88
+ async report(row) {
89
+ const tools = this.tools(await this.configFor(row.root), row.root);
90
+ return tools.find(t => t.name === 'secure_report').execute({}, {});
91
+ }
92
+ async runAutomatic(root, signal) {
93
+ const cfg = await this.configFor(root);
94
+ const autoCfg = { ...cfg, stateDir: path.join(cfg.stateDir, 'automatic'), stateDirRelative: undefined, engines: ['builtin'] };
95
+ const result = await this.tools(autoCfg, root).find(t => t.name === 'secure_diff').execute({ includeUntracked: true, engines: ['builtin'] }, { signal });
96
+ const manual = await loadState(cfg.stateDir);
97
+ return { ...result, newFindings: splitByBaseline(result.findings, manual.baseline).fresh };
98
+ }
99
+ safeResult(result) {
100
+ if (!result || typeof result !== 'object')
101
+ return result;
102
+ const value = { ...result };
103
+ delete value.baseline;
104
+ for (const name of ['findings', 'newFindings', 'acceptedFindings', 'closed', 'remaining', 'fresh', 'unverified', 'dependencies'])
105
+ if (Array.isArray(value[name])) {
106
+ value[name + 'Total'] = value[name].length;
107
+ value[name] = value[name].slice(0, 200);
108
+ }
109
+ if (value.audit)
110
+ value.audit = { ...value.audit, vulnerabilitiesTotal: value.audit.vulnerabilities.length, vulnerabilities: value.audit.vulnerabilities.slice(0, 200) };
111
+ return value;
112
+ }
113
+ async run(root, name, args, signal) {
114
+ const tools = this.tools(await this.configFor(root), root), selected = tools.find(t => t.name === name);
115
+ if (!selected)
116
+ throw new Error('Unknown security operation');
117
+ return selected.execute(args, { signal });
118
+ }
119
+ async action(body) {
120
+ if (body.operation === 'list')
121
+ return { ok: true, ...await this.catalog() };
122
+ const row = await this.workspace(body.id);
123
+ if (body.operation === 'open') {
124
+ const cfg = await this.configFor(row.root);
125
+ let automatic;
126
+ try {
127
+ const state = await loadState(path.join(cfg.stateDir, 'automatic'));
128
+ automatic = state.last ? { time: state.last.time, complete: state.last.complete !== false, highRisk: state.last.findings.filter(f => f.severity === 'high' || f.severity === 'critical').length } : null;
129
+ }
130
+ catch {
131
+ automatic = { problem: 'automatic-state-invalid' };
132
+ }
133
+ if (this.automaticProblems.has(row.root))
134
+ automatic = { ...automatic, problem: this.automaticProblems.get(row.root) };
135
+ const running = [...this.jobs.values()].find(j => j.workspace === row.id && j.status === 'running');
136
+ return { ok: true, workspace: row, report: this.safeResult(await this.report(row)), settings: (await this.catalog()).settings, automatic, ...(running ? { activeJob: { id: running.id, action: running.action, status: running.status } } : {}) };
137
+ }
138
+ if (body.operation === 'settings') {
139
+ if (typeof body.autoCheck !== 'boolean')
140
+ throw new Error('autoCheck must be boolean');
141
+ const checked = resolveConfig({ engines: body.engines, enginePaths: body.enginePaths, ...(body.osvDatabaseDir === undefined ? {} : { osvDatabaseDir: body.osvDatabaseDir }) });
142
+ await this.update(data => { data.settings = { engines: checked.engines, enginePaths: checked.enginePaths, ...(checked.osvDatabaseDir ? { osvDatabaseDir: checked.osvDatabaseDir } : {}) }; data.workspaces.find(w => w.id === row.id).autoCheck = body.autoCheck; });
143
+ this.onAutoChanged?.(row.root, body.autoCheck);
144
+ return { ok: true, ...await this.action({ operation: 'open', id: row.id }) };
145
+ }
146
+ if (body.operation === 'location') {
147
+ const cfg = await this.configFor(row.root), state = await loadState(cfg.stateDir), finding = state.last?.findings.find(f => f.id === body.finding);
148
+ if (!finding)
149
+ throw new Error('问题已变化,请刷新 / Finding changed; refresh the report');
150
+ const file = path.resolve(row.root, finding.file), rel = path.relative(row.root, file);
151
+ if (rel.startsWith('..') || path.isAbsolute(rel) || await fs.realpath(file) !== file || !(await fs.lstat(file)).isFile())
152
+ throw new Error('不能打开工作区外或已移动的文件 / File is outside the workspace or has moved');
153
+ return { ok: true, path: file, line: finding.line, sessionId: row.sessionId };
154
+ }
155
+ if (body.operation === 'job' || body.operation === 'cancel') {
156
+ const job = this.jobs.get(String(body.job));
157
+ if (!job || job.workspace !== row.id)
158
+ throw new Error('任务已结束或已重启 / Job expired or DSH restarted');
159
+ if (body.operation === 'cancel')
160
+ job.controller.abort(new Error('cancelled by operator'));
161
+ return { ok: true, job: { id: job.id, action: job.action, status: job.status, ...(job.result ? { result: this.safeResult(job.result) } : {}), ...(job.error ? { error: job.error } : {}) } };
162
+ }
163
+ if (body.operation === 'export') {
164
+ if (body.format !== 'sarif' && body.format !== 'markdown')
165
+ throw new Error('Invalid export format');
166
+ const result = await this.run(row.root, 'secure_export', { format: body.format });
167
+ return { ok: true, text: result.text, filename: body.format === 'sarif' ? 'security-report.sarif' : 'security-report.md' };
168
+ }
169
+ if (body.operation === 'start') {
170
+ const actions = { scan: 'secure_scan', diff: 'secure_diff', verify: 'secure_fix_verify', deps: 'secure_deps', health: 'secure_health' };
171
+ const action = String(body.action), tool = actions[action];
172
+ if (!tool)
173
+ throw new Error('不支持此操作 / Unsupported operation');
174
+ if ([...this.jobs.values()].some(j => j.workspace === row.id && j.status === 'running'))
175
+ throw new Error('已有检查正在运行 / A check is already running');
176
+ if (action === 'deps' && !['none', 'offline', 'online'].includes(String(body.audit ?? 'none')))
177
+ throw new Error('Invalid audit mode');
178
+ const args = action === 'deps' ? { audit: body.audit ?? 'none' } : action === 'diff' ? { includeUntracked: true } : {};
179
+ const job = { id: randomUUID(), workspace: row.id, action, status: 'running', controller: new AbortController() };
180
+ this.jobs.set(job.id, job);
181
+ job.work = this.run(row.root, tool, args, job.controller.signal).then(result => { job.result = result; job.status = 'completed'; }, error => { job.status = job.controller.signal.aborted ? 'cancelled' : 'failed'; job.error = job.controller.signal.aborted ? '已取消 / Cancelled' : redactText(error instanceof Error ? error.message : String(error)).slice(0, 2000); });
182
+ if (this.jobs.size > 100)
183
+ for (const [id, old] of this.jobs) {
184
+ if (old.status !== 'running')
185
+ this.jobs.delete(id);
186
+ if (this.jobs.size <= 80)
187
+ break;
188
+ }
189
+ return { ok: true, job: { id: job.id, action, status: job.status } };
190
+ }
191
+ throw new Error('不支持此操作 / Unsupported operation');
192
+ }
193
+ async fetch(request) {
194
+ if (request.method !== 'POST')
195
+ return json(405, { ok: false, message: 'POST required' });
196
+ const origin = request.headers.get('origin'), site = request.headers.get('sec-fetch-site');
197
+ if (request.headers.get('x-dsh-security-action') !== '1' || request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json' || site && !['same-origin', 'none'].includes(site))
198
+ return json(403, { ok: false, message: '请从安全检查面板操作 / Use the security panel' });
199
+ try {
200
+ if (origin) {
201
+ // DSH's connection adapter uses an internal Request URL; Host retains the browser authority.
202
+ const source = new URL(origin), internal = new URL(request.url), authority = request.headers.get('host') || internal.host;
203
+ if (!['http:', 'https:'].includes(source.protocol) || source.host !== authority || authority === internal.host && source.protocol !== internal.protocol)
204
+ return json(403, { ok: false, message: '拒绝跨源操作 / Cross-origin request refused' });
205
+ }
206
+ const reader = request.body?.getReader();
207
+ if (!reader)
208
+ throw new Error('Request body missing');
209
+ let size = 0;
210
+ const chunks = [];
211
+ try {
212
+ for (;;) {
213
+ const part = await reader.read();
214
+ if (part.done)
215
+ break;
216
+ size += part.value.byteLength;
217
+ if (size > 16384) {
218
+ await reader.cancel();
219
+ return json(413, { ok: false, message: 'Request too large' });
220
+ }
221
+ ;
222
+ chunks.push(part.value);
223
+ }
224
+ }
225
+ finally {
226
+ reader.releaseLock();
227
+ }
228
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
229
+ if (!body || typeof body !== 'object' || Array.isArray(body))
230
+ throw new Error('Invalid operation');
231
+ return json(200, await this.action(body));
232
+ }
233
+ catch (error) {
234
+ return json(400, { ok: false, message: redactText(error instanceof Error ? error.message : String(error)) });
235
+ }
236
+ }
237
+ async dispose() { for (const job of this.jobs.values())
238
+ job.controller.abort(new Error('DSH disposed')); await Promise.allSettled([...this.jobs.values()].map(job => job.work)); }
239
+ }
240
+ export function installSecurityWorkbench(ctx, workbench) {
241
+ ctx.inject?.(['connection'], (host) => host.connection.fetch.register({ path: SECURITY_WORKBENCH_ROUTE, methods: ['POST'], requestBody: 'buffered', fetch: (request) => workbench.fetch(request) }));
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-security",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "对代码与 Git 改动进行本地静态安全检查,并追踪修复结果。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,16 +10,19 @@
10
10
  "types": "./lib/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
+ "./client": "./lib/client.js",
13
14
  "./cordis.patch.yml": "./cordis.patch.yml",
14
15
  "./package.json": "./package.json"
15
16
  },
16
17
  "files": [
17
18
  "lib",
19
+ "rules",
18
20
  "cordis.patch.yml",
19
21
  "README.md",
20
22
  "README.en.md",
21
23
  "docs",
22
- "CHANGELOG.md"
24
+ "CHANGELOG.md",
25
+ "THIRD_PARTY_NOTICES.md"
23
26
  ],
24
27
  "scripts": {
25
28
  "build": "tsc -p tsconfig.json",
@@ -30,6 +33,10 @@
30
33
  "dsh": {
31
34
  "bundle": {
32
35
  "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "inject": ["@deepseek-ai/dsh-client-ui-settings"],
39
+ "platform": "web"
33
40
  }
34
41
  },
35
42
  "keywords": [
@@ -47,8 +54,10 @@
47
54
  "node": "^22.19.0 || >=24.0.0"
48
55
  },
49
56
  "devDependencies": {
57
+ "@types/babel__traverse": "^7.28.0",
50
58
  "@types/node": "^24.0.0",
51
59
  "@types/proper-lockfile": "4.1.4",
60
+ "fflate": "0.8.3",
52
61
  "typescript": "^5.6.0"
53
62
  },
54
63
  "repository": {
@@ -62,6 +71,10 @@
62
71
  "author": "stardustlc",
63
72
  "packageManager": "pnpm@11.7.0",
64
73
  "dependencies": {
65
- "proper-lockfile": "4.1.2"
74
+ "@babel/parser": "7.29.9",
75
+ "@babel/traverse": "7.29.10",
76
+ "@babel/types": "7.29.8",
77
+ "proper-lockfile": "4.1.2",
78
+ "smol-toml": "1.9.1"
66
79
  }
67
80
  }
@@ -0,0 +1,3 @@
1
+ # Use the installed engine's reviewed default rules, never a project's executable rules.
2
+ [extend]
3
+ useDefault = true
package/rules/osv.toml ADDED
@@ -0,0 +1 @@
1
+ # Explicit empty scanner configuration; project overrides are not executed or imported.
@@ -0,0 +1,72 @@
1
+ # Original rules for this plugin (MIT). No Semgrep-maintained registry rules bundled.
2
+ rules:
3
+ - id: dsh-js-command-input
4
+ languages: [javascript, typescript]
5
+ mode: taint
6
+ message: Request input flows into a shell command argument. Review quoting and use argument arrays.
7
+ severity: ERROR
8
+ metadata: {cwe: [CWE-78], confidence: MEDIUM}
9
+ pattern-sources:
10
+ - pattern-either:
11
+ - pattern: $REQ.query
12
+ - pattern: $REQ.body
13
+ - pattern: $REQ.params
14
+ pattern-sinks:
15
+ - patterns:
16
+ - pattern-either:
17
+ - pattern: exec($CMD, ...)
18
+ - pattern: execSync($CMD, ...)
19
+ - pattern: $CP.exec($CMD, ...)
20
+ - pattern: $CP.execSync($CMD, ...)
21
+ - focus-metavariable: $CMD
22
+ - id: dsh-js-html-input
23
+ languages: [javascript, typescript]
24
+ mode: taint
25
+ message: Request input flows into an HTML insertion point without a recognized sanitizer.
26
+ severity: ERROR
27
+ metadata: {cwe: [CWE-79], confidence: MEDIUM}
28
+ pattern-sources:
29
+ - pattern-either:
30
+ - pattern: $REQ.query
31
+ - pattern: $REQ.body
32
+ - pattern: $REQ.params
33
+ pattern-sinks:
34
+ - patterns:
35
+ - pattern: $EL.innerHTML = $HTML
36
+ - focus-metavariable: $HTML
37
+ - id: dsh-js-sql-input
38
+ languages: [javascript, typescript]
39
+ mode: taint
40
+ message: Request input flows into SQL text. Parameterized values are separate from SQL text.
41
+ severity: ERROR
42
+ metadata: {cwe: [CWE-89], confidence: MEDIUM}
43
+ pattern-sources:
44
+ - pattern-either:
45
+ - pattern: $REQ.query
46
+ - pattern: $REQ.body
47
+ - pattern: $REQ.params
48
+ pattern-sinks:
49
+ - patterns:
50
+ - pattern-either:
51
+ - pattern: $DB.query($SQL, ...)
52
+ - pattern: $DB.execute($SQL, ...)
53
+ - focus-metavariable: $SQL
54
+ - id: dsh-python-command-input
55
+ languages: [python]
56
+ mode: taint
57
+ message: Request input flows into a shell command argument.
58
+ severity: ERROR
59
+ metadata: {cwe: [CWE-78], confidence: MEDIUM}
60
+ pattern-sources:
61
+ - pattern-either:
62
+ - pattern: request.args
63
+ - pattern: request.form
64
+ - pattern: request.GET
65
+ - pattern: request.POST
66
+ pattern-sinks:
67
+ - patterns:
68
+ - pattern-either:
69
+ - pattern: os.system($CMD)
70
+ - pattern: subprocess.run($CMD, ..., shell=True, ...)
71
+ - pattern: subprocess.call($CMD, ..., shell=True, ...)
72
+ - focus-metavariable: $CMD