Skip to content

Commit dedf01b

Browse files
committed
fix(web): 修客户端扫不了(过滤漏.md/document.write不可靠) + web集成测试 v0.7.5
- 客户端过滤对齐服务端SCAN_EXT(含.md/.mdx/.ipynb),markdown项目不再被全滤光 - 结果渲染改 Blob URL 跳转(替代不可靠的document.write) - 加可见状态提示,不再静默失败 - 新增 test-web.ts 18项端到端集成测试,纳入 npm test - 全套300测试全绿
1 parent ade047f commit dedf01b

5 files changed

Lines changed: 140 additions & 10 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.5] - 2026-06-20
9+
10+
### Fixed — web 客户端"扫不了"的真实 bug(用户反馈后修)
11+
- **客户端文件过滤未对齐服务端**:上传时漏掉 `.md`/`.mdx`/`.ipynb` 等,导致 markdown 为主的项目(如 prompt/skill 库)被全滤光、提示"未找到可扫描文件"。现与服务端 SCAN_EXT 对齐
12+
- **结果渲染从 `document.write` 改为 Blob URL 跳转**,更可靠(此前某些情况报告写不出、页面像卡住)
13+
- **加可见状态提示**:读取/扫描/失败都在页面显示(不再静默失败或只弹 alert)
14+
- 新增 `test-web.ts`(18 项端到端集成测试:上传扫描、本地路径、URL 校验、路径穿越防护、双模式权限),纳入 `npm test`
15+
- 全套 **300 测试**全绿
16+
817
## [0.7.4] - 2026-06-20
918

