Skip to content

Commit 481e402

Browse files
committed
feat(web): 本地客户端改为选文件夹上传(不再手敲路径) v0.7.1
- web --local 首页:选择项目文件夹(浏览器读取→仅发本机本地服务)+公开仓库URL 双入口 - 客户端过滤(跳过node_modules等/仅文本配置/限大小/≤3000文件),数据不出本机 - POST /scan-files(仅本地):路径穿越防护(拒绝绝对路径/..)、16MB上限、用完即删 - 用户反馈"填路径不好填"后改进
1 parent 4634da0 commit 481e402

3 files changed

Lines changed: 121 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ 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.1] - 2026-06-20
9+
10+
### Changed — 本地客户端 UX:选文件夹上传(不再手敲路径)
11+
- 本地模式(`shellward web --local`)首页改为:**「选择项目文件夹」**(浏览器读取→仅发送到本机本地服务→扫描)+ 「公开仓库 URL」双入口,不必再手敲路径
12+
- 文件夹上传客户端侧过滤(跳过 node_modules/.git 等、仅文本/配置、单文件 512KB、总量 8MB、≤3000 文件),数据**不经过任何外部服务器、不出本机**
13+
- 新增 `POST /scan-files`(仅本地模式):路径穿越防护(拒绝绝对路径/`..`,限制写入临时目录内)、16MB 上限、用完即删
14+
- 用户反馈"填路径不好填"后改进
15+
816
## [0.7.0] - 2026-06-20
917

