dsh-code-security 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.6.0(2026-10-25)
8
+
9
+ - 安全检查主工作台,问题、证据、覆盖与工具配置分开;切项目保护未保存设置,并通过原生会话打开文件。
10
+ - 提供 DSH 原生侧栏入口,保留设置页;统一中英、深浅色、窄屏、键盘焦点与真实空/失败状态。
11
+ - 浏览器源码移至 templates/client.js、templates/panels/ 与共享组件,独立构建为 lib/client.js;移除重复的首次引导。
12
+ - 官方 DSH 0.2.1-rc.3 Web 逐项实际操作验收,账户模拟和未验收范围见 [工作台说明](docs/WORKBENCH.md)。
13
+
7
14
  ## 0.5.1(2026-10-20)
8
15
 
9
16
  - 修复审查外来仓库时可能执行对方 `.git/config` 命令的问题:所有 git 调用加上 `-c core.fsmonitor=false`,`secure_diff` 与 `secure_scan` 的 `includeUntracked` 都不再触发被扫仓库的 fsmonitor 命令
package/README.en.md CHANGED
@@ -10,6 +10,8 @@ Run local static security checks on code or Git changes and verify fixes.
10
10
 
11
11
  Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-code-security/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-code-security/pulls).
12
12
 
13
+ Open the new native sidebar workbench after updating and restarting DSH. Settings remains available. See the [workbench guide and validation boundaries](docs/WORKBENCH.md).
14
+
13
15
  ## What it does
14
16
 
15
17
  - Scan files, directories or Git diffs with locations and code evidence.
package/README.md CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-code-security/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-code-security/pulls)。
12
12
 
13
+ 更新并重启 DSH 后,可从原生侧栏打开新版工作台,原设置入口继续保留。详见[工作台说明与验收范围](docs/WORKBENCH.md)。
14
+
13
15
  ## 功能
14
16
 
15
17
  - 扫描文件、目录或 Git diff,报告规则、位置与代码证据。
