Skip to content

Commit c496875

Browse files
committed
feat(web): 本地客户端改用目录浏览器(零上传),解决3万文件上传问题 v0.7.8
- 本地模式弃用 webkitdirectory 上传(会读整个node_modules弹3万文件) - 改服务端 /browse 目录浏览器:网页点选文件夹→服务端直读本机扫描,零上传不出本机,跳过node_modules - 公网模式禁 /browse(403,防扫服务器硬盘) - test-web 18→20; 全套302全绿
1 parent dce683c commit c496875

5 files changed

Lines changed: 86 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.7.8] - 2026-06-20
9+
10+
### Changed — 本地客户端改用「目录浏览器」,彻底解决上传 3 万文件的问题
11+
- **本地模式不再用浏览器文件夹上传**(webkitdirectory 会读取整个 node_modules,弹"上传 3 万+ 文件"、又慢又吓人)
12+
- 改为**服务端目录浏览器** `/browse`(仅本地模式):在网页里点进本机文件夹 → 服务端**直接读取本机文件扫描****零上传、不出本机、自动跳过 node_modules**
13+
- 公网模式禁止 `/browse`(防止扫服务器硬盘),返回 403
14+
- 配合 0.7.7 跳过 `release/*.app` 构建产物,长路径不再压坏报告表格
15+
- `test-web.ts` 扩至 20 项(含目录浏览 + 公网拒绝浏览);全套 **302 测试**全绿
16+
817
## [0.7.7] - 2026-06-20
918

