shellward 0.6.9 → 0.7.1

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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/shellward?color=cb0000&label=npm)](https://www.npmjs.com/package/shellward)
10
10
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
11
- [![tests](https://img.shields.io/badge/tests-263%20passing-brightgreen)](#performance)
11
+ [![tests](https://img.shields.io/badge/tests-273%20passing-brightgreen)](#performance)
12
12
  [![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](#performance)
13
13
 
14
14
  **🌐 官网: https://jnmetacode.github.io/shellward/**
@@ -39,6 +39,10 @@ npx shellward scan
39
39
 
40
40
  想在浏览器里看?`npx shellward scan --open`(扫完直接打开报告)或 `--serve`(本地 http://localhost 提供报告)——**数据全程不出本机**。
41
41
 
42
+ **Web 扫描器 / 客户端(双模式)**:
43
+ - `shellward web` — 公开仓库 web 扫描器:网页贴「公开仓库 URL」或用 `/scan?repo=URL` 链接体检(可部署,见 `Dockerfile`)。
44
+ - `shellward web --local` — 本地 web GUI(客户端体验):填本地路径扫描,**私有代码不上传、不出本机**,无需命令行。
45
+
42
46
  `--json` 供 CI · `--ci` 发现 critical 时让构建失败 · `--html report.html` 导出可打印成 PDF 的报告(备案/审计存档)· 也可作 [GitHub Action](#github-action-pr-compliance-gate) 接入 PR 门禁。
43
47
 
44
48
  > 检测重点:**境外大模型端点与 SDK 依赖(数据出境——中国独有、英文工具没有的概念)**、硬编码密钥、文件中的中文 PII、`.env` 暴露。扫到境外模型(如 `openai` 依赖)时,**直接给出境内合规替代**(通义千问 / DeepSeek / Kimi / 智谱)及其 OpenAI 兼容 `base_url`——多数迁移只需改一个 `base_url`。
package/dist/cli.js CHANGED
@@ -31,6 +31,13 @@ async function main() {
31
31
  await import('./mcp-server.js');
32
32
  return;
33
33
  }
34
+ if (cmd === 'web') {
35
+ const { startWebServer } = await import('./web/scan-server.js');
36
+ const local = argv.includes('--local');
37
+ const portArg = flagValue(argv, '--port') || argv.slice(1).find(a => /^\d+$/.test(a));
38
+ startWebServer({ port: Number(portArg) || 8080, local });
39
+ return;
40
+ }
34
41
  if (cmd === 'scan') {
35
42
  runScan(argv.slice(1));
36
43
  return;
@@ -178,6 +185,8 @@ Usage:
178
185
  shellward scan --html f Export a self-contained HTML report (print to PDF)
179
186
  shellward scan --open Scan and open the report in your browser (local)
180
187
  shellward scan --serve Scan and serve the report at http://localhost (local)
188
+ shellward web [port] Web scanner for public repo URLs (deploy this)
189
+ shellward web --local Local web GUI: scan a local path (private, no upload)
181
190
  shellward mcp Start MCP server (stdio)
182
191
  shellward --help
183
192
 
@@ -195,6 +204,8 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
195
204
  shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
196
205
  shellward scan --open 扫描并在浏览器打开报告(本地,方便看)
197
206
  shellward scan --serve 扫描并在 http://localhost 提供报告(本地服务)
207
+ shellward web [端口] 公开仓库 web 扫描器(贴 URL 体检,用于部署)
208
+ shellward web --local 本地 web GUI:填本地路径扫描(私有、不上传,客户端体验)
198
209
  shellward mcp 启动 MCP 服务器(stdio)
199
210
  shellward --help
200
211
 
@@ -0,0 +1,14 @@
1
+ export interface WebServerOptions {
2
+ port: number;
3
+ /** 本地模式:开放本地路径扫描、仅监听 127.0.0.1 */
4
+ local?: boolean;
5
+ }
6
+ /** 校验仓库 URL:仅允许白名单代码托管域名,拒绝带凭据/异常字符 */
7
+ export declare function validateRepoUrl(input: string): {
8
+ ok: true;
9
+ url: string;
10
+ } | {
11
+ ok: false;
12
+ reason: string;
13
+ };
14
+ export declare function startWebServer(opts: WebServerOptions): void;
@@ -0,0 +1,309 @@
1
+ // src/web/scan-server.ts — ShellWard 合规扫描 web 服务(零依赖)
2
+ //
3
+ // 双模式,一份代码两用:
4
+ // 1) 公网模式(部署):贴「公开仓库 URL」或访问 /scan?repo=URL → 浅克隆 + 扫描 + 出报告
5
+ // 公开仓库的代码本就公开,服务端扫描不涉及数据出境;私有代码引导用本地 CLI。
6
+ // 2) 本地模式(shellward web --local,仅 127.0.0.1):填「本地路径」扫描,私有代码不上传
7
+ // —— 这就是「客户端」体验(浏览器 GUI、不用命令行),但零 Electron 包袱。
8
+ //
9
+ // 安全加固:
10
+ // - 仓库 URL 域名白名单(github/gitlab/gitee...),严格正则,拒带凭据的 URL
11
+ // - 浅克隆 --depth 1 --single-branch,GIT_TERMINAL_PROMPT=0(不卡在私有库鉴权),30s 超时
12
+ // - 临时目录隔离,用完即删;扫描器只读文件、绝不执行仓库代码
13
+ // - 本地路径扫描仅在 --local 模式开放(公网模式拒绝 path 参数,防止扫服务器硬盘)
14
+ // - 并发上限,防滥用
15
+ import { createServer } from 'http';
16
+ import { spawn } from 'child_process';
17
+ import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs';
18
+ import { tmpdir } from 'os';
19
+ import { join, resolve, dirname, normalize, isAbsolute } from 'path';
20
+ import { runProjectComplianceAudit } from '../compliance/audit.js';
21
+ import { renderHtmlReport } from '../compliance/html-report.js';
22
+ import { DEFAULT_CONFIG, resolveLocale } from '../types.js';
23
+ const REPO_RE = /^https:\/\/(github\.com|gitlab\.com|gitee\.com|bitbucket\.org)\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?(?:\.git)?\/?$/;
24
+ const CLONE_TIMEOUT_MS = 30_000;
25
+ const MAX_CONCURRENT = 2;
26
+ /** 校验仓库 URL:仅允许白名单代码托管域名,拒绝带凭据/异常字符 */
27
+ export function validateRepoUrl(input) {
28
+ const url = (input || '').trim();
29
+ if (!url)
30
+ return { ok: false, reason: '请输入仓库地址' };
31
+ if (url.includes('@') || /\s/.test(url))
32
+ return { ok: false, reason: '地址含非法字符' };
33
+ if (!REPO_RE.test(url))
34
+ return { ok: false, reason: '仅支持 github.com / gitlab.com / gitee.com / bitbucket.org 的公开仓库 URL' };
35
+ return { ok: true, url };
36
+ }
37
+ export function startWebServer(opts) {
38
+ const locale = resolveLocale(DEFAULT_CONFIG);
39
+ const host = opts.local ? '127.0.0.1' : '0.0.0.0';
40
+ let active = 0;
41
+ const server = createServer(async (req, res) => {
42
+ try {
43
+ const u = new URL(req.url || '/', `http://localhost:${opts.port}`);
44
+ if (u.pathname === '/' || u.pathname === '') {
45
+ return send(res, 200, 'text/html', formPage(!!opts.local));
46
+ }
47
+ // 本地客户端:选文件夹上传(仅本地模式;数据只到 localhost、不出本机)
48
+ if (u.pathname === '/scan-files' && req.method === 'POST') {
49
+ if (!opts.local)
50
+ return send(res, 403, 'text/html', errorPage('公网模式不支持上传;请用「公开仓库 URL」。'));
51
+ if (active >= MAX_CONCURRENT)
52
+ return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'));
53
+ return await handleUpload(req, res, locale, () => { active++; }, () => { active--; });
54
+ }
55
+ if (u.pathname === '/scan') {
56
+ if (active >= MAX_CONCURRENT) {
57
+ return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'));
58
+ }
59
+ const repo = u.searchParams.get('repo');
60
+ const path = u.searchParams.get('path');
61
+ // 本地路径扫描:仅本地模式开放
62
+ if (path) {
63
+ if (!opts.local)
64
+ return send(res, 403, 'text/html', errorPage('公网模式不支持本地路径扫描;请用「公开仓库 URL」,私有代码请用本地 CLI:npx shellward scan'));
65
+ return await handleLocal(res, path, locale, () => { active++; }, () => { active--; });
66
+ }
67
+ if (repo) {
68
+ return await handleRepo(res, repo, locale, () => { active++; }, () => { active--; });
69
+ }
70
+ return send(res, 400, 'text/html', errorPage('缺少参数:repo(仓库 URL)' + (opts.local ? ' 或 path(本地路径)' : '')));
71
+ }
72
+ send(res, 404, 'text/html', errorPage('页面不存在'));
73
+ }
74
+ catch (e) {
75
+ send(res, 500, 'text/html', errorPage('内部错误:' + esc(e?.message || String(e))));
76
+ }
77
+ });
78
+ server.on('error', (e) => {
79
+ console.error(`[ShellWard web] 启动失败: ${e?.message}`);
80
+ process.exit(1);
81
+ });
82
+ server.listen(opts.port, host, () => {
83
+ const url = `http://localhost:${opts.port}`;
84
+ if (opts.local) {
85
+ console.log(`🌐 ShellWard 本地合规扫描(客户端模式): ${url}\n 填本地路径即可扫描,私有代码不上传、不出本机。Ctrl+C 退出。`);
86
+ }
87
+ else {
88
+ console.log(`🌐 ShellWard 公开仓库合规扫描: ${url} (监听 ${host}:${opts.port})\n 贴公开仓库 URL 即可体检。私有代码请用本地 CLI: npx shellward scan`);
89
+ }
90
+ });
91
+ }
92
+ async function handleRepo(res, repo, locale, inc, dec) {
93
+ const v = validateRepoUrl(repo);
94
+ if (!v.ok)
95
+ return send(res, 400, 'text/html', errorPage(v.reason));
96
+ const dir = mkdtempSync(join(tmpdir(), 'sw-web-'));
97
+ inc();
98
+ try {
99
+ await cloneRepo(v.url, dir);
100
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir);
101
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url }));
102
+ }
103
+ catch (e) {
104
+ send(res, 502, 'text/html', errorPage('克隆/扫描失败:' + esc(e?.message || String(e)) + '。请确认是可公开访问的仓库。'));
105
+ }
106
+ finally {
107
+ dec();
108
+ try {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ catch { /* ignore */ }
112
+ }
113
+ }
114
+ async function handleLocal(res, path, locale, inc, dec) {
115
+ const root = resolve(path);
116
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
117
+ return send(res, 400, 'text/html', errorPage('路径不存在或不是目录:' + esc(root)));
118
+ }
119
+ inc();
120
+ try {
121
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, root);
122
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root }));
123
+ }
124
+ finally {
125
+ dec();
126
+ }
127
+ }
128
+ const MAX_UPLOAD_BYTES = 16 * 1024 * 1024; // 16MB JSON 上限
129
+ /** 本地上传:客户端把选中的文件夹读成 {path,content}[] 发来,写入临时目录后扫描 */
130
+ async function handleUpload(req, res, locale, inc, dec) {
131
+ let body = '';
132
+ let size = 0;
133
+ let aborted = false;
134
+ await new Promise((resolveBody) => {
135
+ req.on('data', (c) => {
136
+ size += c.length;
137
+ if (size > MAX_UPLOAD_BYTES) {
138
+ aborted = true;
139
+ req.destroy();
140
+ resolveBody();
141
+ return;
142
+ }
143
+ body += c.toString('utf8');
144
+ });
145
+ req.on('end', () => resolveBody());
146
+ req.on('error', () => { aborted = true; resolveBody(); });
147
+ });
148
+ if (aborted)
149
+ return send(res, 413, 'text/html', errorPage('内容过大或读取失败(上限 16MB)。大项目请用本地 CLI:npx shellward scan'));
150
+ let payload;
151
+ try {
152
+ payload = JSON.parse(body);
153
+ }
154
+ catch {
155
+ return send(res, 400, 'text/html', errorPage('上传数据格式错误'));
156
+ }
157
+ const files = Array.isArray(payload.files) ? payload.files : [];
158
+ if (files.length === 0)
159
+ return send(res, 400, 'text/html', errorPage('未选择任何文件'));
160
+ const dir = mkdtempSync(join(tmpdir(), 'sw-up-'));
161
+ inc();
162
+ try {
163
+ for (const f of files) {
164
+ if (!f || typeof f.path !== 'string' || typeof f.content !== 'string')
165
+ continue;
166
+ // 路径安全:去掉绝对路径/.. 逃逸,落在临时目录内
167
+ const rel = normalize(f.path).replace(/^(\.\.(\/|\\|$))+/, '');
168
+ if (isAbsolute(rel) || rel.includes('..'))
169
+ continue;
170
+ const dest = join(dir, rel);
171
+ if (!dest.startsWith(dir))
172
+ continue;
173
+ try {
174
+ mkdirSync(dirname(dest), { recursive: true });
175
+ writeFileSync(dest, f.content);
176
+ }
177
+ catch { /* skip */ }
178
+ }
179
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir);
180
+ const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)';
181
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName }));
182
+ }
183
+ catch (e) {
184
+ send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))));
185
+ }
186
+ finally {
187
+ dec();
188
+ try {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ catch { /* ignore */ }
192
+ }
193
+ }
194
+ /** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
195
+ function cloneRepo(url, dir) {
196
+ return new Promise((res, rej) => {
197
+ const p = spawn('git', ['clone', '--depth', '1', '--single-branch', '--no-tags', url, dir], {
198
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'true' },
199
+ timeout: CLONE_TIMEOUT_MS,
200
+ stdio: 'ignore',
201
+ });
202
+ p.on('error', rej);
203
+ p.on('close', (code, signal) => {
204
+ if (signal)
205
+ return rej(new Error('克隆超时'));
206
+ code === 0 ? res() : rej(new Error('克隆失败 (git exit ' + code + ')'));
207
+ });
208
+ });
209
+ }
210
+ // ===== 页面 =====
211
+ function send(res, code, type, body) {
212
+ res.writeHead(code, { 'Content-Type': type + '; charset=utf-8', 'X-Content-Type-Options': 'nosniff' });
213
+ res.end(body);
214
+ }
215
+ function formPage(local) {
216
+ const urlForm = `
217
+ <form action="/scan" method="get">
218
+ <label>${local ? '② ' : ''}公开仓库地址</label>
219
+ <input name="repo" placeholder="https://github.com/owner/repo"${local ? '' : ' autofocus'}>
220
+ <button type="submit">${local ? '体检该仓库 →' : '开始体检 →'}</button>
221
+ <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。${local ? '' : '<b>私有/敏感代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。'}</p>
222
+ </form>`;
223
+ const uploadForm = local ? `
224
+ <form id="dirform">
225
+ <label>① 选择本地项目文件夹(推荐)</label>
226
+ <input type="file" id="dir" webkitdirectory directory multiple>
227
+ <button id="dbtn" type="submit">开始体检 →</button>
228
+ <p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
229
+ </form>
230
+ <div class="or">— 或 —</div>` : '';
231
+ return page('ShellWard 合规体检', `
232
+ <div class="hero">
233
+ <div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
234
+ <h1>AI 应用合规体检</h1>
235
+ <p class="sub">${local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
236
+ ${uploadForm}
237
+ ${urlForm}
238
+ <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
239
+ <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
240
+ </div>
241
+ ${local ? UPLOAD_SCRIPT : ''}`);
242
+ }
243
+ // 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
244
+ const UPLOAD_SCRIPT = `<script>
245
+ (function(){
246
+ var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
247
+ var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv)$/i;
248
+ var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
249
+ var form=document.getElementById('dirform'); if(!form) return;
250
+ form.addEventListener('submit', async function(e){
251
+ e.preventDefault();
252
+ var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
253
+ if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
254
+ btn.disabled=true; btn.textContent='读取中…';
255
+ var picked=[], total=0, root='';
256
+ for(var i=0;i<inp.files.length;i++){
257
+ var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
258
+ if(SKIP.test(rel)) continue;
259
+ var base=rel.split('/').pop();
260
+ if(!(EXT.test(rel)||ENV.test(rel)||DEP.test(base))) continue;
261
+ if(f.size>524288) continue;
262
+ if(picked.length>=3000||total>8388608) break;
263
+ total+=f.size; picked.push(f);
264
+ }
265
+ if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
266
+ btn.textContent='扫描中… ('+picked.length+' 个文件)';
267
+ 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(_){} }
268
+ try{
269
+ var resp=await fetch('/scan-files',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({root:root,files:out})});
270
+ var html=await resp.text(); document.open(); document.write(html); document.close();
271
+ }catch(err){ alert('扫描失败: '+err); btn.disabled=false; btn.textContent='开始体检 →'; }
272
+ });
273
+ })();
274
+ </script>`;
275
+ function errorPage(msg) {
276
+ return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
277
+ <h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
278
+ <p><a class="back" href="/">← 返回重试</a></p></div>`);
279
+ }
280
+ function page(title, body) {
281
+ return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
282
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
283
+ <style>
284
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;
285
+ background:linear-gradient(135deg,#eef1f6,#e2e8f0);color:#0f172a;
286
+ font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
287
+ .hero{background:#fff;max-width:560px;width:92%;margin:40px;padding:40px;border-radius:18px;
288
+ box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}
289
+ .logo{font-weight:800;font-size:15px}.logo span{color:#cb0000}
290
+ h1{font-size:30px;margin:14px 0 8px;letter-spacing:-.5px}
291
+ .sub{color:#64748b;margin:0 0 26px}
292
+ form{display:flex;flex-direction:column;gap:10px;text-align:left}
293
+ label{font-size:13px;font-weight:600;color:#475569}
294
+ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16px;width:100%}
295
+ input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
296
+ .hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
297
+ .hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
298
+ input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
299
+ button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
300
+ font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
301
+ button:disabled{background:#94a3b8;cursor:default}
302
+ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
303
+ .foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
304
+ .back{font-weight:600}
305
+ </style></head><body>${body}</body></html>`;
306
+ }
307
+ function esc(s) {
308
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
309
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellward",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
4
4
  "mcpName": "io.github.jnMetaCode/shellward",
5
5
  "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.",
6
6
  "keywords": [
package/src/cli.ts CHANGED
@@ -36,6 +36,14 @@ async function main() {
36
36
  return
37
37
  }
38
38
 
39
+ if (cmd === 'web') {
40
+ const { startWebServer } = await import('./web/scan-server.js')
41
+ const local = argv.includes('--local')
42
+ const portArg = flagValue(argv, '--port') || argv.slice(1).find(a => /^\d+$/.test(a))
43
+ startWebServer({ port: Number(portArg) || 8080, local })
44
+ return
45
+ }
46
+
39
47
  if (cmd === 'scan') {
40
48
  runScan(argv.slice(1))
41
49
  return
@@ -189,6 +197,8 @@ Usage:
189
197
  shellward scan --html f Export a self-contained HTML report (print to PDF)
190
198
  shellward scan --open Scan and open the report in your browser (local)
191
199
  shellward scan --serve Scan and serve the report at http://localhost (local)
200
+ shellward web [port] Web scanner for public repo URLs (deploy this)
201
+ shellward web --local Local web GUI: scan a local path (private, no upload)
192
202
  shellward mcp Start MCP server (stdio)
193
203
  shellward --help
194
204
 
@@ -205,6 +215,8 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
205
215
  shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
206
216
  shellward scan --open 扫描并在浏览器打开报告(本地,方便看)
207
217
  shellward scan --serve 扫描并在 http://localhost 提供报告(本地服务)
218
+ shellward web [端口] 公开仓库 web 扫描器(贴 URL 体检,用于部署)
219
+ shellward web --local 本地 web GUI:填本地路径扫描(私有、不上传,客户端体验)
208
220
  shellward mcp 启动 MCP 服务器(stdio)
209
221
  shellward --help
210
222
 
@@ -0,0 +1,298 @@
1
+ // src/web/scan-server.ts — ShellWard 合规扫描 web 服务(零依赖)
2
+ //
3
+ // 双模式,一份代码两用:
4
+ // 1) 公网模式(部署):贴「公开仓库 URL」或访问 /scan?repo=URL → 浅克隆 + 扫描 + 出报告
5
+ // 公开仓库的代码本就公开,服务端扫描不涉及数据出境;私有代码引导用本地 CLI。
6
+ // 2) 本地模式(shellward web --local,仅 127.0.0.1):填「本地路径」扫描,私有代码不上传
7
+ // —— 这就是「客户端」体验(浏览器 GUI、不用命令行),但零 Electron 包袱。
8
+ //
9
+ // 安全加固:
10
+ // - 仓库 URL 域名白名单(github/gitlab/gitee...),严格正则,拒带凭据的 URL
11
+ // - 浅克隆 --depth 1 --single-branch,GIT_TERMINAL_PROMPT=0(不卡在私有库鉴权),30s 超时
12
+ // - 临时目录隔离,用完即删;扫描器只读文件、绝不执行仓库代码
13
+ // - 本地路径扫描仅在 --local 模式开放(公网模式拒绝 path 参数,防止扫服务器硬盘)
14
+ // - 并发上限,防滥用
15
+
16
+ import { createServer } from 'http'
17
+ import { spawn } from 'child_process'
18
+ import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs'
19
+ import { tmpdir } from 'os'
20
+ import { join, resolve, dirname, normalize, isAbsolute } from 'path'
21
+ import { runProjectComplianceAudit } from '../compliance/audit.js'
22
+ import { renderHtmlReport } from '../compliance/html-report.js'
23
+ import { DEFAULT_CONFIG, resolveLocale } from '../types.js'
24
+
25
+ const REPO_RE = /^https:\/\/(github\.com|gitlab\.com|gitee\.com|bitbucket\.org)\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?(?:\.git)?\/?$/
26
+ const CLONE_TIMEOUT_MS = 30_000
27
+ const MAX_CONCURRENT = 2
28
+
29
+ export interface WebServerOptions {
30
+ port: number
31
+ /** 本地模式:开放本地路径扫描、仅监听 127.0.0.1 */
32
+ local?: boolean
33
+ }
34
+
35
+ /** 校验仓库 URL:仅允许白名单代码托管域名,拒绝带凭据/异常字符 */
36
+ export function validateRepoUrl(input: string): { ok: true; url: string } | { ok: false; reason: string } {
37
+ const url = (input || '').trim()
38
+ if (!url) return { ok: false, reason: '请输入仓库地址' }
39
+ if (url.includes('@') || /\s/.test(url)) return { ok: false, reason: '地址含非法字符' }
40
+ if (!REPO_RE.test(url)) return { ok: false, reason: '仅支持 github.com / gitlab.com / gitee.com / bitbucket.org 的公开仓库 URL' }
41
+ return { ok: true, url }
42
+ }
43
+
44
+ export function startWebServer(opts: WebServerOptions): void {
45
+ const locale = resolveLocale(DEFAULT_CONFIG)
46
+ const host = opts.local ? '127.0.0.1' : '0.0.0.0'
47
+ let active = 0
48
+
49
+ const server = createServer(async (req, res) => {
50
+ try {
51
+ const u = new URL(req.url || '/', `http://localhost:${opts.port}`)
52
+ if (u.pathname === '/' || u.pathname === '') {
53
+ return send(res, 200, 'text/html', formPage(!!opts.local))
54
+ }
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
+ }
61
+ if (u.pathname === '/scan') {
62
+ if (active >= MAX_CONCURRENT) {
63
+ return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'))
64
+ }
65
+ const repo = u.searchParams.get('repo')
66
+ const path = u.searchParams.get('path')
67
+
68
+ // 本地路径扫描:仅本地模式开放
69
+ if (path) {
70
+ if (!opts.local) return send(res, 403, 'text/html', errorPage('公网模式不支持本地路径扫描;请用「公开仓库 URL」,私有代码请用本地 CLI:npx shellward scan'))
71
+ return await handleLocal(res, path, locale, () => { active++ }, () => { active-- })
72
+ }
73
+ if (repo) {
74
+ return await handleRepo(res, repo, locale, () => { active++ }, () => { active-- })
75
+ }
76
+ return send(res, 400, 'text/html', errorPage('缺少参数:repo(仓库 URL)' + (opts.local ? ' 或 path(本地路径)' : '')))
77
+ }
78
+ send(res, 404, 'text/html', errorPage('页面不存在'))
79
+ } catch (e: any) {
80
+ send(res, 500, 'text/html', errorPage('内部错误:' + esc(e?.message || String(e))))
81
+ }
82
+ })
83
+
84
+ server.on('error', (e: any) => {
85
+ console.error(`[ShellWard web] 启动失败: ${e?.message}`)
86
+ process.exit(1)
87
+ })
88
+ server.listen(opts.port, host, () => {
89
+ const url = `http://localhost:${opts.port}`
90
+ if (opts.local) {
91
+ console.log(`🌐 ShellWard 本地合规扫描(客户端模式): ${url}\n 填本地路径即可扫描,私有代码不上传、不出本机。Ctrl+C 退出。`)
92
+ } else {
93
+ console.log(`🌐 ShellWard 公开仓库合规扫描: ${url} (监听 ${host}:${opts.port})\n 贴公开仓库 URL 即可体检。私有代码请用本地 CLI: npx shellward scan`)
94
+ }
95
+ })
96
+ }
97
+
98
+ async function handleRepo(res: any, repo: string, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
99
+ const v = validateRepoUrl(repo)
100
+ if (!v.ok) return send(res, 400, 'text/html', errorPage(v.reason))
101
+ const dir = mkdtempSync(join(tmpdir(), 'sw-web-'))
102
+ inc()
103
+ try {
104
+ await cloneRepo(v.url, dir)
105
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
106
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url }))
107
+ } catch (e: any) {
108
+ send(res, 502, 'text/html', errorPage('克隆/扫描失败:' + esc(e?.message || String(e)) + '。请确认是可公开访问的仓库。'))
109
+ } finally {
110
+ dec()
111
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
112
+ }
113
+ }
114
+
115
+ async function handleLocal(res: any, path: string, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
116
+ const root = resolve(path)
117
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
118
+ return send(res, 400, 'text/html', errorPage('路径不存在或不是目录:' + esc(root)))
119
+ }
120
+ inc()
121
+ try {
122
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, root)
123
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root }))
124
+ } finally {
125
+ dec()
126
+ }
127
+ }
128
+
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
+
175
+ /** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
176
+ function cloneRepo(url: string, dir: string): Promise<void> {
177
+ return new Promise((res, rej) => {
178
+ const p = spawn('git', ['clone', '--depth', '1', '--single-branch', '--no-tags', url, dir], {
179
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'true' },
180
+ timeout: CLONE_TIMEOUT_MS,
181
+ stdio: 'ignore',
182
+ })
183
+ p.on('error', rej)
184
+ p.on('close', (code, signal) => {
185
+ if (signal) return rej(new Error('克隆超时'))
186
+ code === 0 ? res() : rej(new Error('克隆失败 (git exit ' + code + ')'))
187
+ })
188
+ })
189
+ }
190
+
191
+ // ===== 页面 =====
192
+
193
+ function send(res: any, code: number, type: string, body: string) {
194
+ res.writeHead(code, { 'Content-Type': type + '; charset=utf-8', 'X-Content-Type-Options': 'nosniff' })
195
+ res.end(body)
196
+ }
197
+
198
+ function formPage(local: boolean): string {
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
+
216
+ return page('ShellWard 合规体检', `
217
+ <div class="hero">
218
+ <div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
219
+ <h1>AI 应用合规体检</h1>
220
+ <p class="sub">${local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
221
+ ${uploadForm}
222
+ ${urlForm}
223
+ <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
224
+ <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
225
+ </div>
226
+ ${local ? UPLOAD_SCRIPT : ''}`)
227
+ }
228
+
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
+
262
+ function errorPage(msg: string): string {
263
+ return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
264
+ <h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
265
+ <p><a class="back" href="/">← 返回重试</a></p></div>`)
266
+ }
267
+
268
+ function page(title: string, body: string): string {
269
+ return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
270
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
271
+ <style>
272
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;
273
+ background:linear-gradient(135deg,#eef1f6,#e2e8f0);color:#0f172a;
274
+ font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
275
+ .hero{background:#fff;max-width:560px;width:92%;margin:40px;padding:40px;border-radius:18px;
276
+ box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}
277
+ .logo{font-weight:800;font-size:15px}.logo span{color:#cb0000}
278
+ h1{font-size:30px;margin:14px 0 8px;letter-spacing:-.5px}
279
+ .sub{color:#64748b;margin:0 0 26px}
280
+ form{display:flex;flex-direction:column;gap:10px;text-align:left}
281
+ label{font-size:13px;font-weight:600;color:#475569}
282
+ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16px;width:100%}
283
+ input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
284
+ .hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
285
+ .hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
286
+ input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
287
+ button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
288
+ 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}
291
+ .foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
292
+ .back{font-weight:600}
293
+ </style></head><body>${body}</body></html>`
294
+ }
295
+
296
+ function esc(s: string): string {
297
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
298
+ }