@@ -0,0 +1,29 @@
1
+ # dsh-code-security 工作台 / Workbench
2
+
3
+ 安全检查主工作台,问题、证据、覆盖与工具配置分开;切项目保护未保存设置,并通过原生会话打开文件。
4
+
5
+ Security workbench with findings, evidence, coverage and tools; protects unsaved settings and opens files through native sessions.
6
+
7
+ ## 使用 / Use
8
+
9
+ 更新插件并重启 DSH,在原生左侧栏打开工作台;没有固定时可在“更多”中找到。原有“设置”入口继续可用。侧栏排序和固定由 DSH 管理。
10
+
11
+ Update the plugin, restart DSH and open its native sidebar panel (or More if it is not pinned). The existing Settings entry remains available. DSH owns sidebar order and pinning.
12
+
13
+ 界面跟随宿主中英文和深浅色,保留用户内容原文。窄窗口中先显示列表,打开详情后可返回;编辑器按可用宽度分栏或顺序排列。危险操作需要明确确认,未保存内容不会因切页而静默丢失。
14
+
15
+ The UI follows host language and theme without translating user content. Narrow master/detail views have a Back action; editors adapt their columns to available space. Destructive actions require confirmation and edits are protected.
16
+
17
+ ## 源码 / Source
18
+
19
+ 编辑 templates/client.js、templates/panels/*.js 与 templates/workbench-ui.js,运行 pnpm run build。lib/client.js 是确定性产物,请随源码提交。公共组件的规范源位于 dsh-suite/ui/workbench-ui.js,Suite 的同步脚本显式分发副本;单个仓库可独立构建和安装。
20
+
21
+ Edit the template sources and run pnpm run build. Commit generated lib/client.js with source changes. The vendored UI has no Suite runtime dependency.
22
+
23
+ ## 验收范围 / Validation boundary
24
+
25
+ 2026-10-25:Windows、官方 DSH 0.2.1-rc.3 Web,后台 Chrome/Playwright 实际点击和输入;覆盖中英文、浅深色、1536px 与 390px、状态读回与控制台。Browser plugin 不可用,因此使用本地 Playwright。
26
+
27
+ 邮件使用受控 AgentMail HTTP 服务,日历使用受控 CalDAV 协议传输;插件处理器、存储、校验和原生宿主均实际运行。未使用真实邮箱、真实日历账号或付费模型。RSS 访问 Node.js 官方公开来源。PPT 实际生成并渲染 PPTX、下载 ZIP;两个视频插件实际运行官方引擎并播放 MP4。
28
+
29
+ 这些是 Web 共用界面的验收结果,不代表已验收 Electron 专属菜单、系统权限、真实账号、全部外部来源或所有可选模型/引擎。Docker、Slack、钉钉继续按用户约定搁置。
package/lib/client.js CHANGED
@@ -1,114 +1,403 @@
1
+ // Generated from templates/client.js, templates/panels/ and templates/workbench-ui.js. Edit the sources.
2
+ (function () {
3
+ /* DSH workbench UI v1. MIT. Canonical source: dsh-suite/ui/workbench-ui.js.
4
+ * Vendored into each plugin: no Suite or extra runtime dependency is required.
5
+ * Styles are scoped to .dshw and never replace the host chrome or theme.
6
+ */
7
+ function createWorkbenchUI(React) {
8
+ const h = React.createElement;
9
+ const { useEffect, useId, useRef } = React;
10
+ const cx = (...values) => values.filter(Boolean).join(' ');
11
+ const paths = {
12
+ book: ['M4 4h6a3 3 0 0 1 2 2v15a4 4 0 0 0-4-2H4z', 'M20 4h-6a3 3 0 0 0-2 2v15a4 4 0 0 1 4-2h4z'],
13
+ calendar: ['M5 5h14a2 2 0 0 1 2 2v13H3V7a2 2 0 0 1 2-2Z', 'M7 3v4M17 3v4M3 10h18'],
14
+ mail: ['M3 5h18v14H3z', 'm3 6 9 7 9-7'],
15
+ rss: ['M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16', 'M4 19h.01'],
16
+ slides: ['M3 4h18v13H3z', 'M12 17v4M8 21h8'],
17
+ shield: ['m12 3 8 3v6c0 4-4 7-8 9-4-2-8-5-8-9V6z', 'm8 12 3 3 5-6'],
18
+ layers: ['M5 3h16v16H5z', 'M2 7v15h15'],
19
+ play: ['m9 6 9 6-9 6Z'],
20
+ video: ['M3 5h12v14H3z', 'm15 10 6-4v12l-6-4'],
21
+ search: ['M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15Z', 'm16 16 5 5'],
22
+ plus: ['M12 5v14M5 12h14'],
23
+ refresh: ['M20 7a9 9 0 1 0 1 8', 'M20 2v5h-5'],
24
+ settings: ['m9 3-.7 2.4-2 .9L4 5.8 2.5 8.4l1.7 1.8v2.3L2.5 14 4 17l2.5-.4 1.9 1L9 20h3l.8-2.4 2-1 2.5.4 1.5-2.7-1.7-1.9v-2.2l1.7-1.8-1.5-2.6-2.5.4-2-1L12 3Z', 'M14 11.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z'],
25
+ close: ['m6 6 12 12M18 6 6 18'],
26
+ left: ['m14 5-7 7 7 7'],
27
+ right: ['m10 5 7 7-7 7'],
28
+ down: ['m5 9 7 7 7-7'],
29
+ filter: ['M4 5h16M7 12h10M10 19h4'],
30
+ download: ['M12 3v12m-5-5 5 5 5-5', 'M4 16v5h16v-5'],
31
+ upload: ['M12 16V4m-5 5 5-5 5 5', 'M4 16v5h16v-5'],
32
+ copy: ['M8 8h13v13H8z', 'M16 8V3H3v13h5'],
33
+ external: ['M14 3h7v7m0-7L10 14', 'M10 3H3v18h18v-7'],
34
+ file: ['M4 3h10l6 6v12H4z', 'M14 3v6h6M8 13h8M8 17h6'],
35
+ folder: ['M3 5h7l2 3h9v12H3z'],
36
+ edit: ['m15 4 5 5-10 10-6 1 1-6Z', 'm13 6 5 5'],
37
+ trash: ['M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7'],
38
+ more: ['M5 12h.01M12 12h.01M19 12h.01'],
39
+ info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z', 'M12 11v6M12 7h.01'],
40
+ check: ['m5 12 4 4L19 6'],
41
+ quote: ['M4 12h6v7H3v-7c0-4 2-6 5-7M15 12h6v7h-7v-7c0-4 2-6 5-7'],
42
+ star: ['m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z'],
43
+ reply: ['m9 5-7 7 7 7v-5h6c3 0 5 2 6 5 0-8-4-11-12-11Z'],
44
+ link: ['m10 13 4-4M8 16l-2 2a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0', 'm14 8 2-2a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0'],
45
+ };
46
+ function Icon({ name = 'file', size = 20, ...props }) {
47
+ return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.75, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false, ...props },
48
+ (paths[name] || paths.file).map((d, index) => h('path', { key: index, d })));
49
+ }
50
+ function Button({ children, icon, variant, className, ...props }) {
51
+ return h('button', { type: 'button', ...props, className: cx('w-button', variant && 'w-' + variant, className) }, icon ? h(Icon, { name: icon, size: 18 }) : null, children);
52
+ }
53
+ function IconButton({ label, icon, ...props }) {
54
+ return h(Button, { ...props, icon, className: cx('w-icon-button', props.className), 'aria-label': label, title: label });
55
+ }
56
+ function Page({ title, subtitle, actions, children, workbench = false, className, rootRef, ...props }) {
57
+ return h('section', { ...props, ref: rootRef, className: cx('dshw', 'w-page', className), 'data-main': workbench ? 'true' : 'false' },
58
+ h('style', null, styles),
59
+ h('header', { className: 'w-page-header' }, h('div', { className: 'w-heading' }, h('h1', null, title), subtitle ? h('p', { className: 'w-subtitle' }, subtitle) : null), actions ? h('div', { className: 'w-header-actions' }, actions) : null),
60
+ h('div', { className: 'w-body' }, children));
61
+ }
62
+ function Tabs({ items, value, onChange, label, children }) {
63
+ const id = useId();
64
+ const active = items.some(item => item.id === value) ? value : items[0]?.id;
65
+ function keys(event, index) {
66
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
67
+ event.preventDefault();
68
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
69
+ const node = event.currentTarget.parentElement?.querySelectorAll('[role=tab]')[next];
70
+ if (node) { onChange(items[next].id); node.focus(); }
71
+ }
72
+ return h('div', { className: 'w-tabs-layout' },
73
+ h('div', { className: 'w-tabs', role: 'tablist', 'aria-label': label }, items.map((item, index) => h('button', {
74
+ type: 'button', role: 'tab', key: item.id, id: id + '-' + item.id, 'aria-controls': id + '-panel', 'aria-selected': active === item.id, tabIndex: active === item.id ? 0 : -1,
75
+ onClick: () => onChange(item.id), onKeyDown: event => keys(event, index),
76
+ }, item.label, item.count !== undefined ? h('span', { className: 'w-count' }, item.count) : null))),
77
+ h('div', { className: 'w-tab-panel', id: id + '-panel', role: 'tabpanel', 'aria-labelledby': id + '-' + active }, children));
78
+ }
79
+ function Status({ children, tone = 'info', className }) {
80
+ if (!children) return null;
81
+ return h('div', { className: cx('w-status', 'w-status-' + tone, className), role: tone === 'error' ? 'alert' : 'status', 'aria-live': tone === 'error' ? 'assertive' : 'polite' }, h(Icon, { name: tone === 'success' ? 'check' : 'info', size: 18 }), h('div', null, children));
82
+ }
83
+ function Empty({ title, children, action, icon = 'file' }) {
84
+ return h('div', { className: 'w-empty' }, h(Icon, { name: icon, size: 32 }), h('h2', null, title), children ? h('div', { className: 'w-muted' }, children) : null, action ? h('div', { className: 'w-toolbar' }, action) : null);
85
+ }
86
+ function Split({ list, detail, selected, onBack, backLabel, className, listWidth = 360 }) {
87
+ const ref = useRef(null);
88
+ useEffect(() => {
89
+ if (!selected || !ref.current || ref.current.clientWidth > 760) return;
90
+ const heading = ref.current.querySelector('.w-detail h2');
91
+ if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
92
+ }, [selected]);
93
+ return h('div', { ref, className: cx('w-split', className), 'data-selected': selected ? 'true' : 'false', style: { '--w-list-width': listWidth + 'px' } },
94
+ h('div', { className: 'w-master' }, list),
95
+ h('div', { className: 'w-detail' }, selected ? h(Button, { className: 'w-back', icon: 'left', variant: 'ghost', onClick: onBack }, backLabel) : null, detail));
96
+ }
97
+ function Row({ title, meta, description, active, onClick, children, status, className, disabled }) {
98
+ return h('div', { className: cx('w-row', active && 'w-selected', className) },
99
+ children ? h('div', { className: 'w-row-select' }, children) : null,
100
+ h('button', { type: 'button', className: 'w-row-button', onClick, disabled, 'aria-pressed': !!active },
101
+ h('span', { className: 'w-row-head' }, h('strong', null, title), status ? h('span', { className: 'w-tag' }, status) : null),
102
+ meta ? h('span', { className: 'w-row-meta' }, meta) : null,
103
+ description ? h('span', { className: 'w-row-description' }, description) : null));
104
+ }
105
+ function Search({ label, value, onChange, ...props }) {
106
+ const id = useId();
107
+ return h('div', { className: 'w-search' }, h(Icon, { name: 'search', size: 18 }), h('input', { type: 'search', id, 'aria-label': label, placeholder: label, value, onChange, ...props }));
108
+ }
109
+ function Switch({ label, checked, onChange, disabled }) {
110
+ return h('label', { className: 'w-switch-label' }, h('span', null, label), h('input', { type: 'checkbox', role: 'switch', checked, disabled, onChange, 'aria-label': label }), h('span', { className: 'w-switch-track', 'aria-hidden': true }));
111
+ }
112
+ function Dialog({ title, children, onClose, closeLabel, className, busy = false }) {
113
+ const id = useId(), panel = useRef(null);
114
+ useEffect(() => {
115
+ const previous = document.activeElement;
116
+ panel.current?.querySelector('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]')?.focus();
117
+ return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }); };
118
+ }, []);
119
+ function keydown(event) {
120
+ if (event.key === 'Escape' && !event.isComposing) { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); return; }
121
+ if (event.key !== 'Tab') return;
122
+ const controls = [...panel.current.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]')].filter(node => node.getClientRects().length);
123
+ const first = controls[0], last = controls.at(-1);
124
+ if (!controls.length) { event.preventDefault(); panel.current.focus(); }
125
+ else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
126
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
127
+ }
128
+ return h('div', { className: 'w-overlay', onClick: event => { if (!busy && event.target === event.currentTarget) onClose(); } },
129
+ h('section', { ref: panel, className: cx('w-dialog', className), role: 'dialog', 'aria-modal': true, 'aria-labelledby': id, tabIndex: -1, onKeyDown: keydown },
130
+ h('header', { className: 'w-dialog-header' }, h('h2', { id }, title), h(IconButton, { icon: 'close', label: closeLabel, disabled: busy, onClick: onClose })), h('div', { className: 'w-dialog-body' }, children)));
131
+ }
132
+ function register(ctx, { id, label, icon, component, inject, order = 50 }) {
133
+ // The two slots are optional. Existing settings and manual tools still load
134
+ // in hosts without a workbench, while RC.3 owns navigation/pinning itself.
135
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: id, inject: () => ({ ...(inject?.() || {}), workbench: true }) }, component));
136
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id, order, label }, function WorkbenchIcon({ size }) { return h(Icon, { name: icon, size }); }));
137
+ }
138
+ const styles = `
139
+ .dshw{--w-bg:var(--dsw-alias-bg-layer-1,#fff);--w-soft:var(--dsw-alias-bg-layer-2,#f7f8fa);--w-text:var(--dsw-alias-label-primary,#18202c);--w-muted:var(--dsw-alias-label-secondary,#667085);--w-border:var(--dsw-alias-border-l3,#e7e9ef);--w-accent:var(--dsh-plugin-workbench-accent,#4f6bdb);--w-selected:color-mix(in srgb,var(--w-accent) 10%,var(--w-bg));--w-primary:var(--w-text);--w-on-primary:var(--w-bg);container-type:inline-size;container-name:dsh-workbench;color:var(--w-text);background:var(--w-bg);font:14px/1.65 var(--dsh-font-family-content,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif);min-width:0;max-width:100%;box-sizing:border-box;text-align:start;color-scheme:inherit}
140
+ .dshw *,.dshw *:before,.dshw *:after{box-sizing:border-box}.dshw h1,.dshw h2,.dshw h3,.dshw h4,.dshw p{margin:0}.dshw h1{font-size:30px;line-height:1.25;letter-spacing:-.025em;font-weight:650}.dshw h2{font-size:24px;line-height:1.35;font-weight:650;letter-spacing:-.015em;overflow-wrap:anywhere}.dshw h3,.dshw h4{font-size:16px;line-height:1.4;font-weight:600}.dshw a{color:var(--w-accent);text-underline-offset:3px;overflow-wrap:anywhere}.dshw button,.dshw input,.dshw select,.dshw textarea{font:inherit;color:inherit;max-width:100%}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select,.dshw textarea{background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 11px;min-width:0;line-height:1.5}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select{min-height:38px}.dshw textarea{resize:vertical}.dshw input[type=checkbox],.dshw input[type=radio]{width:16px;height:16px;flex-shrink:0;accent-color:var(--w-accent)}.dshw :focus-visible{outline:2px solid var(--w-accent);outline-offset:3px}.dshw button:disabled,.dshw input:disabled,.dshw select:disabled,.dshw textarea:disabled{opacity:.5;cursor:not-allowed}.dshw label{font-size:13px}.dshw fieldset{min-width:0;border:1px solid var(--w-border);border-radius:8px}.dshw summary{cursor:pointer;font-weight:550;list-style:none;display:flex;align-items:center;gap:8px}.dshw summary::-webkit-details-marker{display:none}.dshw summary:after{content:'';display:block;margin-inline-start:auto;width:7px;height:7px;border-inline-end:1.75px solid currentColor;border-bottom:1.75px solid currentColor;transform:rotate(45deg);flex-shrink:0}.dshw details[open]>summary:after{transform:rotate(225deg)}.dshw details>summary+*{margin-top:12px}.dshw pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;max-width:100%;background:var(--w-soft);border-radius:6px;padding:14px}.dshw img,.dshw video,.dshw iframe{max-width:100%}
141
+ .w-page{display:flex;flex-direction:column;position:relative;padding:0;min-height:540px;gap:0}.w-page[data-main=true]{height:100%;min-height:0;padding:calc(24px + var(--dsh-frame-top-clearance,0px)) 28px 0 max(28px,var(--dsh-frame-leading-clearance,0px));overflow:auto}.w-page-header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;padding-bottom:22px;flex-shrink:0}.w-heading{min-width:0}.w-subtitle{margin-top:8px!important;font-size:14px;color:var(--w-muted);line-height:1.6}.w-header-actions,.w-toolbar,.w-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}.w-header-actions{justify-content:flex-end}.w-body,.w-tabs-layout{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0}.w-body{overflow:auto}.w-tab-panel{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:auto}.w-tabs{display:flex;align-items:stretch;gap:20px;border-bottom:1px solid var(--w-border);flex-shrink:0;overflow-x:auto;scrollbar-width:thin}.dshw .w-tabs>button{border:0;border-bottom:2px solid transparent;background:transparent;padding:11px 8px 13px;white-space:nowrap;font-size:14px;font-weight:550;cursor:pointer;color:var(--w-muted);display:flex;align-items:center;gap:7px}.dshw .w-tabs>button[aria-selected=true]{border-bottom-color:var(--w-accent);color:var(--w-accent)}.w-count,.w-tag{font-size:12px;line-height:1.5;display:inline-flex;align-items:center;flex-shrink:0;background:var(--w-soft);border-radius:5px;padding:1px 6px;color:var(--w-muted)}.w-tabs [aria-selected=true] .w-count,.w-selected .w-tag{color:var(--w-accent);background:var(--w-selected)}
142
+ .dshw .w-button{display:inline-flex;justify-content:center;align-items:center;gap:7px;background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 12px;min-height:36px;line-height:1.4;font-size:14px;font-weight:500;cursor:pointer;text-decoration:none;white-space:normal}.dshw .w-button:not(:disabled):hover{background:var(--w-soft)}.dshw .w-primary{background:var(--w-primary);border-color:var(--w-primary);color:var(--w-on-primary)}.dshw .w-primary:not(:disabled):hover{background:var(--w-primary);opacity:.9}.dshw .w-ghost{background:transparent;border-color:transparent}.dshw .w-danger{color:var(--dsw-alias-state-error-primary,#b43c3c)}.dshw .w-icon-button{width:36px;min-width:36px;padding:8px}.dshw .w-button svg{flex-shrink:0}.w-muted{color:var(--w-muted);font-size:12px;line-height:1.6}.w-section{padding:24px 0;border-bottom:1px solid var(--w-border);min-width:0}.w-section>h3{margin-bottom:12px}.w-section>p+p{margin-top:8px}.w-fields{display:grid;gap:16px;min-width:0}.w-field{display:grid;gap:6px;min-width:0}.w-field>input,.w-field>select,.w-field>textarea{width:100%}.w-status{display:flex;align-items:flex-start;gap:8px;padding:11px 13px;margin:12px 0;border:1px solid var(--w-border);border-radius:8px;background:var(--w-soft);font-size:13px;overflow-wrap:anywhere;flex-shrink:0}.w-status>svg{flex-shrink:0;margin-top:2px}.w-status-error{color:var(--dsw-alias-state-error-primary,#ad3636)}.w-status-success{color:var(--dsw-alias-state-success-primary,#24794a)}.w-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:14px;max-width:580px;padding:48px 24px;margin:auto;min-height:280px;overflow-wrap:anywhere}.w-empty>svg{color:var(--w-muted)}.w-empty h2{font-size:20px}.w-empty .w-muted{font-size:14px}.w-empty .w-toolbar{justify-content:center}
143
+ .w-split{display:grid;grid-template-columns:minmax(240px,var(--w-list-width)) minmax(0,1fr);min-height:460px;flex:1;min-width:0}.w-master{min-width:0;border-inline-end:1px solid var(--w-border);overflow:auto;padding-bottom:24px}.w-detail{min-width:0;padding:26px 28px;overflow:auto;overflow-wrap:anywhere;display:flex;flex-direction:column}.w-master-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:20px 16px}.w-search{display:flex;align-items:center;gap:8px;border:1px solid var(--w-border);border-radius:8px;padding:0 10px;min-width:0;flex:1;background:var(--w-bg)}.w-search>svg{color:var(--w-muted);flex-shrink:0}.dshw .w-search>input{border:0!important;background:transparent!important;padding:8px 0!important;width:100%;outline-offset:0}.w-row{display:flex;gap:0;min-width:0;position:relative;border-bottom:1px solid var(--w-border);border-inline-start:3px solid transparent}.w-row-select{display:flex;align-items:flex-start;padding:21px 0 0 14px}.dshw .w-row-button{display:flex;flex-direction:column;align-items:stretch;gap:7px;text-align:start;width:100%;padding:18px 18px 18px 16px;border:0;background:transparent;color:inherit;min-width:0;cursor:pointer;line-height:1.5}.dshw .w-row-button:hover{background:var(--w-soft)}.w-row-head{display:flex;align-items:flex-start;gap:10px;justify-content:space-between;min-width:0}.w-row-head>strong{font-size:14px;line-height:1.5;font-weight:600;overflow-wrap:anywhere}.w-row-meta,.w-row-description{color:var(--w-muted);font-size:12px;line-height:1.6;overflow-wrap:anywhere}.w-row-description{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.w-selected{background:var(--w-selected);border-inline-start-color:var(--w-accent)}.w-back{display:none!important}.w-detail-title{margin-bottom:12px!important}.w-detail-meta{color:var(--w-muted);font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.w-detail-footer{padding:24px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:auto;border-top:1px solid var(--w-border)}.w-detail-footer .w-muted{flex:1;min-width:160px}.w-definition{display:grid;grid-template-columns:minmax(80px,120px) minmax(0,1fr);gap:8px 16px;font-size:14px;margin:20px 0}.w-definition dt{color:var(--w-muted)}.w-definition dd{margin:0;min-width:0;overflow-wrap:anywhere}
144
+ .w-switch-label{display:inline-flex;align-items:center;gap:8px;font-size:13px;position:relative;cursor:pointer}.w-switch-label>input{position:absolute;opacity:0;inset-inline-end:0;width:36px!important;height:22px!important;margin:0;cursor:pointer}.w-switch-track{width:36px;height:22px;border-radius:12px;display:block;background:var(--w-border);border:1px solid var(--w-border);pointer-events:none;padding:2px;transition:background .15s}.w-switch-track:before{content:'';display:block;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0002;transition:transform .15s}.w-switch-label>input:checked+.w-switch-track{background:var(--w-accent);border-color:var(--w-accent)}.w-switch-label>input:checked+.w-switch-track:before{transform:translateX(14px)}.w-switch-label>input:focus-visible+.w-switch-track{outline:2px solid var(--w-accent);outline-offset:3px}.w-switch-label:has(>input:disabled){opacity:.5;cursor:not-allowed}
145
+ .w-overlay{position:fixed;inset:0;background:#0005;display:flex;justify-content:flex-end;z-index:1000;padding:12px;overflow:auto}.w-dialog{width:min(620px,100%);max-height:100%;display:flex;flex-direction:column;min-height:0;background:var(--w-bg);color:var(--w-text);border:1px solid var(--w-border);border-radius:12px;box-shadow:0 18px 70px #0003}.w-dialog-header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px 24px;border-bottom:1px solid var(--w-border);flex-shrink:0}.w-dialog-header>h2{font-size:20px}.w-dialog-body{overflow:auto;padding:20px 24px;min-height:0}.w-editor-grid{display:grid;grid-template-columns:210px minmax(0,1fr) 300px;min-height:500px;flex:1}.w-editor-list{padding:20px 12px;border-inline-end:1px solid var(--w-border);min-width:0}.w-editor-canvas{background:var(--w-soft);padding:20px;min-width:0;display:flex;flex-direction:column;gap:14px}.w-editor-inspector{padding:24px;border-inline-start:1px solid var(--w-border);min-width:0;overflow:auto}.w-editor-inspector h3{margin-bottom:18px}.w-preview-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:12px;aspect-ratio:16/9;min-height:180px;background:var(--w-soft);border:1px solid var(--w-border);border-radius:8px;padding:24px;color:var(--w-muted)}.w-content-pad{padding:24px 0}.w-stack{display:flex;flex-direction:column;gap:16px;min-width:0}.w-scroll-x{overflow:auto;max-width:100%}
146
+ @container dsh-workbench (max-width:1000px){.w-editor-grid{grid-template-columns:160px minmax(0,1fr)}.w-editor-inspector{grid-column:1/-1;border-inline-start:0;border-top:1px solid var(--w-border)}.w-header-actions{max-width:60%}.w-split{grid-template-columns:minmax(240px,32%) minmax(0,1fr)}}
147
+ @container dsh-workbench (max-width:760px){.w-page-header{gap:14px;flex-wrap:wrap;padding-bottom:18px}.w-heading{flex:1;min-width:170px}.dshw h1{font-size:24px}.w-header-actions{max-width:100%;justify-content:flex-start}.w-subtitle{font-size:13px}.w-tabs{gap:12px}.dshw .w-tabs>button{padding:10px 4px 12px}.w-split{display:block;min-height:360px}.w-split[data-selected=false]>.w-detail{display:none}.w-split[data-selected=true]>.w-master{display:none}.w-master{border-inline-end:0}.w-detail{padding:20px 0}.w-back{display:inline-flex!important;align-self:flex-start;margin:0 0 16px!important}.w-detail h2{font-size:22px}.w-master-toolbar{padding:16px 0}.dshw .w-row-button{padding:16px 10px}.w-detail-footer{position:static}.w-detail-footer .w-muted{flex-basis:100%}.w-editor-grid{display:flex;flex-direction:column}.w-editor-list{border-inline-end:0;border-bottom:1px solid var(--w-border);padding:16px 0}.w-editor-canvas{padding:16px 0;background:var(--w-bg)}.w-editor-inspector{padding:20px 0}.w-dialog-header,.w-dialog-body{padding:16px}.w-button{min-height:40px}.w-definition{grid-template-columns:80px minmax(0,1fr)}}
148
+ @media(max-width:700px){.w-page[data-main=true]{padding:calc(16px + var(--dsh-frame-top-clearance,0px)) 16px 0 max(16px,var(--dsh-frame-leading-clearance,0px))}.w-overlay{padding:8px}.w-dialog{width:100%}}
149
+ @media(prefers-reduced-motion:reduce){.dshw *,.dshw *:before{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
150
+ `;
151
+ return { Icon, Button, IconButton, Page, Tabs, Status, Empty, Split, Row, Search, Switch, Dialog, register, styles };
152
+ }
153
+
154
+ // Editable browser entry. Build with node scripts/build-client.mjs.
1
155
  // Browser source, distributed directly; the TypeScript build preserves this file.