1019
### Added — 合规扫描检测基准(把"信我能检"变成"看数字")

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-282%20passing-brightgreen)](#performance)
11+
[![tests](https://img.shields.io/badge/tests-300%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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "shellward",
3-
"version": "0.7.4",
3+
"version": "0.7.5",
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": [
@@ -57,7 +57,7 @@
5757
"scripts": {
5858
"build": "tsc",
5959
"mcp": "npx tsx src/mcp-server.ts",
60-
"test": "npx tsx test-sdk.ts && npx tsx test-integration.ts && npx tsx test-edge-cases.ts && npx tsx test-rugpull.ts && npx tsx test-redos.ts && npx tsx test-mcp-client.ts && npx tsx test-mcp.ts && npx tsx test-compliance.ts",
60+
"test": "npx tsx test-sdk.ts && npx tsx test-integration.ts && npx tsx test-edge-cases.ts && npx tsx test-rugpull.ts && npx tsx test-redos.ts && npx tsx test-mcp-client.ts && npx tsx test-mcp.ts && npx tsx test-compliance.ts && npx tsx test-web.ts",
6161
"test:redos": "npx tsx test-redos.ts",
6262
"test:compliance": "npx tsx test-compliance.ts",
6363
"test:integration": "npx tsx test-integration.ts",

src/web/scan-server.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ function formPage(local: boolean): string {
212212
<label>① 选择本地项目文件夹(推荐)</label>
213213
<input type="file" id="dir" webkitdirectory directory multiple>
214214
<button id="dbtn" type="submit">开始体检 →</button>
215+
<div id="status" class="status"></div>
215216
<p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
216217
</form>
217218
<div class="or">— 或 —</div>` : ''
@@ -230,17 +231,20 @@ function formPage(local: boolean): string {
230231
}
231232

232233
// 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
234+
// 注意:过滤后缀须与服务端 SCAN_EXT 对齐(含 .md),否则 markdown 项目会被全滤光显得"扫不了"。
233235
const UPLOAD_SCRIPT = `<script>
234236
(function(){
235237
var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
236-
var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv)$/i;
238+
var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv|md|mdx|ipynb|properties|xml|gradle|tf)$/i;
237239
var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
238240
var form=document.getElementById('dirform'); if(!form) return;
241+
var statusEl=document.getElementById('status');
242+
function s(m){ if(statusEl){statusEl.textContent=m;statusEl.style.display='block';} }
239243
form.addEventListener('submit', async function(e){
240244
e.preventDefault();
241245
var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
242-
if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
243-
btn.disabled=true; btn.textContent='读取中…';
246+
if(!inp.files||!inp.files.length){ s('请先点上方按钮选择项目文件夹'); return; }
247+
btn.disabled=true;
244248
var picked=[], total=0, root='';
245249
for(var i=0;i<inp.files.length;i++){
246250
var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
@@ -251,13 +255,17 @@ const UPLOAD_SCRIPT = `<script>
251255
if(picked.length>=3000||total>8388608) break;
252256
total+=f.size; picked.push(f);
253257
}
254-
if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
255-
btn.textContent='扫描中… ('+picked.length+' 个文件)';
258+
if(!picked.length){ s('未找到可扫描的源码/配置文件(已自动跳过 node_modules、图片、超大文件)。请选含代码或配置的目录。'); btn.disabled=false; return; }
259+
s('读取 '+picked.length+' 个文件…');
256260
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(_){} }
261+
s('扫描中…('+out.length+' 个文件,请稍候)');
257262
try{
258263
var resp=await fetch('/scan-files',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({root:root,files:out})});
259-
var html=await resp.text(); document.open(); document.write(html); document.close();
260-
}catch(err){ alert('扫描失败: '+err); btn.disabled=false; btn.textContent='开始体检 →'; }
264+
if(!resp.ok){ s('扫描失败:HTTP '+resp.status+'。请重试,或改用命令行 npx shellward scan。'); btn.disabled=false; return; }
265+
var html=await resp.text();
266+
// 用 Blob URL 跳转展示报告(比 document.write 可靠)
267+
window.location.href=URL.createObjectURL(new Blob([html],{type:'text/html'}));
268+
}catch(err){ s('扫描失败:'+(err&&err.message||err)+'。请重试。'); btn.disabled=false; }
261269
});
262270
})();
263271
</script>`
@@ -291,6 +299,8 @@ button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;fo
291299
font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
292300
button:disabled{background:#94a3b8;cursor:default}
293301
form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
302+
.status{display:none;margin:10px 0 0;padding:10px 14px;border-radius:8px;background:#f1f5f9;
303+
color:#334155;font-size:13.5px;border-left:3px solid #cb0000;text-align:left}
294304
.foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
295305
.back{font-weight:600}
296306
</style></head><body>${body}</body></html>`

test-web.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env npx tsx
2+
// test-web.ts — web 扫描服务端到端集成测试(启真实 http 服务,打全部端点)
3+
4+
import { spawn } from 'child_process'
5+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
6+
import { join } from 'path'
7+
import { tmpdir } from 'os'
8+
9+
let passed = 0, failed = 0
10+
function test(name: string, cond: boolean, detail?: string) {
11+
if (cond) { passed++; console.log(` ✅ ${name}`) }
12+
else { failed++; console.log(` ❌ ${name}${detail ? ' — ' + detail : ''}`) }
13+
}
14+
15+
async function waitUp(url: string, ms = 8000): Promise<boolean> {
16+
const t0 = Date.now()
17+
while (Date.now() - t0 < ms) {
18+
try { const r = await fetch(url); if (r.ok) return true } catch { /* retry */ }
19+
await new Promise(r => setTimeout(r, 200))
20+
}
21+
return false
22+
}
23+
24+
function startServer(args: string[]): Promise<any> {
25+
const child = spawn('node', ['dist/cli.js', 'web', ...args], { stdio: 'ignore' })
26+
return Promise.resolve(child)
27+
}
28+
29+
async function main() {
30+
console.log('\n========== ShellWard Web 服务集成测试 ==========\n')
31+
32+
// ---- 本地模式 ----
33+
console.log('--- 本地模式 (web --local) ---')
34+
const localPort = 8211
35+
const localSrv = await startServer(['--local', '--port', String(localPort)])
36+
const base = `http://localhost:${localPort}`
37+
const up = await waitUp(base + '/')
38+
test('服务启动并响应', up)
39+
if (up) {
40+
const home = await (await fetch(base + '/')).text()
41+
test('首页含「选择本地项目文件夹」', home.includes('选择本地项目文件夹'))
42+
test('首页含上传脚本 /scan-files', home.includes('scan-files'))
43+
test('首页含 URL 入口', home.includes('公开仓库地址'))
44+
45+
// 模拟浏览器上传(客户端读文件夹后发的 JSON)
46+
const payload = {
47+
root: 'myproj',
48+
files: [
49+
{ path: 'myproj/package.json', content: '{"dependencies":{"openai":"^4"}}' },
50+
{ path: 'myproj/app.ts', content: 'const k="sk-RZ9mKp2QwLs7Yv3Nd8Tb1Hc4Xj6Pq"\nconst phone="13912345678"' },
51+
],
52+
}
53+
const upl = await fetch(base + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
54+
test('上传扫描返回 200', upl.status === 200, `status=${upl.status}`)
55+
const uhtml = await upl.text()
56+
test('上传报告是完整 HTML', uhtml.startsWith('<!DOCTYPE html>'))
57+
test('上传报告含 openai 依赖发现', uhtml.includes('openai'))
58+
test('上传报告含密钥发现', uhtml.includes('硬编码') || uhtml.includes('密钥'))
59+
test('上传报告含手机号发现', uhtml.includes('手机号'))
60+
61+
// 空上传
62+
const empty = await fetch(base + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: [] }) })
63+
test('空上传返回 400', empty.status === 400, `status=${empty.status}`)
64+
65+
// 路径穿越防护
66+
const eviltmp = mkdtempSync(join(tmpdir(), 'sw-evil-'))
67+
const evil = await fetch(base + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ root: 'x', files: [{ path: '../../../../' + eviltmp.replace(/^\//, '') + '/PWNED', content: 'x' }] }) })
68+
await evil.text()
69+
let pwned = false
70+
try { require('fs').statSync(join(eviltmp, 'PWNED')); pwned = true } catch { /* good */ }
71+
test('路径穿越被挡(未写出目标文件)', !pwned)
72+
rmSync(eviltmp, { recursive: true, force: true })
73+
74+
// 本地路径扫描
75+
const proj = mkdtempSync(join(tmpdir(), 'sw-proj-'))
76+
writeFileSync(join(proj, 'package.json'), '{"dependencies":{"@anthropic-ai/sdk":"^0.2"}}')
77+
const pathScan = await fetch(base + '/scan?path=' + encodeURIComponent(proj))
78+
test('本地路径扫描返回 200', pathScan.status === 200, `status=${pathScan.status}`)
79+
test('本地路径扫描含 Anthropic 发现', (await pathScan.text()).includes('Anthropic'))
80+
rmSync(proj, { recursive: true, force: true })
81+
82+
// 非法仓库 URL
83+
const bad = await fetch(base + '/scan?repo=' + encodeURIComponent('http://evil.com/a/b'))
84+
test('非法 URL 返回 400', bad.status === 400, `status=${bad.status}`)
85+
}
86+
try { localSrv.kill() } catch {}
87+
88+
// ---- 公网模式 ----
89+
console.log('\n--- 公网模式 (web) ---')
90+
const pubPort = 8212
91+
const pubSrv = await startServer([String(pubPort)])
92+
const pbase = `http://localhost:${pubPort}`
93+
const pup = await waitUp(pbase + '/')
94+
test('公网服务启动', pup)
95+
if (pup) {
96+
const phome = await (await fetch(pbase + '/')).text()
97+
test('公网首页不含本地上传(只 URL)', !phome.includes('选择本地项目文件夹') && phome.includes('公开仓库地址'))
98+
// 公网模式禁止本地路径扫描
99+
const blocked = await fetch(pbase + '/scan?path=/etc')
100+
test('公网模式拒绝本地路径扫描 (403)', blocked.status === 403, `status=${blocked.status}`)
101+
// 公网模式拒绝上传
102+
const noupload = await fetch(pbase + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"files":[]}' })
103+
test('公网模式拒绝上传 (403)', noupload.status === 403, `status=${noupload.status}`)
104+
}
105+
try { pubSrv.kill() } catch {}
106+
107+
console.log(`\n========== Web 测试: ${passed} 通过, ${failed} 失败 ==========\n`)
108+
if (failed > 0) process.exit(1)
109+
}
110+
111+
main().catch(e => { console.error('测试崩溃:', e); process.exit(1) })

0 commit comments

Comments
 (0)