1018
### Added — Web 扫描器 / 客户端(双模式,一套代码)

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.0",
3+
"version": "0.7.1",
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: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616
import { createServer } from 'http'
1717
import { spawn } from 'child_process'
18-
import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'
18+
import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs'
1919
import { tmpdir } from 'os'
20-
import { join, resolve } from 'path'
20+
import { join, resolve, dirname, normalize, isAbsolute } from 'path'
2121
import { runProjectComplianceAudit } from '../compliance/audit.js'
2222
import { renderHtmlReport } from '../compliance/html-report.js'
2323
import { DEFAULT_CONFIG, resolveLocale } from '../types.js'
@@ -52,6 +52,12 @@ export function startWebServer(opts: WebServerOptions): void {
5252
if (u.pathname === '/' || u.pathname === '') {
5353
return send(res, 200, 'text/html', formPage(!!opts.local))
5454
}
55+
// 本地客户端:选文件夹上传(仅本地模式;数据只到 localhost、不出本机)
56+
if (u.pathname === '/scan-files' && req.method === 'POST') {
57+
if (!opts.local) return send(res, 403, 'text/html', errorPage('公网模式不支持上传;请用「公开仓库 URL」。'))
58+
if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'))
59+
return await handleUpload(req, res, locale, () => { active++ }, () => { active-- })
60+
}
5561
if (u.pathname === '/scan') {
5662
if (active >= MAX_CONCURRENT) {
5763
return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'))
@@ -120,6 +126,52 @@ async function handleLocal(res: any, path: string, locale: 'zh' | 'en', inc: ()
120126
}
121127
}
122128

129+
const MAX_UPLOAD_BYTES = 16 * 1024 * 1024 // 16MB JSON 上限
130+
131+
/** 本地上传:客户端把选中的文件夹读成 {path,content}[] 发来,写入临时目录后扫描 */
132+
async function handleUpload(req: any, res: any, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
133+
let body = ''
134+
let size = 0
135+
let aborted = false
136+
await new Promise<void>((resolveBody) => {
137+
req.on('data', (c: Buffer) => {
138+
size += c.length
139+
if (size > MAX_UPLOAD_BYTES) { aborted = true; req.destroy(); resolveBody(); return }
140+
body += c.toString('utf8')
141+
})
142+
req.on('end', () => resolveBody())
143+
req.on('error', () => { aborted = true; resolveBody() })
144+
})
145+
if (aborted) return send(res, 413, 'text/html', errorPage('内容过大或读取失败(上限 16MB)。大项目请用本地 CLI:npx shellward scan'))
146+
147+
let payload: { root?: string; files?: { path: string; content: string }[] }
148+
try { payload = JSON.parse(body) } catch { return send(res, 400, 'text/html', errorPage('上传数据格式错误')) }
149+
const files = Array.isArray(payload.files) ? payload.files : []
150+
if (files.length === 0) return send(res, 400, 'text/html', errorPage('未选择任何文件'))
151+
152+
const dir = mkdtempSync(join(tmpdir(), 'sw-up-'))
153+
inc()
154+
try {
155+
for (const f of files) {
156+
if (!f || typeof f.path !== 'string' || typeof f.content !== 'string') continue
157+
// 路径安全:去掉绝对路径/.. 逃逸,落在临时目录内
158+
const rel = normalize(f.path).replace(/^(\.\.(\/|\\|$))+/, '')
159+
if (isAbsolute(rel) || rel.includes('..')) continue
160+
const dest = join(dir, rel)
161+
if (!dest.startsWith(dir)) continue
162+
try { mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, f.content) } catch { /* skip */ }
163+
}
164+
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
165+
const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)'
166+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName }))
167+
} catch (e: any) {
168+
send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))))
169+
} finally {
170+
dec()
171+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
172+
}
173+
}
174+
123175
/** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
124176
function cloneRepo(url: string, dir: string): Promise<void> {
125177
return new Promise((res, rej) => {
@@ -144,27 +196,69 @@ function send(res: any, code: number, type: string, body: string) {
144196
}
145197

146198
function formPage(local: boolean): string {
147-
const field = local
148-
? `<label>本地项目路径</label>
149-
<input name="path" placeholder="/Users/you/your-ai-project" autofocus>
150-
<p class="hint">本地模式:代码不上传、不出本机(客户端体验)。</p>`
151-
: `<label>公开仓库地址</label>
152-
<input name="repo" placeholder="https://github.com/owner/repo" autofocus>
153-
<p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。<b>私有/敏感代码请用本地 CLI</b>:<code>npx shellward scan</code>(不上传)。</p>`
199+
const urlForm = `
200+
<form action="/scan" method="get">
201+
<label>${local ? '② ' : ''}公开仓库地址</label>
202+
<input name="repo" placeholder="https://github.com/owner/repo"${local ? '' : ' autofocus'}>
203+
<button type="submit">${local ? '体检该仓库 →' : '开始体检 →'}</button>
204+
<p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。${local ? '' : '<b>私有/敏感代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。'}</p>
205+
</form>`
206+
207+
const uploadForm = local ? `
208+
<form id="dirform">
209+
<label>① 选择本地项目文件夹(推荐)</label>
210+
<input type="file" id="dir" webkitdirectory directory multiple>
211+
<button id="dbtn" type="submit">开始体检 →</button>
212+
<p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
213+
</form>
214+
<div class="or">— 或 —</div>` : ''
215+
154216
return page('ShellWard 合规体检', `
155217
<div class="hero">
156218
<div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
157219
<h1>AI 应用合规体检</h1>
158-
<p class="sub">${local ? '填本地路径' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
159-
<form action="/scan" method="get">
160-
${field}
161-
<button type="submit">开始体检 →</button>
162-
</form>
220+
<p class="sub">${local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
221+
${uploadForm}
222+
${urlForm}
163223
<p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
164224
<a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
165-
</div>`)
225+
</div>
226+
${local ? UPLOAD_SCRIPT : ''}`)
166227
}
167228

229+
// 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
230+
const UPLOAD_SCRIPT = `<script>
231+
(function(){
232+
var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
233+
var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv)$/i;
234+
var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
235+
var form=document.getElementById('dirform'); if(!form) return;
236+
form.addEventListener('submit', async function(e){
237+
e.preventDefault();
238+
var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
239+
if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
240+
btn.disabled=true; btn.textContent='读取中…';
241+
var picked=[], total=0, root='';
242+
for(var i=0;i<inp.files.length;i++){
243+
var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
244+
if(SKIP.test(rel)) continue;
245+
var base=rel.split('/').pop();
246+
if(!(EXT.test(rel)||ENV.test(rel)||DEP.test(base))) continue;
247+
if(f.size>524288) continue;
248+
if(picked.length>=3000||total>8388608) break;
249+
total+=f.size; picked.push(f);
250+
}
251+
if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
252+
btn.textContent='扫描中… ('+picked.length+' 个文件)';
253+
var out=[]; for(var j=0;j<picked.length;j++){ try{ out.push({path:picked[j].webkitRelativePath||picked[j].name, content:await picked[j].text()}); }catch(_){} }
254+
try{
255+
var resp=await fetch('/scan-files',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({root:root,files:out})});
256+
var html=await resp.text(); document.open(); document.write(html); document.close();
257+
}catch(err){ alert('扫描失败: '+err); btn.disabled=false; btn.textContent='开始体检 →'; }
258+
});
259+
})();
260+
</script>`
261+
168262
function errorPage(msg: string): string {
169263
return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
170264
<h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
@@ -189,8 +283,11 @@ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16
189283
input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
190284
.hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
191285
.hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
286+
input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
192287
button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
193288
font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
289+
button:disabled{background:#94a3b8;cursor:default}
290+
form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
194291
.foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
195292
.back{font-weight:600}
196293
</style></head><body>${body}</body></html>`

0 commit comments

Comments
 (0)