2
- window.__ModuleLoader__.load({id:'dsh-code-security',factory:require=>{
3
- const React=require('react'),{useState,useEffect,useRef,useMemo}=React,h=React.createElement;
4
- var __FR_KEY = "dsh-first-run:dsh-code-security:v1";
5
- var __FR_TITLE = "代码安全 · 首次使用(4 步)";
6
- var __FR_STEPS = [{"title":"先全仓扫一次","body":"点「扫描」或让助手跑一次全仓扫描建立基线;第一次结果可能很多,属正常。"},{"title":"之后只看增量","body":"日常用「审 diff」,只报本次改动新增的问题,噪音低得多。"},{"title":"修完复验","body":"修完后点复验,插件会对比上次结果,告诉你哪些真的关掉了。"},{"title":"基线要谨慎","body":"「接受基线」会把当前问题标记为已知,之后不再拦截;只在确认无害时使用。"}];
7
- // ---- first-run guide (shared snippet) ----
8
- var __FR_STYLE_ID = 'dsh-first-run-css';
9
- var __FR_CSS = [
10
- '.fr-card{border:1px solid var(--dsw-border,rgba(0,0,0,.10));border-radius:12px;padding:14px 16px;margin:0 0 14px;background:linear-gradient(180deg,rgba(59,130,246,.06),rgba(59,130,246,.02))}',
11
- '.fr-head{display:flex;align-items:center;gap:8px;margin-bottom:6px}',
12
- '.fr-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-accent,#3b82f6)}',
13
- '.fr-title{font-weight:650;font-size:13.5px}',
14
- '.fr-count{margin-left:auto;font-size:11px;color:var(--dsw-text-weak,#6b7280);font-variant-numeric:tabular-nums}',
15
- '.fr-step{font-weight:600;font-size:13px;margin:2px 0 4px}',
16
- '.fr-body{color:var(--dsw-text-weak,#4b5563);font-size:12.5px;line-height:1.65}',
17
- '.fr-try{margin-top:8px;padding:7px 9px;border-radius:8px;background:rgba(59,130,246,.09);font-size:12px;display:flex;gap:8px}',
18
- '.fr-try code{font-family:ui-monospace,monospace;white-space:pre-wrap;word-break:break-word}',
19
- '.fr-bar{display:flex;gap:8px;align-items:center;margin-top:12px}',
20
- '.fr-progress{display:flex;gap:4px;margin-right:auto}',
21
- '.fr-seg{width:16px;height:3px;border-radius:2px;background:var(--dsw-border,rgba(0,0,0,.12))}',
22
- '.fr-seg.on{background:var(--dsw-accent,#3b82f6)}',
23
- '.fr-btn{border:1px solid var(--dsw-border,rgba(0,0,0,.14));background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:12px;cursor:pointer}',
24
- '.fr-btn:hover{background:rgba(0,0,0,.04)}',
25
- '.fr-btn.primary{background:var(--dsw-accent,#3b82f6);border-color:transparent;color:#fff}',
26
- '.fr-btn.ghost{border-color:transparent;color:var(--dsw-text-weak,#6b7280)}'
27
- ].join('\n');
28
- function __frEnsureCss(){ if (typeof document === 'undefined' || document.getElementById(__FR_STYLE_ID)) return; var el = document.createElement('style'); el.id = __FR_STYLE_ID; el.textContent = __FR_CSS; document.head.appendChild(el); }
29
- function __frRead(){ try { return localStorage.getItem(__FR_KEY) === 'done'; } catch (e) { return false; } }
30
- function __frWrite(){ try { localStorage.setItem(__FR_KEY, 'done'); } catch (e) {} }
31
- function __FirstRunCard(){
32
- var ds = React.useState(__frRead), done = ds[0], setDone = ds[1];
33
- var is = React.useState(0), index = is[0], setIndex = is[1];
34
- React.useEffect(function(){ __frEnsureCss(); }, []);
35
- if (done) return null;
36
- var step = __FR_STEPS[index];
37
- var last = index >= __FR_STEPS.length - 1;
38
- var close = function(){ __frWrite(); setDone(true); };
39
- var kids = [
40
- React.createElement('div', { className: 'fr-head', key: 'h' }, React.createElement('span', { className: 'fr-dot' }), React.createElement('span', { className: 'fr-title' }, __FR_TITLE), React.createElement('span', { className: 'fr-count' }, (index + 1) + ' / ' + __FR_STEPS.length)),
41
- React.createElement('div', { className: 'fr-step', key: 't' }, step.title),
42
- React.createElement('div', { className: 'fr-body', key: 'b' }, step.body)
43
- ];
44
- if (step.tryIt) kids.push(React.createElement('div', { className: 'fr-try', key: 'y' }, React.createElement('span', null, '可以直接说'), React.createElement('code', null, '「' + step.tryIt + '」')));
45
- var buttons = [React.createElement('div', { className: 'fr-progress', key: 'p' }, __FR_STEPS.map(function(_, i){ return React.createElement('span', { key: i, className: 'fr-seg' + (i <= index ? ' on' : '') }); }))];
46
- buttons.push(React.createElement('button', { className: 'fr-btn ghost', type: 'button', onClick: close, key: 's' }, '跳过'));
47
- if (index > 0) buttons.push(React.createElement('button', { className: 'fr-btn', type: 'button', key: 'prev', onClick: function(){ setIndex(index - 1); } }, '上一步'));
48
- buttons.push(React.createElement('button', { className: 'fr-btn primary', type: 'button', key: 'next', onClick: function(){ if (last) close(); else setIndex(index + 1); } }, last ? '开始使用' : '下一步'));
49
- kids.push(React.createElement('div', { className: 'fr-bar', key: 'bar' }, buttons));
50
- return React.createElement('div', { className: 'fr-card' }, kids);
156
+ window.__ModuleLoader__.load({ id: 'dsh-code-security', factory: require => {
157
+ const React = require('react'), { useState, useEffect, useRef, useMemo } = React, h = React.createElement;
158
+ const W = createWorkbenchUI(React);
159
+ function SecurityResults({ report, findings, fresh, filter, setFilter, limit, setLimit, busy, loading, download, onOpen, onCopy }) {
160
+ const [selectedId, setSelectedId] = useState(null);
161
+ const active = findings.find(finding => finding.id === selectedId) || findings[0];
162
+ const visible = findings.slice(0, limit);
163
+ if (loading && !report) return h(W.Empty, { title: t('正在载入…', 'Loading…'), icon: 'shield' });
164
+ if (!report?.hasScan) return h(W.Empty, { title: t('尚未检查此项目', 'This project has not been checked'), icon: 'shield' }, t('选择上方的检查方式开始。内置规则无需额外安装。', 'Choose a check above. Built-in rules need no extra installation.'));
165
+ return h(React.Fragment, null,
166
+ h(W.Status, null, h('strong', null, report.complete === false ? t('检查未完成', 'Check incomplete') : report.passed ? t('未发现达到阈值的新增问题', 'No new findings reached the threshold') : t('有问题需要核对', 'Findings need review')),
167
+ ' · ' + t('新增 ', 'New ') + Object.values(report.newCounts || {}).reduce((a, b) => a + Number(b), 0) + ' · ' + t('已接受 ', 'Accepted ') + Object.values(report.acceptedCounts || {}).reduce((a, b) => a + Number(b), 0)),
168
+ h(W.Split, { listWidth: 370, selected: selectedId && findings.some(finding => finding.id === selectedId) ? selectedId : null, onBack: () => setSelectedId(null), backLabel: t('返回问题列表', 'Back to findings'),
169
+ list: h('div', null,
170
+ h('div', { className: 'w-master-toolbar' }, h('select', { 'aria-label': t('问题筛选', 'Filter findings'), value: filter, onChange: event => { setFilter(event.target.value); setLimit(50); setSelectedId(null); } }, h('option', { value: 'all' }, t('全部问题', 'All findings')), h('option', { value: 'new' }, t('仅新增', 'New only')), h('option', { value: 'high' }, t('高风险', 'High risk')))),
171
+ visible.map(finding => h(W.Row, { key: finding.id, title: describeFinding(finding).title, active: finding.id === active?.id, meta: finding.file + ':' + finding.line, description: describeFinding(finding).detail, status: risk(finding.severity), onClick: () => setSelectedId(finding.id), className: 'security-list-' + finding.severity })),
172
+ !findings.length ? h(W.Empty, { title: t('此筛选下没有问题。', 'No findings match this filter.'), icon: 'shield' }) : null,
173
+ findings.length > limit ? h(W.Button, { className: 'security-more', onClick: () => setLimit(n => n + 50) }, t('显示更多', 'Show more')) : null,
174
+ (report.findingsTotal ?? 0) > 200 ? h('p', { className: 'w-muted security-list-note' }, t('面板最多展示 200 项,完整结果请导出报告。', 'The panel shows up to 200 findings. Export the full report for all results.')) : null),
175
+ detail: h(React.Fragment, null,
176
+ active ? h(FindingCard, { key: active.id, finding: active, fresh: fresh.has(active.id), onOpen, onCopy }) : h(W.Empty, { title: t('没有匹配的问题', 'No matching findings'), icon: 'shield' }),
177
+ h('details', { className: 'w-section' }, h('summary', null, t('检查覆盖范围', 'Check coverage')), h(Coverage, { rows: report.coverage }), report.time ? h('p', { className: 'w-muted' }, new Date(report.time).toLocaleString(english() ? 'en-US' : 'zh-CN')) : null),
178
+ h('div', { className: 'w-detail-footer' }, h(W.Button, { icon: 'download', disabled: busy, onClick: () => download('sarif') }, t('下载 SARIF', 'Download SARIF')), h(W.Button, { icon: 'download', disabled: busy, onClick: () => download('markdown') }, t('下载 Markdown', 'Download Markdown')))) }));
51
179
  }
52
- function __withFirstRun(Component){ return function FirstRunWrapped(props){ return React.createElement(React.Fragment, null, React.createElement(__FirstRunCard, null), React.createElement(Component, props)); }; }
53
- // ---- end first-run guide ----
54
180
 
55
- let locale,sidebar;
56
- const english=()=>{try{return String(locale?.getSnapshot?.().active||'').startsWith('en')}catch{return false}};
57
- const t=(zh,en)=>english()?en:zh;
58
- const statuses={completed:['执行完成','Completed'],partial:['部分完成','Partial'],failed:['执行失败','Failed'],'not-installed':['未安装','Not installed'],'timed-out':['超时','Timed out'],'not-run':['未运行','Not run'],running:['正在检查…','Checking…'],cancelled:['已取消','Cancelled']};
59
- const reasons={'executable-missing':['找不到程序,请安装或设置路径。','Executable missing. Install it or set its path.'],'version-check-failed':['无法确认工具版本。','Could not verify the tool version.'],'unsupported-version':['此版本暂不支持,请查看安装说明。','Unsupported version. Check the installation guide.'],'database-unavailable':['离线漏洞库不可用;请先下载数据库或选择联网审计。','Offline database unavailable. Download it or choose an online audit.'],'invalid-or-truncated-json':['结果损坏或超过输出上限,请缩小范围重试。','The result is invalid or too large. Retry with a smaller scope.'],'incomplete-coverage':['部分文件或结果未能检查,不能判定通过。','Some files or results were not checked. This is not a pass.'],'no-eligible-files':['没有适合此引擎的文件。','No eligible files for this engine.'],'no-lockfiles':['未找到支持的锁文件,请先生成依赖锁文件。','No supported lockfile. Generate a resolved lockfile first.'],'no-resolved-packages':['未提取到实际依赖版本。','No resolved dependency versions were extracted.'],'execution-failed':['工具未完成,请检查安装与网络后重试。','The tool did not finish. Check its installation and network.'],'execution-timeout':['工具执行超时,可缩小范围后重试。','The tool timed out. Retry with a smaller scope.'],'snapshot-size-limit':['超过检查体积上限,本次未完成。','The scan size limit was reached. This check is incomplete.']};
60
- const status=s=>{const words=statuses[s];return words?t(...words):s};
61
- const reason=s=>reasons[s]?t(...reasons[s]):s;
62
- const message=value=>{const s=String(value||'');if(s.includes(' / ')){const parts=s.split(' / ');return english()?parts.at(-1):parts[0]}return english()&&/[\u3400-\u9fff]/.test(s)?'The operation failed. Refresh and try again.':s};
63
- const names={builtin:'DSH',betterleaks:'Betterleaks',semgrep:'Semgrep CE',osv:'OSV-Scanner'};
64
- const risk=value=>({critical:t('严重','Critical'),high:t('高','High'),medium:t('中','Medium'),low:t('低','Low')}[value]||value);
65
- const links={betterleaks:'https://github.com/betterleaks/betterleaks/releases/tag/v1.9.0',osv:'https://github.com/google/osv-scanner/releases/tag/v2.7.0',semgrep:'https://docs.semgrep.dev/getting-started/quickstart-ce'};
66
- async function action(body,signal){const response=await fetch('api/dsh-code-security/workbench',{method:'POST',credentials:'same-origin',cache:'no-store',signal,headers:{'content-type':'application/json','x-dsh-security-action':'1'},body:JSON.stringify(body)});let value;try{value=await response.json()}catch{throw new Error(t('无法读取服务响应,请检查插件是否加载。','Could not read the response. Check that the plugin is loaded.'))}if(!response.ok||value.ok===false)throw new Error(message(value.message||t('操作未完成','The operation did not complete.')));return value}
67
- function Coverage({rows}){return rows?.length?h('div',{className:'secw-coverage'},rows.map(row=>h('div',{key:row.engine,className:'secw-engine'},h('strong',null,names[row.engine]||row.engine),h('span',{className:'secw-badge', 'data-status':row.status},status(row.status)),row.version?h('small',null,row.version):null,row.reason?h('p',{className:'secw-muted'},reason(row.reason)):null))):null}
68
- function FindingCard({finding,fresh,onOpen,onCopy}){
69
- const category={'0':['注入与动态执行','Injection or dynamic execution'],'1':['反序列化','Deserialization'],'2':['加密与密钥','Cryptography'],'3':['凭据泄露','Credential exposure'],'4':['配置与权限','Configuration and permissions'],'5':['信息泄露','Information exposure'],'6':['路径与网络','Paths and network']};
70
- const flows={'CWE-78':[['外部输入进入命令执行','Input reaches a shell command'],['请求参数传入了 shell 命令,请核对输入处理并优先使用参数数组。','Request input reaches a shell command. Review input handling and prefer argument arrays.']],'CWE-79':[['外部输入进入 HTML 写入点','Input reaches an HTML sink'],['发现请求输入到 HTML 写入点的数据流,请核对净化处理与实际页面上下文。','Request input reaches HTML insertion. Review sanitization and the actual page context.']],'CWE-89':[['外部输入进入 SQL 文本','Input reaches SQL text'],['请求输入进入 SQL 语句文本,请核对 SQL 构造方式并使用参数化值。','Request input reaches SQL text. Review query construction and use parameterized values.']]};
71
- const flow=finding.evidence==='dataflow'?flows[finding.cwe]:null;
72
- const title=flow?t(...flow[0]):finding.engine==='betterleaks'?t('疑似凭据泄露','Potential credential exposure'):english()&&/[\u3400-\u9fff]/.test(finding.title)?t(...(category[finding.ruleId?.slice(4,5)]||['模式提示','Code pattern'])):finding.title;
73
- const detail=flow?t(...flow[1]):english()&&/[\u3400-\u9fff]/.test(finding.message)?'Review the recorded code pattern; it does not by itself prove exploitability.':finding.message;
74
- return h('article',{className:'secw-finding','data-severity':finding.severity},h('div',{className:'secw-row'},h('strong',null,title),h('span',{className:'secw-badge'},risk(finding.severity)),h('span',{className:'secw-badge'},fresh?t('新增','New'):t('已接受','Accepted'))),h('p',{className:'secw-file'},finding.file+':'+finding.line),h('p',{className:'secw-muted'},finding.ruleId+' · '+finding.cwe+' · '+t('置信度:','Confidence: ')+risk(finding.confidence)+' · '+(finding.evidence==='dataflow'?t('数据流证据','Data-flow evidence'):t('模式提示','Pattern finding'))),h('p',{className:'secw-muted'},t('来源:','Sources: ')+(finding.sources||[finding.engine||'builtin']).map(engine=>engine==='builtin'?t('内置规则','Built-in rules'):names[engine]||engine).join(', ')),h('p',null,detail),h('details',null,h('summary',null,t('查看脱敏证据','Redacted evidence')),h('pre',null,finding.snippet)),h('div',{className:'secw-tools'},h('button',{onClick:()=>onOpen(finding)},t('打开文件','Open file')),h('button',{onClick:()=>onCopy(finding)},t('复制位置','Copy location'))));
75
- }
76
- function Workbench(){
77
- const[,repaint]=useState(0),[catalog,setCatalog]=useState([]),[id,setId]=useState(''),[data,setData]=useState(null),[settings,setSettings]=useState({engines:['builtin'],enginePaths:{}}),[autoCheck,setAutoCheck]=useState(false),[dirty,setDirty]=useState(false),[job,setJob]=useState(null),[result,setResult]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[loading,setLoading]=useState(true),[mode,setMode]=useState('none'),[filter,setFilter]=useState('all'),[limit,setLimit]=useState(50);
78
- const generation=useRef(0),mounted=useRef(true),requests=useRef(new Set());
79
- function request(body){const ctl=new AbortController();requests.current.add(ctl);return action(body,ctl.signal).finally(()=>requests.current.delete(ctl))}
80
- async function open(selected,keepResult=false){const seq=++generation.current;if(!keepResult)setError('');setLoading(true);try{const value=await request({operation:'open',id:selected});if(!mounted.current||seq!==generation.current)return;setId(selected);setData(value);setSettings(value.settings);setAutoCheck(value.workspace.autoCheck);setDirty(false);setLimit(50);if(!keepResult)setResult(null);setJob(value.activeJob||null)}catch(e){if(mounted.current&&seq===generation.current)setError(message(e.message))}finally{if(mounted.current&&seq===generation.current)setLoading(false)}}
81
- useEffect(()=>{mounted.current=true;const off=locale?.subscribe?.(()=>repaint(n=>n+1));request({operation:'list'}).then(value=>{if(!mounted.current)return;setCatalog(value.workspaces);if(value.workspaces[0])void open(value.workspaces[0].id);else setLoading(false)}).catch(e=>{if(mounted.current){setError(message(e.message));setLoading(false)}});return()=>{mounted.current=false;generation.current++;requests.current.forEach(c=>c.abort());off?.()}},[]);
82
- useEffect(()=>{if(!job||job.status!=='running'||!id)return;let ended=false,timer;const selected=id;async function poll(){try{const value=await request({operation:'job',id:selected,job:job.id});if(ended||!mounted.current)return;if(value.job.status==='running')timer=setTimeout(poll,800);else{setJob(value.job);setResult(value.job.result||null);if(value.job.error)setError(message(value.job.error));void open(selected,true)}}catch(e){if(!ended&&mounted.current){setError(message(e.message));setJob(null)}}}timer=setTimeout(poll,300);return()=>{ended=true;clearTimeout(timer)}},[job?.id,job?.status,id]);
83
- const busy=job?.status==='running',report=data?.report;
84
- const fresh=useMemo(()=>new Set((report?.newFindings||[]).map(f=>f.id)),[report?.newFindings]);
85
- const findings=useMemo(()=>{const list=report?.findings||[];return filter==='new'?list.filter(f=>fresh.has(f.id)):filter==='high'?list.filter(f=>['critical','high'].includes(f.severity)):list},[report?.findings,fresh,filter]);
86
- async function run(name){setError('');setNotice('');setResult(null);try{const value=await request({operation:'start',id,action:name,...(name==='deps'?{audit:mode}:{})});if(mounted.current)setJob(value.job)}catch(e){if(mounted.current)setError(message(e.message))}}
87
- async function save(){setError('');try{const paths=Object.fromEntries(Object.entries(settings.enginePaths).map(([k,v])=>[k,v.trim()]).filter(([,v])=>v));const value=await request({operation:'settings',id,engines:settings.engines,enginePaths:paths,autoCheck,...(settings.osvDatabaseDir?.trim()?{osvDatabaseDir:settings.osvDatabaseDir.trim()}:{})});if(!mounted.current)return;setData(value);setSettings(value.settings);setDirty(false);setNotice(t('设置已保存。','Settings saved.'))}catch(e){if(mounted.current)setError(message(e.message))}}
88
- async function download(format){setError('');try{const value=await request({operation:'export',id,format});const url=URL.createObjectURL(new Blob([value.text],{type:format==='sarif'?'application/sarif+json':'text/markdown;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=value.filename;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);setNotice(t('报告已请求下载。','Report download requested.'))}catch(e){setError(message(e.message))}}
89
- async function location(finding,copy){setError('');try{const value=await request({operation:'location',id,finding:finding.id});if(copy){await navigator.clipboard.writeText(value.path+':'+value.line);setNotice(t('文件位置已复制。','File location copied.'));return}if(!sidebar?.openResource||!value.sessionId||sidebar.mounted?.getSnapshot?.()!==value.sessionId)throw new Error(t('请先打开对应聊天,或复制文件位置。','Open the corresponding chat first, or copy the file location.'));const file=value.path.replace(/\\/g,'/').split('/').map(s=>encodeURIComponent(s).replace(/%3A/ig,':')).join('/');sidebar.openResource('dsh-resource://file/session/'+encodeURIComponent(value.sessionId)+'/'+file,{params:{line:value.line}});document.querySelector('[role=dialog]:has(.secw) button[aria-label="关闭"], [role=dialog]:has(.secw) button[aria-label="Close"]')?.click()}catch(e){if(mounted.current)setError(message(e.message))}}
90
- const disabled=busy||loading||dirty;
91
- return h('section',{className:'secw'},h('style',null,css),h('h2',null,t('代码安全检查','Code security')),h('p',{className:'secw-muted'},t('查看具体证据、检查新增改动,并跟踪修复结果。默认使用本地规则。','Review evidence, check new changes and track fixes. Local rules are the default.')),
92
- error?h('div',{className:'secw-error',role:'alert'},error):null,notice?h('div',{className:'secw-notice',role:'status'},notice):null,
93
- !catalog.length&&!loading?h('div',{className:'secw-card'},h('h3',null,t('还没有检查过的项目','No checked projects yet')),h('p',null,t('在项目聊天中说:“检查这个项目的安全问题。”项目随后会出现在这里。','In a project chat, ask: “Check this project for security issues.” It will then appear here.')),h('button',{onClick:()=>{setLoading(true);request({operation:'list'}).then(v=>{setCatalog(v.workspaces);if(v.workspaces[0])void open(v.workspaces[0].id);else setLoading(false)}).catch(e=>{setError(message(e.message));setLoading(false)})}},t('刷新项目','Refresh projects'))):null,
94
- catalog.length?h(React.Fragment,null,h('label',{htmlFor:'secw-project'},t('项目','Project')),h('select',{id:'secw-project',value:id,onChange:e=>void open(e.target.value)},catalog.map(w=>h('option',{key:w.id,value:w.id},w.label+' — '+w.root))),
95
- h('div',{className:'secw-tools'},h('button',{className:'secw-primary',disabled,onClick:()=>run('scan')},t('检查代码','Scan code')),h('button',{disabled,onClick:()=>run('diff')},t('检查改动','Check changes')),h('button',{disabled:disabled||!report?.hasScan,onClick:()=>run('verify')},t('复查修复','Recheck fixes')),h('button',{disabled:busy||loading,onClick:()=>open(id)},t('刷新结果','Refresh results')),busy?h('button',{onClick:()=>request({operation:'cancel',id,job:job.id}).catch(e=>setError(message(e.message)))},t('取消检查','Cancel check')):null),
96
- busy?h('div',{className:'secw-notice',role:'status'},t('正在检查,可以关闭面板,稍后回来查看结果。','Checking. You can close this panel and return for the result.')):null,
97
- loading?h('p',{role:'status'},t('正在载入…','Loading…')):report?.hasScan?h('div',{className:'secw-card'},h('h3',null,report.complete===false?t('检查未完成','Check incomplete'):report.passed?t('未发现达到阈值的新增问题','No new findings reached the threshold'):t('有问题需要核对','Findings need review')),h('p',{className:'secw-muted'},t('新增:','New: ')+Object.values(report.newCounts||{}).reduce((a,b)=>a+Number(b),0)+' · '+t('已接受:','Accepted: ')+Object.values(report.acceptedCounts||{}).reduce((a,b)=>a+Number(b),0)+' · '+(report.time?new Date(report.time).toLocaleString(english()?'en-US':'zh-CN'):'')),h(Coverage,{rows:report.coverage})):h('p',{className:'secw-muted'},t('尚未检查此项目,选择上方的检查方式开始。','This project has not been checked. Choose a check above to start.')),
98
- result?.coverage&&'closedCount'in result&&result.complete===false?h(Coverage,{rows:Array.isArray(result.coverage)?result.coverage:[result.coverage]}):null,
99
- result?.configurationError?h('div',{className:'secw-error',role:'alert'},message(result.configurationError)):null,
100
- result&&'closedCount'in result?h('div',{className:'secw-notice'},result.complete===false?t('复查未完成,未判定任何问题已修复。','Recheck incomplete. No finding was marked fixed.'):t('本次复查:关闭 ','This recheck: closed ')+result.closedCount+' · '+t('仍存在 ','remaining ')+result.remainingCount+' · '+t('新增 ','new ')+result.freshCount):null,
101
- h('details',{className:'secw-card'},h('summary',null,t('增强工具与自动检查','Enhanced tools and automatic checks')),h('p',{className:'secw-muted'},t('增强工具可选。未安装时仍可用内置规则;已选择但未完成的引擎不会算作通过。','Enhanced tools are optional. Built-in checks remain available; an incomplete selected engine is never counted as a pass.')),
102
- h('fieldset',{disabled:busy},h('legend',null,t('代码检查引擎','Code scanning engines')),['builtin','betterleaks','semgrep'].map(engine=>h('label',{key:engine,className:'secw-choice'},h('input',{type:'checkbox',checked:settings.engines.includes(engine),onChange:e=>{setSettings(s=>({...s,engines:e.target.checked?[...s.engines,engine]:s.engines.filter(x=>x!==engine)}));setDirty(true)}}),names[engine]))),
103
- ['betterleaks','semgrep','osv'].map(engine=>h('div',{key:engine},h('label',{htmlFor:'secw-path-'+engine},names[engine]+t(' 程序路径(可选)',' executable path (optional)')),h('input',{id:'secw-path-'+engine,value:settings.enginePaths[engine]||'',disabled:busy,placeholder:t('已加入 PATH 时可留空','Leave empty when available on PATH'),onChange:e=>{const value=e.target.value;setSettings(s=>({...s,enginePaths:{...s.enginePaths,[engine]:value}}));setDirty(true)}}),h('a',{href:links[engine],target:'_blank',rel:'noopener noreferrer'},t('官方安装说明','Official installation')))),
104
- h('label',{htmlFor:'secw-database'},t('离线漏洞库缓存目录(可选)','Offline database cache directory (optional)')),h('input',{id:'secw-database',value:settings.osvDatabaseDir||'',disabled:busy,placeholder:t('留空使用 OSV 默认缓存','Leave empty for the default OSV cache'),onChange:e=>{setSettings(s=>({...s,osvDatabaseDir:e.target.value}));setDirty(true)}}),h('p',{className:'secw-muted'},t('OSV 2.7 使用缓存目录/osv-scalibr/npm/all.zip;数据库由用户预先下载。','OSV 2.7 uses cache/osv-scalibr/npm/all.zip. Download the database beforehand.')),h('a',{href:'https://google.github.io/osv-scanner/usage/offline-mode/',target:'_blank',rel:'noopener noreferrer'},t('离线数据库说明','Offline database guide')),
105
- h('label',{className:'secw-choice'},h('input',{type:'checkbox',checked:autoCheck,disabled:busy,onChange:e=>{setAutoCheck(e.target.checked);setDirty(true)}}),t('每轮完成后检查改动','Check changes after each turn')),h('p',{className:'secw-muted'},t('自动检查仅用内置规则,包含 Git 未跟踪的新文件。结果单独保存;新增高风险模式会在后续对话中提醒。','Automatic checks use built-in rules and include untracked Git files. Results are stored separately; new high-risk patterns are surfaced in later conversations.')),data?.automatic?h('p',{className:'secw-muted'},data.automatic.problem?t('最近自动检查未完成,请手动检查改动并核对 Git 工作区。','The last automatic check did not complete. Check changes manually and verify the Git workspace.'):t('最近自动检查:','Last automatic check: ')+new Date(data.automatic.time).toLocaleString(english()?'en-US':'zh-CN')+' · '+t('高风险提示 ','High-risk patterns ')+data.automatic.highRisk):null,
106
- h('div',{className:'secw-tools'},h('button',{disabled:busy||!dirty,onClick:save},t('保存设置','Save settings')),h('button',{disabled:busy||!dirty,onClick:()=>{setSettings(data.settings);setAutoCheck(data.workspace.autoCheck);setDirty(false)}},t('取消修改','Cancel edits')),h('button',{disabled,onClick:()=>run('health')},t('检查工具','Check tools'))),dirty?h('p',{className:'secw-muted'},t('设置尚未保存,请保存后再检查。','Save your settings before starting a check.')):null,result?.engines?h(Coverage,{rows:result.engines}):null,result?.database?h('div',null,h('p',{className:'secw-file'},result.database.directory),result.database.available?result.database.databases.map(db=>h('p',{key:db.ecosystem,className:'secw-muted'},db.ecosystem+' · '+t('缓存文件修改时间:','Cache file modified: ')+new Date(db.fileModifiedAt).toLocaleString(english()?'en-US':'zh-CN'))):h('p',{className:'secw-muted'},t('未找到离线漏洞库。','No offline database was found.')),h('p',{className:'secw-muted'},t('文件时间不代表库中每条漏洞的更新日期。','A file timestamp does not establish the freshness of every advisory.'))):null),
107
- h('details',{className:'secw-card'},h('summary',null,t('依赖与已知漏洞','Dependencies and known vulnerabilities')),h('label',{htmlFor:'secw-audit'},t('检查方式','Check mode')),h('select',{id:'secw-audit',value:mode,disabled:busy,onChange:e=>setMode(e.target.value)},h('option',{value:'none'},t('仅列出依赖(离线)','Dependency inventory (offline)')),h('option',{value:'offline'},t('已知漏洞(本地数据库)','Known vulnerabilities (local database)')),h('option',{value:'online'},t('已知漏洞(联网)','Known vulnerabilities (online)'))),h('p',{className:'secw-muted'},mode==='online'?t('联网审计会向 OSV 查询包名、版本等依赖信息;不上传源码。','Online auditing queries OSV with package names, versions and dependency metadata; source code is not uploaded.'):t('清单检查只看依赖声明,不代表不存在已知漏洞。离线漏洞审计需要预先下载数据库。','An inventory is not a vulnerability audit. Offline auditing requires a previously downloaded database.')),h('button',{disabled,onClick:()=>run('deps')},t('检查依赖','Check dependencies')),
108
- result?.dependencies?h('div',null,h('p',null,t('发现依赖:','Dependencies: ')+(result.dependenciesTotal??result.dependencies.length)),...(result.warnings||[]).map((warning,i)=>h('p',{key:i,className:'secw-error'},message(warning))),result.audit?h(React.Fragment,null,h(Coverage,{rows:[result.audit.coverage]}),h('p',null,t('已知漏洞:','Known vulnerabilities: ')+(result.audit.vulnerabilitiesTotal??result.audit.vulnerabilities.length)),result.audit.vulnerabilities.map((v,i)=>h('article',{key:v.id+v.package+i,className:'secw-finding'},h('strong',null,v.package+'@'+v.version),h('p',null,v.summary),h('p',null,t('修复版本:','Fixed versions: ')+(v.fixedVersions?.join(', ')||t('暂无','Not available'))),h('a',{href:v.url,target:'_blank',rel:'noopener noreferrer'},v.id)))):null):null),
109
- report?.hasScan?h(React.Fragment,null,h('div',{className:'secw-tools'},h('label',{htmlFor:'secw-filter'},t('问题筛选','Filter findings')),h('select',{id:'secw-filter',value:filter,onChange:e=>{setFilter(e.target.value);setLimit(50)}},h('option',{value:'all'},t('全部','All')),h('option',{value:'new'},t('仅新增','New only')),h('option',{value:'high'},t('高风险','High risk'))),h('button',{disabled:busy,onClick:()=>download('sarif')},t('下载 SARIF','Download SARIF')),h('button',{disabled:busy,onClick:()=>download('markdown')},t('下载 Markdown','Download Markdown'))),findings.length?findings.slice(0,limit).map(f=>h(FindingCard,{key:f.id,finding:f,fresh:fresh.has(f.id),onOpen:f=>location(f,false),onCopy:f=>location(f,true)})):h('p',{className:'secw-muted'},t('此筛选下没有问题。','No findings match this filter.')),findings.length>limit?h('button',{onClick:()=>setLimit(n=>n+50)},t('显示更多','Show more')):null,(report.findingsTotal??0)>200?h('p',{className:'secw-muted'},t('面板最多展示 200 项,完整结果请导出报告。','The panel shows up to 200 findings. Export the full report for all results.')):null):null):null);
110
- }
111
- const css=`.secw{max-width:1060px;min-width:0;margin:auto;font:14px/1.6 system-ui,sans-serif;color:var(--dsw-alias-label-primary,#253047)}.secw *{box-sizing:border-box}.secw h2{font-size:22px;margin:0}.secw h3{font-size:17px;margin:0 0 8px}.secw p{margin:7px 0}.secw label{display:block;margin:8px 0 4px}.secw button,.secw select,.secw input:not([type=checkbox]){font:inherit;color:inherit;border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff);padding:8px 10px;max-width:100%}.secw input:not([type=checkbox]),.secw select{width:100%;min-width:0}.secw button{cursor:pointer;white-space:normal}.secw button:disabled{opacity:.5;cursor:default}.secw .secw-primary{background:#245ee9;color:#fff;border-color:#245ee9}.secw .secw-tools,.secw .secw-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:12px 0}.secw .secw-tools select{width:auto;max-width:100%}.secw .secw-muted{color:var(--dsw-alias-label-secondary,#64718a);font-size:13px}.secw .secw-card,.secw .secw-finding{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:11px;padding:14px;margin:12px 0;overflow-wrap:anywhere;background:var(--dsw-alias-bg-layer-1,#fff)}.secw .secw-finding[data-severity=critical],.secw .secw-finding[data-severity=high]{border-left:4px solid #c04836}.secw .secw-coverage{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;margin:10px 0}.secw .secw-engine{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:9px;padding:10px;overflow-wrap:anywhere}.secw .secw-engine strong{margin-right:8px}.secw .secw-engine small{display:block;color:var(--dsw-alias-label-secondary,#64718a)}.secw .secw-badge{display:inline-block;background:var(--dsw-alias-bg-layer-2,#f0f3f8);border-radius:5px;padding:1px 6px;font-size:12px}.secw .secw-badge[data-status=failed],.secw .secw-badge[data-status=partial]{color:#b33928}.secw .secw-error{background:#fff0ed;color:#973321;padding:10px;border-radius:8px;overflow-wrap:anywhere}.secw .secw-notice{background:#edf4ff;color:#17468c;padding:10px;border-radius:8px}.secw .secw-choice{display:flex;align-items:center;gap:8px}.secw fieldset{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:8px;margin:12px 0}.secw summary{cursor:pointer;font-weight:600}.secw pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:160px;overflow:auto;background:var(--dsw-alias-bg-layer-2,#f3f5f9);padding:10px;border-radius:6px}.secw .secw-file{font-family:ui-monospace,monospace;overflow-wrap:anywhere}.secw a{color:#245ee9}.secw :focus-visible{outline:3px solid #78a8ff;outline-offset:2px}[role=dialog]:has(.secw){width:min(1200px,calc(100vw - 48px))!important;max-width:1200px!important}@media(max-width:700px){[role=dialog]:has(.secw){width:100%!important;max-width:100%!important;flex-direction:column!important}[role=dialog]:has(.secw) nav{width:100%!important;flex-basis:auto!important;max-height:140px;overflow:auto;border-right:0}[role=dialog]:has(.secw) nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;flex-shrink:0;gap:4px}[role=dialog]:has(.secw)>nav+div{min-width:0;width:100%}[role=dialog]:has(.secw)>nav+div>div:has(.secw){padding:12px!important;min-width:0}.secw .secw-coverage{grid-template-columns:1fr}}`;
112
- function apply(ctx){try{locale=ctx.get?.('locale')||ctx.locale}catch{locale=ctx.locale}try{sidebar=ctx.get?.('sidebarRight')||ctx.sidebarRight}catch{}ctx.slots.inject('settings.section',()=>ctx.slots.register({name:'settings.section',id:'dsh-code-security',order:59,label:()=>t('代码安全','Code security'),inject:()=>({})},__withFirstRun(Workbench)))}
113
- return{inject:['slots','locale'],apply};
114
- }});
181
+ const SECURITY_WORKBENCH_CSS = `
182
+ .security-workbench.secw{max-width:none;margin:0}.security-workbench .security-project{padding-bottom:14px;max-width:460px}.security-workbench .security-project label{margin:0 0 5px}.security-workbench .security-project select{width:100%}.security-workbench .secw-finding-detail{display:flex;flex-direction:column;gap:16px}.security-workbench .secw-finding-detail .w-detail-title{margin:0!important}.security-workbench .secw-finding-detail pre{max-height:300px;margin:0;font-size:13px;background:var(--w-soft);border:1px solid var(--w-border)}.security-workbench .secw-finding-detail .secw-tools{margin:0}.security-workbench .secw-finding-detail .secw-file{color:var(--w-muted);font-size:13px}.security-workbench .secw-finding-detail .secw-badge{background:var(--w-soft);color:var(--w-muted)}.security-workbench .w-row .w-tag{font-weight:550}.security-workbench .security-list-critical .w-tag,.security-workbench .security-list-high .w-tag{color:var(--dsw-alias-state-error-primary,#af3737)}.security-workbench .security-list-medium .w-tag{color:var(--dsw-alias-state-warn-primary,#946400)}.security-workbench .secw-coverage{display:flex;flex-direction:column;gap:0;margin:0}.security-workbench .secw-engine{border:0;border-bottom:1px solid var(--w-border);border-radius:0;padding:12px 0;background:transparent}.security-workbench .secw-engine:last-child{border:0}.security-workbench .secw-engine strong{font-size:13px}.security-workbench .secw-engine small,.security-workbench .secw-muted{color:var(--w-muted);font-size:12px}.security-workbench .secw-engine p{margin-bottom:0}.security-workbench .secw-badge[data-status=failed],.security-workbench .secw-badge[data-status=partial],.security-workbench .secw-badge[data-status=not-installed],.security-workbench .secw-badge[data-status=timed-out]{color:var(--dsw-alias-state-error-primary,#af3737)}.security-workbench .security-settings{max-width:800px;width:100%;padding:24px 0}.security-workbench .security-settings h3{margin-bottom:16px}.security-workbench .security-settings label{margin:18px 0 6px}.security-workbench .security-settings p{margin-top:10px}.security-workbench .security-settings fieldset{margin:20px 0}.security-workbench .secw-tools>button{min-height:36px;font-size:14px;border-radius:8px;border:1px solid var(--w-border);background:var(--w-bg);color:var(--w-text);padding:8px 12px}.security-workbench .security-settings .secw-tools>button:first-child{background:var(--w-primary);color:var(--w-on-primary);border-color:var(--w-primary)}.security-workbench .security-list-note{padding:16px}.security-workbench .security-more{margin:16px}.security-workbench .secw-error{background:var(--w-soft);color:var(--dsw-alias-state-error-primary,#af3737)}.security-workbench .secw-notice{background:var(--w-soft);color:var(--w-text)}.security-workbench .security-settings .secw-finding{border:0;border-bottom:1px solid var(--w-border);border-radius:0;padding:16px 0}.security-workbench .w-master-toolbar select{width:100%}
183
+ `;
184
+
185
+ let securityHostContext;
186
+ let locale, sidebar;
187
+ const english = () => { try {
188
+ return String(locale?.getSnapshot?.().active || '').startsWith('en');
189
+ }
190
+ catch {
191
+ return false;
192
+ } };
193
+ const t = (zh, en) => english() ? en : zh;
194
+ const statuses = { completed: ['执行完成', 'Completed'], partial: ['部分完成', 'Partial'], failed: ['执行失败', 'Failed'], 'not-installed': ['未安装', 'Not installed'], 'timed-out': ['超时', 'Timed out'], 'not-run': ['未运行', 'Not run'], running: ['正在检查…', 'Checking…'], cancelled: ['已取消', 'Cancelled'] };
195
+ const reasons = { 'executable-missing': ['找不到程序,请安装或设置路径。', 'Executable missing. Install it or set its path.'], 'version-check-failed': ['无法确认工具版本。', 'Could not verify the tool version.'], 'unsupported-version': ['此版本暂不支持,请查看安装说明。', 'Unsupported version. Check the installation guide.'], 'database-unavailable': ['离线漏洞库不可用;请先下载数据库或选择联网审计。', 'Offline database unavailable. Download it or choose an online audit.'], 'invalid-or-truncated-json': ['结果损坏或超过输出上限,请缩小范围重试。', 'The result is invalid or too large. Retry with a smaller scope.'], 'incomplete-coverage': ['部分文件或结果未能检查,不能判定通过。', 'Some files or results were not checked. This is not a pass.'], 'no-eligible-files': ['没有适合此引擎的文件。', 'No eligible files for this engine.'], 'no-lockfiles': ['未找到支持的锁文件,请先生成依赖锁文件。', 'No supported lockfile. Generate a resolved lockfile first.'], 'no-resolved-packages': ['未提取到实际依赖版本。', 'No resolved dependency versions were extracted.'], 'execution-failed': ['工具未完成,请检查安装与网络后重试。', 'The tool did not finish. Check its installation and network.'], 'execution-timeout': ['工具执行超时,可缩小范围后重试。', 'The tool timed out. Retry with a smaller scope.'], 'snapshot-size-limit': ['超过检查体积上限,本次未完成。', 'The scan size limit was reached. This check is incomplete.'] };
196
+ const status = s => { const words = statuses[s]; return words ? t(...words) : s; };
197
+ const reason = s => reasons[s] ? t(...reasons[s]) : s;
198
+ const message = value => { const s = String(value || ''); if (s.includes(' / ')) {
199
+ const parts = s.split(' / ');
200
+ return english() ? parts.at(-1) : parts[0];
201
+ } return english() && /[\u3400-\u9fff]/.test(s) ? 'The operation failed. Refresh and try again.' : s; };
202
+ const names = { get builtin(){return t('内置规则','Built-in rules')}, betterleaks: 'Betterleaks', semgrep: 'Semgrep CE', osv: 'OSV-Scanner' };
203
+ const risk = value => ({ critical: t('严重', 'Critical'), high: t('高', 'High'), medium: t('中', 'Medium'), low: t('低', 'Low') }[value] || value);
204
+ const links = { betterleaks: 'https://github.com/betterleaks/betterleaks/releases/tag/v1.9.0', osv: 'https://github.com/google/osv-scanner/releases/tag/v2.7.0', semgrep: 'https://docs.semgrep.dev/getting-started/quickstart-ce' };
205
+ async function action(body, signal) { const response = await fetch('api/dsh-code-security/workbench', { method: 'POST', credentials: 'same-origin', cache: 'no-store', signal, headers: { 'content-type': 'application/json', 'x-dsh-security-action': '1' }, body: JSON.stringify(body) }); let value; try {
206
+ value = await response.json();
207
+ }
208
+ catch {
209
+ throw new Error(t('无法读取服务响应,请检查插件是否加载。', 'Could not read the response. Check that the plugin is loaded.'));
210
+ } if (!response.ok || value.ok === false)
211
+ throw new Error(message(value.message || t('操作未完成', 'The operation did not complete.'))); return value; }
212
+ function Coverage({ rows }) { return rows?.length ? h('div', { className: 'secw-coverage' }, rows.map(row => h('div', { key: row.engine, className: 'secw-engine' }, h('strong', null, names[row.engine] || row.engine), h('span', { className: 'secw-badge', 'data-status': row.status }, status(row.status)), row.version ? h('small', null, row.version) : null, row.reason ? h('p', { className: 'secw-muted' }, reason(row.reason)) : null))) : null; }
213
+ function describeFinding(finding) {
214
+
215
+ const category = { '0': ['注入与动态执行', 'Injection or dynamic execution'], '1': ['反序列化', 'Deserialization'], '2': ['加密与密钥', 'Cryptography'], '3': ['凭据泄露', 'Credential exposure'], '4': ['配置与权限', 'Configuration and permissions'], '5': ['信息泄露', 'Information exposure'], '6': ['路径与网络', 'Paths and network'] };
216
+ const flows = { 'CWE-78': [['外部输入进入命令执行', 'Input reaches a shell command'], ['请求参数传入了 shell 命令,请核对输入处理并优先使用参数数组。', 'Request input reaches a shell command. Review input handling and prefer argument arrays.']], 'CWE-79': [['外部输入进入 HTML 写入点', 'Input reaches an HTML sink'], ['发现请求输入到 HTML 写入点的数据流,请核对净化处理与实际页面上下文。', 'Request input reaches HTML insertion. Review sanitization and the actual page context.']], 'CWE-89': [['外部输入进入 SQL 文本', 'Input reaches SQL text'], ['请求输入进入 SQL 语句文本,请核对 SQL 构造方式并使用参数化值。', 'Request input reaches SQL text. Review query construction and use parameterized values.']] };
217
+ const flow = finding.evidence === 'dataflow' ? flows[finding.cwe] : null;
218
+ const title = flow ? t(...flow[0]) : finding.engine === 'betterleaks' ? t('疑似凭据泄露', 'Potential credential exposure') : english() && /[\u3400-\u9fff]/.test(finding.title) ? t(...(category[finding.ruleId?.slice(4, 5)] || ['模式提示', 'Code pattern'])) : finding.title;
219
+ const detail = flow ? t(...flow[1]) : english() && /[\u3400-\u9fff]/.test(finding.message) ? 'Review the recorded code pattern; it does not by itself prove exploitability.' : finding.message;
220
+
221
+ return {title,detail};
222
+ }
223
+ function FindingCard({finding,fresh,onOpen,onCopy}) {
224
+ const {title,detail}=describeFinding(finding);
225
+ return h('article',{className:'secw-finding-detail','data-severity':finding.severity},
226
+ h('h2',{className:'w-detail-title'},title),h('div',{className:'w-detail-meta'},h('span',{className:'w-tag'},risk(finding.severity)),h('span',{className:'w-tag'},fresh?t('新增','New'):t('已接受','Accepted'))),
227
+ h('p',{className:'secw-file'},finding.file+':'+finding.line),h('pre',{'aria-label':t('脱敏证据','Redacted evidence')},finding.snippet),
228
+ h('section',{className:'w-section'},h('h3',null,t('证据与说明','Evidence and explanation')),h('p',null,detail),
229
+ h('p',{className:'w-muted'},finding.ruleId+' · '+finding.cwe+' · '+t('置信度:','Confidence: ')+risk(finding.confidence)+' · '+(finding.evidence==='dataflow'?t('数据流证据','Data-flow evidence'):t('模式提示','Pattern finding'))),
230
+ h('p',{className:'w-muted'},t('来源:','Sources: ')+(finding.sources||[finding.engine||'builtin']).map(engine=>engine==='builtin'?t('内置规则','Built-in rules'):names[engine]||engine).join(', '))),
231
+ h('section',{className:'w-section'},h('h3',null,t('文件位置','File location')),h('p',{className:'secw-file'},finding.file+':'+finding.line),
232
+ h('div',{className:'w-toolbar'},h(W.Button,{icon:'copy',onClick:()=>onCopy(finding)},t('复制位置','Copy location')),h(W.Button,{icon:'external',onClick:()=>onOpen(finding)},t('打开文件','Open file')))));
233
+ }
234
+ function Workbench({workbench=false}={}) {
235
+ const [activeTab,setActiveTab]=useState('findings'),[switchTo,setSwitchTo]=useState(''); const controlId=React.useId();
236
+ const [, repaint] = useState(0), [catalog, setCatalog] = useState([]), [id, setId] = useState(''), [data, setData] = useState(null), [settings, setSettings] = useState({ engines: ['builtin'], enginePaths: {} }), [autoCheck, setAutoCheck] = useState(false), [dirty, setDirty] = useState(false), [job, setJob] = useState(null), [result, setResult] = useState(null), [error, setError] = useState(''), [notice, setNotice] = useState(''), [loading, setLoading] = useState(true), [mode, setMode] = useState('none'), [filter, setFilter] = useState('all'), [limit, setLimit] = useState(50);
237
+ const generation = useRef(0), mounted = useRef(true), requests = useRef(new Set());
238
+ function request(body) { const ctl = new AbortController(); requests.current.add(ctl); return action(body, ctl.signal).finally(() => requests.current.delete(ctl)); }
239
+ async function open(selected, keepResult = false) { const seq = ++generation.current; if (!keepResult)
240
+ setError(''); setLoading(true); try {
241
+ const value = await request({ operation: 'open', id: selected });
242
+ if (!mounted.current || seq !== generation.current)
243
+ return;
244
+ setId(selected);
245
+ setData(value);
246
+ setSettings(value.settings);
247
+ setAutoCheck(value.workspace.autoCheck);
248
+ setDirty(false);
249
+ setLimit(50);
250
+ if (!keepResult)
251
+ setResult(null);
252
+ setJob(value.activeJob || null);
253
+ }
254
+ catch (e) {
255
+ if (mounted.current && seq === generation.current)
256
+ setError(message(e.message));
257
+ }
258
+ finally {
259
+ if (mounted.current && seq === generation.current)
260
+ setLoading(false);
261
+ } }
262
+ useEffect(() => { mounted.current = true; const off = locale?.subscribe?.(() => repaint(n => n + 1)); request({ operation: 'list' }).then(value => { if (!mounted.current)
263
+ return; setCatalog(value.workspaces); if (value.workspaces[0])
264
+ void open(value.workspaces[0].id);
265
+ else
266
+ setLoading(false); }).catch(e => { if (mounted.current) {
267
+ setError(message(e.message));
268
+ setLoading(false);
269
+ } }); return () => { mounted.current = false; generation.current++; requests.current.forEach(c => c.abort()); off?.(); }; }, []);
270
+ useEffect(() => { if (!job || job.status !== 'running' || !id)
271
+ return; let ended = false, timer; const selected = id; async function poll() { try {
272
+ const value = await request({ operation: 'job', id: selected, job: job.id });
273
+ if (ended || !mounted.current)
274
+ return;
275
+ if (value.job.status === 'running')
276
+ timer = setTimeout(poll, 800);
277
+ else {
278
+ setJob(value.job);
279
+ setResult(value.job.result || null);
280
+ if (value.job.error)
281
+ setError(message(value.job.error));
282
+ void open(selected, true);
283
+ }
284
+ }
285
+ catch (e) {
286
+ if (!ended && mounted.current) {
287
+ setError(message(e.message));
288
+ setJob(null);
289
+ }
290
+ } } timer = setTimeout(poll, 300); return () => { ended = true; clearTimeout(timer); }; }, [job?.id, job?.status, id]);
291
+ const busy = job?.status === 'running', report = data?.report;
292
+ const fresh = useMemo(() => new Set((report?.newFindings || []).map(f => f.id)), [report?.newFindings]);
293
+ const findings = useMemo(() => { const list = report?.findings || []; return filter === 'new' ? list.filter(f => fresh.has(f.id)) : filter === 'high' ? list.filter(f => ['critical', 'high'].includes(f.severity)) : list; }, [report?.findings, fresh, filter]);
294
+ async function run(name) { setError(''); setNotice(''); setResult(null); try {
295
+ const value = await request({ operation: 'start', id, action: name, ...(name === 'deps' ? { audit: mode } : {}) });
296
+ if (mounted.current)
297
+ setJob(value.job);
298
+ }
299
+ catch (e) {
300
+ if (mounted.current)
301
+ setError(message(e.message));
302
+ } }
303
+ async function save() { setError(''); try {
304
+ const paths = Object.fromEntries(Object.entries(settings.enginePaths).map(([k, v]) => [k, v.trim()]).filter(([, v]) => v));
305
+ const value = await request({ operation: 'settings', id, engines: settings.engines, enginePaths: paths, autoCheck, ...(settings.osvDatabaseDir?.trim() ? { osvDatabaseDir: settings.osvDatabaseDir.trim() } : {}) });
306
+ if (!mounted.current)
307
+ return;
308
+ setData(value);
309
+ setSettings(value.settings);
310
+ setDirty(false);
311
+ setNotice(t('设置已保存。', 'Settings saved.')); return true;
312
+ }
313
+ catch (e) {
314
+ if (mounted.current)
315
+ setError(message(e.message));
316
+ return false;
317
+ } }
318
+ async function download(format) { setError(''); try {
319
+ const value = await request({ operation: 'export', id, format });
320
+ const url = URL.createObjectURL(new Blob([value.text], { type: format === 'sarif' ? 'application/sarif+json' : 'text/markdown;charset=utf-8' })), a = document.createElement('a');
321
+ a.href = url;
322
+ a.download = value.filename;
323
+ document.body.append(a);
324
+ a.click();
325
+ a.remove();
326
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
327
+ setNotice(t('报告已请求下载。', 'Report download requested.'));
328
+ }
329
+ catch (e) {
330
+ setError(message(e.message));
331
+ } }
332
+ async function location(finding, copy) { setError(''); try {
333
+ const value = await request({ operation: 'location', id, finding: finding.id });
334
+ if (copy) {
335
+ await navigator.clipboard.writeText(value.path + ':' + value.line);
336
+ setNotice(t('文件位置已复制。', 'File location copied.'));
337
+ return;
338
+ }
339
+ const navigation=securityHostContext?.get?.('uiWorkspace') || securityHostContext?.uiWorkspace;
340
+ const right=securityHostContext?.get?.('sidebarRight') || sidebar;
341
+ if (!right?.openResource || !value.sessionId || !navigation?.openSession)
342
+ throw new Error(t('请先打开对应聊天,或复制文件位置。', 'Open the corresponding chat first, or copy the file location.'));
343
+ const file = value.path.replace(/\\/g, '/').split('/').map(s => encodeURIComponent(s).replace(/%3A/ig, ':')).join('/');
344
+ navigation.openSession(value.sessionId);
345
+ await new Promise(resolve=>requestAnimationFrame(resolve));
346
+ if(right.mounted?.getSnapshot?.() !== value.sessionId) throw new Error(t('请先打开对应聊天,或复制文件位置。','Open the corresponding chat first, or copy the file location.'));
347
+ right.openResource('dsh-resource://file/session/' + encodeURIComponent(value.sessionId) + '/' + file, { params: { line: value.line } });
348
+ }
349
+ catch (e) {
350
+ if (mounted.current)
351
+ setError(message(e.message));
352
+ } }
353
+ const disabled = busy || loading || dirty;
354
+ return h(W.Page,{workbench,title:t('代码安全','Code security'),subtitle:t('检查改动、核对证据并跟踪修复。','Check changes, review evidence and track fixes.'),className:'secw security-workbench',actions:catalog.length?h(React.Fragment,null,
355
+ h(W.Button,{variant:'primary',disabled, onClick:()=>run('scan')},t('检查代码','Scan code')),h(W.Button,{disabled,onClick:()=>run('diff')},t('检查改动','Check changes')),
356
+ h(W.Button,{disabled:disabled || !report?.hasScan,onClick:()=>run('verify')},t('复查修复','Recheck fixes')),h(W.IconButton,{icon:'refresh',label:t('刷新结果','Refresh results'),disabled:busy || loading || dirty,onClick:()=>open(id)})) : null},
357
+ h('style',null,css),h('style',null,SECURITY_WORKBENCH_CSS),h(W.Status,{tone:'error'},error),h(W.Status,{tone:'success'},notice),
358
+ catalog.length ? h('div',{className:'security-project'},h('label',{htmlFor:controlId+'-project'},t('项目','Project')),h('select',{id:controlId+'-project',value:id,disabled:loading,onChange:event=>{if(dirty)setSwitchTo(event.target.value);else void open(event.target.value);}},catalog.map(w=>h('option',{key:w.id,value:w.id},w.label+' — '+w.root)))) : null,
359
+ busy ? h(W.Status,null,t('正在检查,可以关闭面板,稍后回来查看结果。','Checking. You can close this panel and return for the result.'),h(W.Button,{onClick:()=>request({operation:'cancel',id,job:job.id}).catch(e=>setError(message(e.message)))},t('取消检查','Cancel check'))) : null,
360
+ result?.coverage && 'closedCount' in result && result.complete === false ? h(Coverage, { rows: Array.isArray(result.coverage) ? result.coverage : [result.coverage] }) : null,
361
+ result?.configurationError ? h('div', { className: 'secw-error', role: 'alert' }, message(result.configurationError)) : null,
362
+ result && 'closedCount' in result ? h('div', { className: 'secw-notice' }, result.complete === false ? t('复查未完成,未判定任何问题已修复。', 'Recheck incomplete. No finding was marked fixed.') : t('本次复查:关闭 ', 'This recheck: closed ') + result.closedCount + ' · ' + t('仍存在 ', 'remaining ') + result.remainingCount + ' · ' + t('新增 ', 'new ') + result.freshCount) : null,
363
+ !catalog.length ? h(W.Empty,{title:loading?t('正在载入…','Loading…'):t('还没有检查过的项目','No checked projects yet'),icon:'shield',action:!loading?h(W.Button,{onClick:()=>{setLoading(true);request({operation:'list'}).then(v=>{if(!mounted.current)return;setCatalog(v.workspaces);if(v.workspaces[0])void open(v.workspaces[0].id);else setLoading(false);}).catch(e=>{if(mounted.current){setError(message(e.message));setLoading(false);}});}},t('刷新项目','Refresh projects')):null},t('在项目聊天中说:“检查这个项目的安全问题。”项目随后会出现在这里。','In a project chat, ask: “Check this project for security issues.” It will then appear here.')) :
364
+ h(W.Tabs,{label:t('代码安全视图','Code security views'),value:activeTab,onChange:setActiveTab,items:[{id:'findings',label:t('检查结果','Findings')},{id:'dependencies',label:t('依赖检查','Dependencies')},{id:'settings',label:t('工具与设置','Tools and settings')}]},
365
+ activeTab === 'settings' ? h('section',{className:'security-settings'},h('h3',null,t('增强工具与自动检查','Enhanced tools and automatic checks')),h('p', { className: 'secw-muted' }, t('增强工具可选。未安装时仍可用内置规则;已选择但未完成的引擎不会算作通过。', 'Enhanced tools are optional. Built-in checks remain available; an incomplete selected engine is never counted as a pass.')),
366
+ h('fieldset', { disabled: busy }, h('legend', null, t('代码检查引擎', 'Code scanning engines')), ['builtin', 'betterleaks', 'semgrep'].map(engine => h('label', { key: engine, className: 'secw-choice' }, h('input', { type: 'checkbox', checked: settings.engines.includes(engine), onChange: e => { setSettings(s => ({ ...s, engines: e.target.checked ? [...s.engines, engine] : s.engines.filter(x => x !== engine) })); setDirty(true); } }), names[engine]))),
367
+ ['betterleaks', 'semgrep', 'osv'].map(engine => h('div', { key: engine }, h('label', { htmlFor: controlId+'-path-' + engine }, names[engine] + t(' 程序路径(可选)', ' executable path (optional)')), h('input', { id: controlId+'-path-' + engine, value: settings.enginePaths[engine] || '', disabled: busy, placeholder: t('已加入 PATH 时可留空', 'Leave empty when available on PATH'), onChange: e => { const value = e.target.value; setSettings(s => ({ ...s, enginePaths: { ...s.enginePaths, [engine]: value } })); setDirty(true); } }), h('a', { href: links[engine], target: '_blank', rel: 'noopener noreferrer' }, t('官方安装说明', 'Official installation')))),
368
+ h('label', { htmlFor: controlId+'-database' }, t('离线漏洞库缓存目录(可选)', 'Offline database cache directory (optional)')),
369
+ h('input', { id: controlId+'-database', value: settings.osvDatabaseDir || '', disabled: busy, placeholder: t('留空使用 OSV 默认缓存', 'Leave empty for the default OSV cache'), onChange: e => { setSettings(s => ({ ...s, osvDatabaseDir: e.target.value })); setDirty(true); } }),
370
+ h('p', { className: 'secw-muted' }, t('OSV 2.7 使用缓存目录/osv-scalibr/npm/all.zip;数据库由用户预先下载。', 'OSV 2.7 uses cache/osv-scalibr/npm/all.zip. Download the database beforehand.')),
371
+ h('a', { href: 'https://google.github.io/osv-scanner/usage/offline-mode/', target: '_blank', rel: 'noopener noreferrer' }, t('离线数据库说明', 'Offline database guide')),
372
+ h('label', { className: 'secw-choice' }, h('input', { type: 'checkbox', checked: autoCheck, disabled: busy, onChange: e => { setAutoCheck(e.target.checked); setDirty(true); } }), t('每轮完成后检查改动', 'Check changes after each turn')),
373
+ h('p', { className: 'secw-muted' }, t('自动检查仅用内置规则,包含 Git 未跟踪的新文件。结果单独保存;新增高风险模式会在后续对话中提醒。', 'Automatic checks use built-in rules and include untracked Git files. Results are stored separately; new high-risk patterns are surfaced in later conversations.')),
374
+ data?.automatic ? h('p', { className: 'secw-muted' }, data.automatic.problem ? t('最近自动检查未完成,请手动检查改动并核对 Git 工作区。', 'The last automatic check did not complete. Check changes manually and verify the Git workspace.') : t('最近自动检查:', 'Last automatic check: ') + new Date(data.automatic.time).toLocaleString(english() ? 'en-US' : 'zh-CN') + ' · ' + t('高风险提示 ', 'High-risk patterns ') + data.automatic.highRisk) : null,
375
+ h('div', { className: 'secw-tools' }, h('button', { disabled: busy || !dirty, onClick: save }, t('保存设置', 'Save settings')), h('button', { disabled: busy || !dirty, onClick: () => { setSettings(data.settings); setAutoCheck(data.workspace.autoCheck); setDirty(false); } }, t('取消修改', 'Cancel edits')), h('button', { disabled, onClick: () => run('health') }, t('检查工具', 'Check tools'))),
376
+ dirty ? h('p', { className: 'secw-muted' }, t('设置尚未保存,请保存后再检查。', 'Save your settings before starting a check.')) : null,
377
+ result?.engines ? h(Coverage, { rows: result.engines }) : null,
378
+ result?.database ? h('div', null, h('p', { className: 'secw-file' }, result.database.directory), result.database.available ? result.database.databases.map(db => h('p', { key: db.ecosystem, className: 'secw-muted' }, db.ecosystem + ' · ' + t('缓存文件修改时间:', 'Cache file modified: ') + new Date(db.fileModifiedAt).toLocaleString(english() ? 'en-US' : 'zh-CN'))) : h('p', { className: 'secw-muted' }, t('未找到离线漏洞库。', 'No offline database was found.')), h('p', { className: 'secw-muted' }, t('文件时间不代表库中每条漏洞的更新日期。', 'A file timestamp does not establish the freshness of every advisory.'))) : null) : activeTab === 'dependencies' ? h('section',{className:'security-settings'},h('h3',null,t('依赖与已知漏洞','Dependencies and known vulnerabilities')),h('label', { htmlFor: controlId+'-audit' }, t('检查方式', 'Check mode')),
379
+ h('select', { id: controlId+'-audit', value: mode, disabled: busy, onChange: e => setMode(e.target.value) }, h('option', { value: 'none' }, t('仅列出依赖(离线)', 'Dependency inventory (offline)')), h('option', { value: 'offline' }, t('已知漏洞(本地数据库)', 'Known vulnerabilities (local database)')), h('option', { value: 'online' }, t('已知漏洞(联网)', 'Known vulnerabilities (online)'))),
380
+ h('p', { className: 'secw-muted' }, mode === 'online' ? t('联网审计会向 OSV 查询包名、版本等依赖信息;不上传源码。', 'Online auditing queries OSV with package names, versions and dependency metadata; source code is not uploaded.') : t('清单检查只看依赖声明,不代表不存在已知漏洞。离线漏洞审计需要预先下载数据库。', 'An inventory is not a vulnerability audit. Offline auditing requires a previously downloaded database.')),
381
+ h('button', { disabled, onClick: () => run('deps') }, t('检查依赖', 'Check dependencies')),
382
+ result?.dependencies ? h('div', null, h('p', null, t('发现依赖:', 'Dependencies: ') + (result.dependenciesTotal ?? result.dependencies.length)), ...(result.warnings || []).map((warning, i) => h('p', { key: i, className: 'secw-error' }, message(warning))), result.audit ? h(React.Fragment, null, h(Coverage, { rows: [result.audit.coverage] }), h('p', null, t('已知漏洞:', 'Known vulnerabilities: ') + (result.audit.vulnerabilitiesTotal ?? result.audit.vulnerabilities.length)), result.audit.vulnerabilities.map((v, i) => h('article', { key: v.id + v.package + i, className: 'secw-finding' }, h('strong', null, v.package + '@' + v.version), h('p', null, v.summary), h('p', null, t('修复版本:', 'Fixed versions: ') + (v.fixedVersions?.join(', ') || t('暂无', 'Not available'))), h('a', { href: v.url, target: '_blank', rel: 'noopener noreferrer' }, v.id)))) : null) : null) : h(SecurityResults,{report,findings,fresh,filter,setFilter,limit,setLimit,busy,loading,download,onOpen:f=>location(f,false),onCopy:f=>location(f,true)})),
383
+ switchTo ? h(W.Dialog,{title:t('设置尚未保存','Unsaved settings'),closeLabel:t('继续编辑','Keep editing'),onClose:()=>setSwitchTo('')},h('p',null,t('切换项目之前,选择如何处理当前修改。','Choose what to do with the current settings before switching projects.')),h('div',{className:'w-toolbar'},
384
+ h(W.Button,{variant:'primary',onClick:async()=>{if(await save()){const next=switchTo;setSwitchTo('');void open(next);}}},t('保存并切换','Save and switch')),
385
+ h(W.Button,{onClick:()=>{const next=switchTo;setSwitchTo('');void open(next);}},t('放弃修改并切换','Discard and switch')),
386
+ h(W.Button,{onClick:()=>setSwitchTo('')},t('继续编辑','Keep editing')))) : null);
387
+ }
388
+ const css = `.secw{max-width:1060px;min-width:0;margin:auto;font:14px/1.6 system-ui,sans-serif;color:var(--dsw-alias-label-primary,#253047)}.secw *{box-sizing:border-box}.secw h2{font-size:22px;margin:0}.secw h3{font-size:17px;margin:0 0 8px}.secw p{margin:7px 0}.secw label{display:block;margin:8px 0 4px}.secw button,.secw select,.secw input:not([type=checkbox]){font:inherit;color:inherit;border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff);padding:8px 10px;max-width:100%}.secw input:not([type=checkbox]),.secw select{width:100%;min-width:0}.secw button{cursor:pointer;white-space:normal}.secw button:disabled{opacity:.5;cursor:default}.secw .secw-primary{background:#245ee9;color:#fff;border-color:#245ee9}.secw .secw-tools,.secw .secw-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:12px 0}.secw .secw-tools select{width:auto;max-width:100%}.secw .secw-muted{color:var(--dsw-alias-label-secondary,#64718a);font-size:13px}.secw .secw-card,.secw .secw-finding{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:11px;padding:14px;margin:12px 0;overflow-wrap:anywhere;background:var(--dsw-alias-bg-layer-1,#fff)}.secw .secw-finding[data-severity=critical],.secw .secw-finding[data-severity=high]{border-left:4px solid #c04836}.secw .secw-coverage{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;margin:10px 0}.secw .secw-engine{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:9px;padding:10px;overflow-wrap:anywhere}.secw .secw-engine strong{margin-right:8px}.secw .secw-engine small{display:block;color:var(--dsw-alias-label-secondary,#64718a)}.secw .secw-badge{display:inline-block;background:var(--dsw-alias-bg-layer-2,#f0f3f8);border-radius:5px;padding:1px 6px;font-size:12px}.secw .secw-badge[data-status=failed],.secw .secw-badge[data-status=partial]{color:#b33928}.secw .secw-error{background:#fff0ed;color:#973321;padding:10px;border-radius:8px;overflow-wrap:anywhere}.secw .secw-notice{background:#edf4ff;color:#17468c;padding:10px;border-radius:8px}.secw .secw-choice{display:flex;align-items:center;gap:8px}.secw fieldset{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:8px;margin:12px 0}.secw summary{cursor:pointer;font-weight:600}.secw pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:160px;overflow:auto;background:var(--dsw-alias-bg-layer-2,#f3f5f9);padding:10px;border-radius:6px}.secw .secw-file{font-family:ui-monospace,monospace;overflow-wrap:anywhere}.secw a{color:#245ee9}.secw :focus-visible{outline:3px solid #78a8ff;outline-offset:2px}[role=dialog]:has(.secw){width:min(1200px,calc(100vw - 48px))!important;max-width:1200px!important}@media(max-width:700px){[role=dialog]:has(.secw){width:100%!important;max-width:100%!important;flex-direction:column!important}[role=dialog]:has(.secw) nav{width:100%!important;flex-basis:auto!important;max-height:140px;overflow:auto;border-right:0}[role=dialog]:has(.secw) nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;flex-shrink:0;gap:4px}[role=dialog]:has(.secw)>nav+div{min-width:0;width:100%}[role=dialog]:has(.secw)>nav+div>div:has(.secw){padding:12px!important;min-width:0}.secw .secw-coverage{grid-template-columns:1fr}}`;
389
+ function apply(ctx) {
390
+ securityHostContext=ctx;W.register(ctx,{id:'dsh-code-security.workbench',label:()=>t('代码安全','Code security'),icon:'shield',component:Workbench,order:59});
391
+ try {
392
+ locale = ctx.get?.('locale') || ctx.locale;
393
+ }
394
+ catch {
395
+ locale = ctx.locale;
396
+ } try {
397
+ sidebar = ctx.get?.('sidebarRight') || ctx.sidebarRight;
398
+ }
399
+ catch { } ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-code-security', order: 59, label: () => t('代码安全', 'Code security'), inject: () => ({}) }, Workbench)); }
400
+ return { inject: ['slots', 'locale'], apply };
401
+ } });
402
+
403
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-security",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "对代码与 Git 改动进行本地静态安全检查,并追踪修复结果。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -25,7 +25,7 @@
25
25
  "THIRD_PARTY_NOTICES.md"
26
26
  ],
27
27
  "scripts": {
28
- "build": "tsc -p tsconfig.json",
28
+ "build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
29
29
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
30
  "test": "pnpm run build && node --test \"test/*.test.mjs\"",
31
31
  "prepublishOnly": "pnpm run build"
@@ -36,7 +36,9 @@
36
36
  },
37
37
  "client": {
38
38
  "inject": [
39
- "@deepseek-ai/dsh-client-ui-settings"
39
+ "@deepseek-ai/dsh-client-ui-settings",
40
+ "@deepseek-ai/dsh-client-ui-layout",
41
+ "@deepseek-ai/dsh-client-ui-sidebar"
40
42
  ],
41
43
  "platform": "web"
42
44
  }