1019
### Fixed — 真实项目扫描的两个问题(用户实测发现)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
[![npm](https://img.shields.io/npm/v/shellward?color=cb0000&label=npm)](https://www.npmjs.com/package/shellward)
1010
[![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
11-
[![tests](https://img.shields.io/badge/tests-300%20passing-brightgreen)](#performance)
11+
[![tests](https://img.shields.io/badge/tests-302%20passing-brightgreen)](#performance)
1212
[![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](#performance)
1313

1414
**🌐 官网: https://jnmetacode.github.io/shellward/**

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "shellward",
3-
"version": "0.7.7",
3+
"version": "0.7.8",
44
"mcpName": "io.github.jnMetaCode/shellward",
55
"description": "AI agent security & MCP security middleware — prompt injection detection, AI firewall, runtime guardrails & data-loss prevention for LLM tool calls. 8-layer defense against data exfiltration & dangerous commands. Zero dependencies. SDK + OpenClaw plugin. Supports LangChain, AutoGPT, Claude Code, Cursor, OpenAI Agents, Hermes Agent.",
66
"keywords": [

src/web/scan-server.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515

1616
import { createServer } from 'http'
1717
import { spawn } from 'child_process'
18-
import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs'
19-
import { tmpdir } from 'os'
18+
import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
19+
import { tmpdir, homedir } from 'os'
2020
import { join, resolve, dirname, normalize, isAbsolute } from 'path'
2121
import { runProjectComplianceAudit } from '../compliance/audit.js'
2222
import { renderHtmlReport } from '../compliance/html-report.js'
@@ -58,6 +58,11 @@ export function startWebServer(opts: WebServerOptions): void {
5858
if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'))
5959
return await handleUpload(req, res, locale, () => { active++ }, () => { active-- })
6060
}
61+
// 本地目录浏览(仅本地模式):服务端直接列子目录,让用户点选要扫的文件夹(零上传)
62+
if (u.pathname === '/browse') {
63+
if (!opts.local) return send(res, 403, 'application/json', JSON.stringify({ error: '仅本地模式可用' }))
64+
return handleBrowse(res, u.searchParams.get('dir'))
65+
}
6166
// 演示:扫一个内置的「含风险样例项目」——证明"秒出≠没检查"(满屏发现 + 行号)
6267
if (u.pathname === '/demo') {
6368
if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'))
@@ -180,6 +185,27 @@ async function handleUpload(req: any, res: any, locale: 'zh' | 'en', inc: () =>
180185
}
181186
}
182187

188+
/** 本地目录浏览:返回某目录下的子目录列表(供网页点选;不读文件内容、不上传) */
189+
function handleBrowse(res: any, dirParam: string | null) {
190+
try {
191+
const abs = resolve(dirParam && dirParam.trim() ? dirParam : homedir())
192+
const entries = readdirSync(abs, { withFileTypes: true })
193+
.filter(e => { try { return e.isDirectory() } catch { return false } })
194+
.map(e => e.name)
195+
.filter(n => !n.startsWith('.') && n !== 'node_modules')
196+
.sort((a, b) => a.localeCompare(b))
197+
.slice(0, 500)
198+
const parent = dirname(abs)
199+
send(res, 200, 'application/json', JSON.stringify({
200+
current: abs,
201+
parent: parent === abs ? null : parent,
202+
dirs: entries,
203+
}))
204+
} catch (e: any) {
205+
send(res, 200, 'application/json', JSON.stringify({ error: e?.message || String(e) }))
206+
}
207+
}
208+
183209
/** 演示:内置「含风险样例项目」扫描,证明检测真在工作 */
184210
function handleDemo(res: any, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
185211
const dir = mkdtempSync(join(tmpdir(), 'sw-demo-'))
@@ -242,13 +268,13 @@ function formPage(local: boolean): string {
242268
</form>`
243269

244270
const uploadForm = local ? `
245-
<form id="dirform">
246-
<label>① 选择本地项目文件夹(推荐)</label>
247-
<input type="file" id="dir" webkitdirectory directory multiple>
248-
<button id="dbtn" type="submit">开始体检 →</button>
249-
<div id="status" class="status"></div>
250-
<p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
251-
</form>
271+
<label>① 在本机点选项目文件夹(推荐 · 零上传)</label>
272+
<div class="browser">
273+
<div class="bpath" id="curpath">加载中…</div>
274+
<ul class="dirs" id="dirs"></ul>
275+
</div>
276+
<button id="scanbtn" type="button">✅ 扫描当前文件夹 →</button>
277+
<p class="hint">📂 在你电脑上点进项目目录,再点"扫描当前文件夹"。<b>服务端直接读取本机文件、零上传、不出本机</b>,自动跳过 node_modules,无需选 3 万个文件。</p>
252278
<div class="or">— 或 —</div>` : ''
253279

254280
return page('ShellWard 合规体检', `
@@ -262,11 +288,30 @@ function formPage(local: boolean): string {
262288
<p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
263289
<a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
264290
</div>
265-
${local ? UPLOAD_SCRIPT : ''}`)
291+
${local ? BROWSE_SCRIPT : ''}`)
266292
}
267293

268-
// 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
269-
// 注意:过滤后缀须与服务端 SCAN_EXT 对齐(含 .md),否则 markdown 项目会被全滤光显得"扫不了"。
294+
// 本地目录浏览器:点选文件夹 → 服务端直接扫(零上传,不读 node_modules)
295+
const BROWSE_SCRIPT = `<script>
296+
(function(){
297+
var cur='';
298+
function load(dir){
299+
var cp=document.getElementById('curpath'); if(cp)cp.textContent='加载中…';
300+
fetch('/browse?dir='+encodeURIComponent(dir||'')).then(function(r){return r.json()}).then(function(d){
301+
if(d.error){ if(cp)cp.textContent='无法读取:'+d.error; return; }
302+
cur=d.current; if(cp)cp.textContent=cur;
303+
var ul=document.getElementById('dirs'); if(!ul)return; ul.innerHTML='';
304+
if(d.parent){ var up=document.createElement('li'); up.className='up'; up.textContent='⬆ 上级目录'; up.onclick=function(){load(d.parent)}; ul.appendChild(up); }
305+
if(!d.dirs.length){ var e=document.createElement('li'); e.className='empty'; e.textContent='(此目录无子文件夹,可直接点上方扫描)'; ul.appendChild(e); }
306+
d.dirs.forEach(function(name){ var li=document.createElement('li'); li.textContent='📁 '+name; li.onclick=function(){ load(cur.replace(/\\/+$/,'')+'/'+name) }; ul.appendChild(li); });
307+
}).catch(function(e){ if(cp)cp.textContent='错误:'+e; });
308+
}
309+
var sb=document.getElementById('scanbtn');
310+
if(sb){ sb.onclick=function(){ if(cur){ sb.disabled=true; sb.textContent='扫描中…'; window.location.href='/scan?path='+encodeURIComponent(cur); } }; load(''); }
311+
})();
312+
</script>`
313+
314+
// (旧上传脚本保留备用,当前本地模式改用目录浏览器)
270315
const UPLOAD_SCRIPT = `<script>
271316
(function(){
272317
var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
@@ -337,6 +382,14 @@ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6
337382
.status{display:none;margin:10px 0 0;padding:10px 14px;border-radius:8px;background:#f1f5f9;
338383
color:#334155;font-size:13.5px;border-left:3px solid #cb0000;text-align:left}
339384
.demo{margin:18px 0 0;font-size:13px;color:#475569}.demo a{font-weight:600}
385+
.browser{border:1px solid #cbd5e1;border-radius:10px;overflow:hidden;margin:4px 0 10px;text-align:left}
386+
.bpath{background:#0f172a;color:#93c5fd;font-family:ui-monospace,Menlo,monospace;font-size:12px;
387+
padding:9px 12px;word-break:break-all}
388+
.dirs{list-style:none;margin:0;padding:0;max-height:240px;overflow-y:auto}
389+
.dirs li{padding:9px 14px;border-top:1px solid #eef2f7;cursor:pointer;font-size:14px}
390+
.dirs li:hover{background:#f1f5f9}
391+
.dirs li.up{color:#cb0000;font-weight:600}
392+
.dirs li.empty{color:#94a3b8;cursor:default;font-size:13px}
340393
.foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
341394
.back{font-weight:600}
342395
</style></head><body>${body}</body></html>`

test-web.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ async function main() {
3838
test('服务启动并响应', up)
3939
if (up) {
4040
const home = await (await fetch(base + '/')).text()
41-
test('首页含「选择本地项目文件夹」', home.includes('选择本地项目文件夹'))
42-
test('首页含上传脚本 /scan-files', home.includes('scan-files'))
41+
test('首页含目录浏览器(点选文件夹)', home.includes('扫描当前文件夹') && home.includes('curpath'))
4342
test('首页含 URL 入口', home.includes('公开仓库地址'))
4443

44+
// 目录浏览器:列子目录(零上传)
45+
const browse = await (await fetch(base + '/browse?dir=' + encodeURIComponent(tmpdir()))).json() as any
46+
test('/browse 返回当前目录与子目录列表', typeof browse.current === 'string' && Array.isArray(browse.dirs))
47+
test('/browse 不列出 node_modules', !browse.dirs.includes('node_modules'))
48+
4549
// 模拟浏览器上传(客户端读文件夹后发的 JSON)
4650
const payload = {
4751
root: 'myproj',
@@ -94,7 +98,10 @@ async function main() {
9498
test('公网服务启动', pup)
9599
if (pup) {
96100
const phome = await (await fetch(pbase + '/')).text()
97-
test('公网首页不含本地上传(只 URL)', !phome.includes('选择本地项目文件夹') && phome.includes('公开仓库地址'))
101+
test('公网首页不含本地目录浏览(只 URL)', !phome.includes('扫描当前文件夹') && phome.includes('公开仓库地址'))
102+
// 公网模式禁止目录浏览(防止扫服务器硬盘)
103+
const browseBlocked = await fetch(pbase + '/browse?dir=/etc')
104+
test('公网模式拒绝目录浏览 (403)', browseBlocked.status === 403, `status=${browseBlocked.status}`)
98105
// 公网模式禁止本地路径扫描
99106
const blocked = await fetch(pbase + '/scan?path=/etc')
100107
test('公网模式拒绝本地路径扫描 (403)', blocked.status === 403, `status=${blocked.status}`)

0 commit comments

Comments
 (0)