shellward 0.7.0 → 0.7.2

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-273%20passing-brightgreen)](#performance)
11
+ [![tests](https://img.shields.io/badge/tests-282%20passing-brightgreen)](#performance)
12
12
  [![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](#performance)
13
13
 
14
14
  **🌐 官网: https://jnmetacode.github.io/shellward/**
@@ -36,6 +36,10 @@ export interface ComplianceReport {
36
36
  generatedAt: string;
37
37
  /** 项目实测风险造成的扣分(仅项目体检路径);0 表示纯控制项评分 */
38
38
  projectPenalty?: number;
39
+ /** 是否为静态扫描(未部署运行时):此时多数控制项不可验证,得分不代表完整合规 */
40
+ staticScan?: boolean;
41
+ /** 项目扫描的文件总数(静态扫描路径) */
42
+ filesScanned?: number;
39
43
  }
40
44
  /** 采集真实环境事实(运行时调用;测试可绕过直接注入 EnvFacts) */
41
45
  export declare function gatherEnvFacts(): EnvFacts;
@@ -127,6 +127,8 @@ export function runProjectComplianceAudit(config, root) {
127
127
  report.grade = gradeOf(report.score);
128
128
  report.projectPenalty = penalty;
129
129
  }
130
+ report.staticScan = true;
131
+ report.filesScanned = scan.filesScanned;
130
132
  return { report, scan };
131
133
  }
132
134
  const FINDING_PENALTY = { critical: 8, high: 4, medium: 1 };
@@ -40,23 +40,35 @@ export function renderHtmlReport(report, scan, locale, meta) {
40
40
  const when = report.generatedAt.slice(0, 19).replace('T', ' ');
41
41
  const S = [];
42
42
  // ===== 评分 Hero =====
43
+ // 诚实原则:静态扫描下多数控制项不可验证,不展示"优秀/A"式合规结论,
44
+ // 改以「风险发现数」为主指标,得分仅作"可观测项"的次要参考。
45
+ const findingsN = scan.findings.length;
46
+ const gradeLabel = report.staticScan ? t('可观测项', 'observable') : t(g.zh, g.en);
47
+ const verdict = report.staticScan
48
+ ? (findingsN === 0
49
+ ? { txt: t('未发现可观测风险', 'No observable risks'), c: '#16a34a', ic: '🟢' }
50
+ : { txt: t(`发现 ${findingsN} 项风险`, `${findingsN} risk(s) found`), c: '#dc2626', ic: '🔴' })
51
+ : { txt: t(g.zh, g.en), c: g.color, ic: '' };
43
52
  S.push(`
44
53
  <section class="hero">
45
54
  <div class="gauge" style="--p:${report.score};--c:${g.color}">
46
55
  <div class="gauge-in">
47
56
  <div class="gscore">${report.score}<small>/100</small></div>
48
- <div class="ggrade" style="color:${g.color}">${esc(report.grade)} · ${t(g.zh, g.en)}</div>
57
+ <div class="ggrade" style="color:${g.color}">${esc(report.grade)} · ${esc(gradeLabel)}</div>
49
58
  </div>
50
59
  </div>
51
60
  <div class="hero-side">
61
+ <div class="verdict" style="--vc:${verdict.c}">${verdict.ic} ${esc(verdict.txt)}</div>
52
62
  <div class="stat-row">
53
63
  ${stat('pass', '🟢', t('合规', 'Pass'), report.passed)}
54
64
  ${stat('warn', '🟡', t('部分', 'Partial'), report.warned)}
55
65
  ${stat('fail', '🔴', t('不合规', 'Fail'), report.failed)}
56
- ${stat('manual', '⚪', t('待确认', 'Review'), report.manual)}
66
+ ${stat('manual', '⚪', t('待核验', 'Review'), report.manual)}
57
67
  </div>
58
68
  ${report.projectPenalty ? `<div class="penalty">⚠ ${t('含项目实测风险扣分', 'Includes project-scan penalty')} <b>−${report.projectPenalty}</b></div>` : ''}
59
- <p class="hero-note">${t('得分基于本次可静态观测的项目风险。⚪ 待确认项需把 ShellWard 部署为运行时防护或人工核验。', 'Score reflects statically-observable project risk. ⚪ items need runtime deployment or manual review.')}</p>
69
+ <p class="hero-note">${report.staticScan
70
+ ? t(`⚠ 本次为静态扫描:已检查 ${report.filesScanned ?? scan.filesScanned} 个文件,仅评估可观测风险。<b>${report.manual} 项合规控制项未验证</b>(需部署 ShellWard 运行时或人工核验)——本报告不构成完整合规结论,得分仅供参考。`, `⚠ Static scan: checked ${report.filesScanned ?? scan.filesScanned} files for observable risk only. <b>${report.manual} controls unverified</b> — not a complete compliance verdict.`)
71
+ : t('得分基于已部署运行时的合规评估。', 'Score based on deployed-runtime assessment.')}</p>
60
72
  </div>
61
73
  </section>`);
62
74
  // ===== 项目实测风险 =====
@@ -225,6 +237,7 @@ section,.reg{padding:0 36px}
225
237
  .gscore small{font-size:15px;font-weight:500;color:var(--faint)}
226
238
  .ggrade{font-size:14px;font-weight:700;margin-top:6px}
227
239
  .hero-side{flex:1;min-width:0}
240
+ .verdict{font-size:17px;font-weight:800;color:var(--vc);margin:0 0 12px}
228
241
  .stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
229
242
  .stat{background:#fff;border:1px solid var(--line);border-radius:10px;padding:10px 12px;text-align:center}
230
243
  .stat .sn{font-size:22px;font-weight:800;line-height:1}
Binary file
@@ -31,21 +31,39 @@ export function renderComplianceReport(report, locale) {
31
31
  const bar = scoreBar(report.score);
32
32
  L.push(zh ? '## 总评' : '## Overall');
33
33
  L.push('');
34
- L.push(`**${zh ? '合规得分' : 'Score'}: ${report.score}/100 ${gradeBadge(report.grade)}**`);
35
- L.push('');
36
- L.push('```');
37
- L.push(`${bar} ${report.score}/100 [${report.grade}]`);
38
- L.push('```');
34
+ // 诚实原则:静态扫描下不报"优秀/A"式合规结论,以风险发现数为主指标
35
+ if (report.staticScan) {
36
+ const fn = report.failed + (report.projectPenalty ? 1 : 0);
37
+ L.push(zh
38
+ ? `**项目实测风险: ${report.failed === 0 && !report.projectPenalty ? '🟢 未发现可观测风险' : '🔴 发现风险,详见下方'}**`
39
+ : `**Observable risk: ${report.failed === 0 && !report.projectPenalty ? '🟢 none found' : '🔴 see below'}**`);
40
+ L.push('');
41
+ L.push(`${bar} ${report.score}/100 ${zh ? '(可观测合规项,仅供参考)' : '(observable controls only)'}`);
42
+ void fn;
43
+ }
44
+ else {
45
+ L.push(`**${zh ? '合规得分' : 'Score'}: ${report.score}/100 ${gradeBadge(report.grade)}**`);
46
+ L.push('');
47
+ L.push('```');
48
+ L.push(`${bar} ${report.score}/100 [${report.grade}]`);
49
+ L.push('```');
50
+ }
39
51
  L.push('');
40
52
  L.push(zh
41
- ? `🟢 合规 ${report.passed} | 🟡 部分 ${report.warned} | 🔴 不合规 ${report.failed} | ⚪ 待确认 ${report.manual} (共 ${report.total} 项)`
42
- : `🟢 Pass ${report.passed} | 🟡 Partial ${report.warned} | 🔴 Fail ${report.failed} | ⚪ Manual ${report.manual} (${report.total} controls)`);
53
+ ? `🟢 合规 ${report.passed} | 🟡 部分 ${report.warned} | 🔴 不合规 ${report.failed} | ⚪ 待核验 ${report.manual} (共 ${report.total} 项)`
54
+ : `🟢 Pass ${report.passed} | 🟡 Partial ${report.warned} | 🔴 Fail ${report.failed} | ⚪ Review ${report.manual} (${report.total} controls)`);
43
55
  if (report.projectPenalty && report.projectPenalty > 0) {
44
56
  L.push('');
45
57
  L.push(zh
46
58
  ? `> 含项目实测风险扣分 **-${report.projectPenalty}**(见下方「项目实测风险」)`
47
59
  : `> Includes **-${report.projectPenalty}** from project scan findings (see Project Scan Findings)`);
48
60
  }
61
+ if (report.staticScan) {
62
+ L.push('');
63
+ L.push(zh
64
+ ? `> ⚠ 本次为静态扫描:已检查 ${report.filesScanned ?? '?'} 个文件,仅评估可观测风险;**${report.manual} 项合规控制项未验证**(需部署 ShellWard 运行时或人工核验)。本报告不构成完整合规结论。`
65
+ : `> ⚠ Static scan: ${report.filesScanned ?? '?'} files checked; **${report.manual} controls unverified**. Not a complete compliance verdict.`);
66
+ }
49
67
  L.push('');
50
68
  // ===== 优先整改(fail 项,按严重度) =====
51
69
  const fails = report.results
@@ -14,15 +14,15 @@
14
14
  // - 并发上限,防滥用
15
15
  import { createServer } from 'http';
16
16
  import { spawn } from 'child_process';
17
- import { mkdtempSync, rmSync, existsSync, statSync } from 'fs';
17
+ import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs';
18
18
  import { tmpdir } from 'os';
19
- import { join, resolve } from 'path';
19
+ import { join, resolve, dirname, normalize, isAbsolute } from 'path';
20
20
  import { runProjectComplianceAudit } from '../compliance/audit.js';
21
21
  import { renderHtmlReport } from '../compliance/html-report.js';
22
22
  import { DEFAULT_CONFIG, resolveLocale } from '../types.js';
23
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;
24
+ const CLONE_TIMEOUT_MS = 60_000;
25
+ const MAX_CONCURRENT = 4;
26
26
  /** 校验仓库 URL:仅允许白名单代码托管域名,拒绝带凭据/异常字符 */
27
27
  export function validateRepoUrl(input) {
28
28
  const url = (input || '').trim();
@@ -44,6 +44,14 @@ export function startWebServer(opts) {
44
44
  if (u.pathname === '/' || u.pathname === '') {
45
45
  return send(res, 200, 'text/html', formPage(!!opts.local));
46
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
+ }
47
55
  if (u.pathname === '/scan') {
48
56
  if (active >= MAX_CONCURRENT) {
49
57
  return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'));
@@ -93,7 +101,9 @@ async function handleRepo(res, repo, locale, inc, dec) {
93
101
  send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url }));
94
102
  }
95
103
  catch (e) {
96
- send(res, 502, 'text/html', errorPage('克隆/扫描失败:' + esc(e?.message || String(e)) + '。请确认是可公开访问的仓库。'));
104
+ const msg = esc(e?.message || String(e));
105
+ send(res, 502, 'text/html', errorPage(`克隆/扫描失败:${msg}。<br><br>可能原因:仓库过大(克隆超时 60s)、私有仓库、或地址有误。<br>` +
106
+ `<b>大仓库 / 私有代码请用本地客户端</b>(选文件夹、不上传):<code>npx shellward web --local</code>,或命令行 <code>npx shellward scan</code>。`));
97
107
  }
98
108
  finally {
99
109
  dec();
@@ -117,6 +127,72 @@ async function handleLocal(res, path, locale, inc, dec) {
117
127
  dec();
118
128
  }
119
129
  }
130
+ const MAX_UPLOAD_BYTES = 16 * 1024 * 1024; // 16MB JSON 上限
131
+ /** 本地上传:客户端把选中的文件夹读成 {path,content}[] 发来,写入临时目录后扫描 */
132
+ async function handleUpload(req, res, locale, inc, dec) {
133
+ let body = '';
134
+ let size = 0;
135
+ let aborted = false;
136
+ await new Promise((resolveBody) => {
137
+ req.on('data', (c) => {
138
+ size += c.length;
139
+ if (size > MAX_UPLOAD_BYTES) {
140
+ aborted = true;
141
+ req.destroy();
142
+ resolveBody();
143
+ return;
144
+ }
145
+ body += c.toString('utf8');
146
+ });
147
+ req.on('end', () => resolveBody());
148
+ req.on('error', () => { aborted = true; resolveBody(); });
149
+ });
150
+ if (aborted)
151
+ return send(res, 413, 'text/html', errorPage('内容过大或读取失败(上限 16MB)。大项目请用本地 CLI:npx shellward scan'));
152
+ let payload;
153
+ try {
154
+ payload = JSON.parse(body);
155
+ }
156
+ catch {
157
+ return send(res, 400, 'text/html', errorPage('上传数据格式错误'));
158
+ }
159
+ const files = Array.isArray(payload.files) ? payload.files : [];
160
+ if (files.length === 0)
161
+ return send(res, 400, 'text/html', errorPage('未选择任何文件'));
162
+ const dir = mkdtempSync(join(tmpdir(), 'sw-up-'));
163
+ inc();
164
+ try {
165
+ for (const f of files) {
166
+ if (!f || typeof f.path !== 'string' || typeof f.content !== 'string')
167
+ continue;
168
+ // 路径安全:去掉绝对路径/.. 逃逸,落在临时目录内
169
+ const rel = normalize(f.path).replace(/^(\.\.(\/|\\|$))+/, '');
170
+ if (isAbsolute(rel) || rel.includes('..'))
171
+ continue;
172
+ const dest = join(dir, rel);
173
+ if (!dest.startsWith(dir))
174
+ continue;
175
+ try {
176
+ mkdirSync(dirname(dest), { recursive: true });
177
+ writeFileSync(dest, f.content);
178
+ }
179
+ catch { /* skip */ }
180
+ }
181
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir);
182
+ const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)';
183
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName }));
184
+ }
185
+ catch (e) {
186
+ send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))));
187
+ }
188
+ finally {
189
+ dec();
190
+ try {
191
+ rmSync(dir, { recursive: true, force: true });
192
+ }
193
+ catch { /* ignore */ }
194
+ }
195
+ }
120
196
  /** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
121
197
  function cloneRepo(url, dir) {
122
198
  return new Promise((res, rej) => {
@@ -139,26 +215,65 @@ function send(res, code, type, body) {
139
215
  res.end(body);
140
216
  }
141
217
  function formPage(local) {
142
- const field = local
143
- ? `<label>本地项目路径</label>
144
- <input name="path" placeholder="/Users/you/your-ai-project" autofocus>
145
- <p class="hint">本地模式:代码不上传、不出本机(客户端体验)。</p>`
146
- : `<label>公开仓库地址</label>
147
- <input name="repo" placeholder="https://github.com/owner/repo" autofocus>
148
- <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。<b>私有/敏感代码请用本地 CLI</b>:<code>npx shellward scan</code>(不上传)。</p>`;
218
+ const urlForm = `
219
+ <form action="/scan" method="get" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='扫描中…(大仓库需 10–60 秒,请勿重复点击)';">
220
+ <label>${local ? '② ' : ''}公开仓库地址</label>
221
+ <input name="repo" placeholder="https://github.com/owner/repo"${local ? '' : ' autofocus'}>
222
+ <button type="submit">${local ? '体检该仓库 →' : '开始体检 →'}</button>
223
+ <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。大仓库可能超时——${local ? '此时改用上方「选择文件夹」更稳。' : '<b>大仓库 / 私有代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。'}</p>
224
+ </form>`;
225
+ const uploadForm = local ? `
226
+ <form id="dirform">
227
+ <label>① 选择本地项目文件夹(推荐)</label>
228
+ <input type="file" id="dir" webkitdirectory directory multiple>
229
+ <button id="dbtn" type="submit">开始体检 →</button>
230
+ <p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
231
+ </form>
232
+ <div class="or">— 或 —</div>` : '';
149
233
  return page('ShellWard 合规体检', `
150
234
  <div class="hero">
151
235
  <div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
152
236
  <h1>AI 应用合规体检</h1>
153
- <p class="sub">${local ? '填本地路径' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
154
- <form action="/scan" method="get">
155
- ${field}
156
- <button type="submit">开始体检 →</button>
157
- </form>
237
+ <p class="sub">${local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
238
+ ${uploadForm}
239
+ ${urlForm}
158
240
  <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
159
241
  <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
160
- </div>`);
242
+ </div>
243
+ ${local ? UPLOAD_SCRIPT : ''}`);
161
244
  }
245
+ // 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
246
+ const UPLOAD_SCRIPT = `<script>
247
+ (function(){
248
+ var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
249
+ var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv)$/i;
250
+ var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
251
+ var form=document.getElementById('dirform'); if(!form) return;
252
+ form.addEventListener('submit', async function(e){
253
+ e.preventDefault();
254
+ var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
255
+ if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
256
+ btn.disabled=true; btn.textContent='读取中…';
257
+ var picked=[], total=0, root='';
258
+ for(var i=0;i<inp.files.length;i++){
259
+ var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
260
+ if(SKIP.test(rel)) continue;
261
+ var base=rel.split('/').pop();
262
+ if(!(EXT.test(rel)||ENV.test(rel)||DEP.test(base))) continue;
263
+ if(f.size>524288) continue;
264
+ if(picked.length>=3000||total>8388608) break;
265
+ total+=f.size; picked.push(f);
266
+ }
267
+ if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
268
+ btn.textContent='扫描中… ('+picked.length+' 个文件)';
269
+ 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(_){} }
270
+ try{
271
+ var resp=await fetch('/scan-files',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({root:root,files:out})});
272
+ var html=await resp.text(); document.open(); document.write(html); document.close();
273
+ }catch(err){ alert('扫描失败: '+err); btn.disabled=false; btn.textContent='开始体检 →'; }
274
+ });
275
+ })();
276
+ </script>`;
162
277
  function errorPage(msg) {
163
278
  return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
164
279
  <h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
@@ -182,8 +297,11 @@ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16
182
297
  input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
183
298
  .hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
184
299
  .hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
300
+ input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
185
301
  button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
186
302
  font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
303
+ button:disabled{background:#94a3b8;cursor:default}
304
+ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
187
305
  .foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
188
306
  .back{font-weight:600}
189
307
  </style></head><body>${body}</body></html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellward",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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": [
@@ -57,6 +57,10 @@ export interface ComplianceReport {
57
57
  generatedAt: string
58
58
  /** 项目实测风险造成的扣分(仅项目体检路径);0 表示纯控制项评分 */
59
59
  projectPenalty?: number
60
+ /** 是否为静态扫描(未部署运行时):此时多数控制项不可验证,得分不代表完整合规 */
61
+ staticScan?: boolean
62
+ /** 项目扫描的文件总数(静态扫描路径) */
63
+ filesScanned?: number
60
64
  }
61
65
 
62
66
  /** 层能力映射:控制项 id → 必须启用的层(全部启用才 pass,部分启用 warn,全关 fail) */
@@ -191,6 +195,8 @@ export function runProjectComplianceAudit(config: ShellWardConfig, root: string)
191
195
  report.grade = gradeOf(report.score)
192
196
  report.projectPenalty = penalty
193
197
  }
198
+ report.staticScan = true
199
+ report.filesScanned = scan.filesScanned
194
200
 
195
201
  return { report, scan }
196
202
  }
@@ -60,25 +60,37 @@ export function renderHtmlReport(
60
60
  const S: string[] = []
61
61
 
62
62
  // ===== 评分 Hero =====
63
+ // 诚实原则:静态扫描下多数控制项不可验证,不展示"优秀/A"式合规结论,
64
+ // 改以「风险发现数」为主指标,得分仅作"可观测项"的次要参考。
65
+ const findingsN = scan.findings.length
66
+ const gradeLabel = report.staticScan ? t('可观测项', 'observable') : t(g.zh, g.en)
67
+ const verdict = report.staticScan
68
+ ? (findingsN === 0
69
+ ? { txt: t('未发现可观测风险', 'No observable risks'), c: '#16a34a', ic: '🟢' }
70
+ : { txt: t(`发现 ${findingsN} 项风险`, `${findingsN} risk(s) found`), c: '#dc2626', ic: '🔴' })
71
+ : { txt: t(g.zh, g.en), c: g.color, ic: '' }
72
+
63
73
  S.push(`
64
74
  <section class="hero">
65
75
  <div class="gauge" style="--p:${report.score};--c:${g.color}">
66
76
  <div class="gauge-in">
67
77
  <div class="gscore">${report.score}<small>/100</small></div>
68
- <div class="ggrade" style="color:${g.color}">${esc(report.grade)} · ${t(g.zh, g.en)}</div>
78
+ <div class="ggrade" style="color:${g.color}">${esc(report.grade)} · ${esc(gradeLabel)}</div>
69
79
  </div>
70
80
  </div>
71
81
  <div class="hero-side">
82
+ <div class="verdict" style="--vc:${verdict.c}">${verdict.ic} ${esc(verdict.txt)}</div>
72
83
  <div class="stat-row">
73
84
  ${stat('pass', '🟢', t('合规', 'Pass'), report.passed)}
74
85
  ${stat('warn', '🟡', t('部分', 'Partial'), report.warned)}
75
86
  ${stat('fail', '🔴', t('不合规', 'Fail'), report.failed)}
76
- ${stat('manual', '⚪', t('待确认', 'Review'), report.manual)}
87
+ ${stat('manual', '⚪', t('待核验', 'Review'), report.manual)}
77
88
  </div>
78
89
  ${report.projectPenalty ? `<div class="penalty">⚠ ${t('含项目实测风险扣分', 'Includes project-scan penalty')} <b>−${report.projectPenalty}</b></div>` : ''}
79
- <p class="hero-note">${t(
80
- '得分基于本次可静态观测的项目风险。⚪ 待确认项需把 ShellWard 部署为运行时防护或人工核验。',
81
- 'Score reflects statically-observable project risk. items need runtime deployment or manual review.')}</p>
90
+ <p class="hero-note">${report.staticScan
91
+ ? t(`⚠ 本次为静态扫描:已检查 ${report.filesScanned ?? scan.filesScanned} 个文件,仅评估可观测风险。<b>${report.manual} 项合规控制项未验证</b>(需部署 ShellWard 运行时或人工核验)——本报告不构成完整合规结论,得分仅供参考。`,
92
+ `⚠ Static scan: checked ${report.filesScanned ?? scan.filesScanned} files for observable risk only. <b>${report.manual} controls unverified</b> — not a complete compliance verdict.`)
93
+ : t('得分基于已部署运行时的合规评估。', 'Score based on deployed-runtime assessment.')}</p>
82
94
  </div>
83
95
  </section>`)
84
96
 
@@ -263,6 +275,7 @@ section,.reg{padding:0 36px}
263
275
  .gscore small{font-size:15px;font-weight:500;color:var(--faint)}
264
276
  .ggrade{font-size:14px;font-weight:700;margin-top:6px}
265
277
  .hero-side{flex:1;min-width:0}
278
+ .verdict{font-size:17px;font-weight:800;color:var(--vc);margin:0 0 12px}
266
279
  .stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
267
280
  .stat{background:#fff;border:1px solid var(--line);border-radius:10px;padding:10px 12px;text-align:center}
268
281
  .stat .sn{font-size:22px;font-weight:800;line-height:1}
Binary file
@@ -41,21 +41,38 @@ export function renderComplianceReport(report: ComplianceReport, locale: 'zh' |
41
41
  const bar = scoreBar(report.score)
42
42
  L.push(zh ? '## 总评' : '## Overall')
43
43
  L.push('')
44
- L.push(`**${zh ? '合规得分' : 'Score'}: ${report.score}/100 ${gradeBadge(report.grade)}**`)
45
- L.push('')
46
- L.push('```')
47
- L.push(`${bar} ${report.score}/100 [${report.grade}]`)
48
- L.push('```')
44
+ // 诚实原则:静态扫描下不报"优秀/A"式合规结论,以风险发现数为主指标
45
+ if (report.staticScan) {
46
+ const fn = report.failed + (report.projectPenalty ? 1 : 0)
47
+ L.push(zh
48
+ ? `**项目实测风险: ${report.failed === 0 && !report.projectPenalty ? '🟢 未发现可观测风险' : '🔴 发现风险,详见下方'}**`
49
+ : `**Observable risk: ${report.failed === 0 && !report.projectPenalty ? '🟢 none found' : '🔴 see below'}**`)
50
+ L.push('')
51
+ L.push(`${bar} ${report.score}/100 ${zh ? '(可观测合规项,仅供参考)' : '(observable controls only)'}`)
52
+ void fn
53
+ } else {
54
+ L.push(`**${zh ? '合规得分' : 'Score'}: ${report.score}/100 ${gradeBadge(report.grade)}**`)
55
+ L.push('')
56
+ L.push('```')
57
+ L.push(`${bar} ${report.score}/100 [${report.grade}]`)
58
+ L.push('```')
59
+ }
49
60
  L.push('')
50
61
  L.push(zh
51
- ? `🟢 合规 ${report.passed} | 🟡 部分 ${report.warned} | 🔴 不合规 ${report.failed} | ⚪ 待确认 ${report.manual} (共 ${report.total} 项)`
52
- : `🟢 Pass ${report.passed} | 🟡 Partial ${report.warned} | 🔴 Fail ${report.failed} | ⚪ Manual ${report.manual} (${report.total} controls)`)
62
+ ? `🟢 合规 ${report.passed} | 🟡 部分 ${report.warned} | 🔴 不合规 ${report.failed} | ⚪ 待核验 ${report.manual} (共 ${report.total} 项)`
63
+ : `🟢 Pass ${report.passed} | 🟡 Partial ${report.warned} | 🔴 Fail ${report.failed} | ⚪ Review ${report.manual} (${report.total} controls)`)
53
64
  if (report.projectPenalty && report.projectPenalty > 0) {
54
65
  L.push('')
55
66
  L.push(zh
56
67
  ? `> 含项目实测风险扣分 **-${report.projectPenalty}**(见下方「项目实测风险」)`
57
68
  : `> Includes **-${report.projectPenalty}** from project scan findings (see Project Scan Findings)`)
58
69
  }
70
+ if (report.staticScan) {
71
+ L.push('')
72
+ L.push(zh
73
+ ? `> ⚠ 本次为静态扫描:已检查 ${report.filesScanned ?? '?'} 个文件,仅评估可观测风险;**${report.manual} 项合规控制项未验证**(需部署 ShellWard 运行时或人工核验)。本报告不构成完整合规结论。`
74
+ : `> ⚠ Static scan: ${report.filesScanned ?? '?'} files checked; **${report.manual} controls unverified**. Not a complete compliance verdict.`)
75
+ }
59
76
  L.push('')
60
77
 
61
78
  // ===== 优先整改(fail 项,按严重度) =====
@@ -15,16 +15,16 @@
15
15
 
16
16
  import { createServer } from 'http'
17
17
  import { spawn } from 'child_process'
18
- import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'
18
+ import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs'
19
19
  import { tmpdir } from 'os'
20
- import { join, resolve } from 'path'
20
+ import { join, resolve, dirname, normalize, isAbsolute } from 'path'
21
21
  import { runProjectComplianceAudit } from '../compliance/audit.js'
22
22
  import { renderHtmlReport } from '../compliance/html-report.js'
23
23
  import { DEFAULT_CONFIG, resolveLocale } from '../types.js'
24
24
 
25
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
26
+ const CLONE_TIMEOUT_MS = 60_000
27
+ const MAX_CONCURRENT = 4
28
28
 
29
29
  export interface WebServerOptions {
30
30
  port: number
@@ -52,6 +52,12 @@ export function startWebServer(opts: WebServerOptions): void {
52
52
  if (u.pathname === '/' || u.pathname === '') {
53
53
  return send(res, 200, 'text/html', formPage(!!opts.local))
54
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
+ }
55
61
  if (u.pathname === '/scan') {
56
62
  if (active >= MAX_CONCURRENT) {
57
63
  return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'))
@@ -99,7 +105,10 @@ async function handleRepo(res: any, repo: string, locale: 'zh' | 'en', inc: () =
99
105
  const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
100
106
  send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url }))
101
107
  } catch (e: any) {
102
- send(res, 502, 'text/html', errorPage('克隆/扫描失败:' + esc(e?.message || String(e)) + '。请确认是可公开访问的仓库。'))
108
+ const msg = esc(e?.message || String(e))
109
+ send(res, 502, 'text/html', errorPage(
110
+ `克隆/扫描失败:${msg}。<br><br>可能原因:仓库过大(克隆超时 60s)、私有仓库、或地址有误。<br>` +
111
+ `<b>大仓库 / 私有代码请用本地客户端</b>(选文件夹、不上传):<code>npx shellward web --local</code>,或命令行 <code>npx shellward scan</code>。`))
103
112
  } finally {
104
113
  dec()
105
114
  try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
@@ -120,6 +129,52 @@ async function handleLocal(res: any, path: string, locale: 'zh' | 'en', inc: ()
120
129
  }
121
130
  }
122
131
 
132
+ const MAX_UPLOAD_BYTES = 16 * 1024 * 1024 // 16MB JSON 上限
133
+
134
+ /** 本地上传:客户端把选中的文件夹读成 {path,content}[] 发来,写入临时目录后扫描 */
135
+ async function handleUpload(req: any, res: any, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
136
+ let body = ''
137
+ let size = 0
138
+ let aborted = false
139
+ await new Promise<void>((resolveBody) => {
140
+ req.on('data', (c: Buffer) => {
141
+ size += c.length
142
+ if (size > MAX_UPLOAD_BYTES) { aborted = true; req.destroy(); resolveBody(); return }
143
+ body += c.toString('utf8')
144
+ })
145
+ req.on('end', () => resolveBody())
146
+ req.on('error', () => { aborted = true; resolveBody() })
147
+ })
148
+ if (aborted) return send(res, 413, 'text/html', errorPage('内容过大或读取失败(上限 16MB)。大项目请用本地 CLI:npx shellward scan'))
149
+
150
+ let payload: { root?: string; files?: { path: string; content: string }[] }
151
+ try { payload = JSON.parse(body) } catch { return send(res, 400, 'text/html', errorPage('上传数据格式错误')) }
152
+ const files = Array.isArray(payload.files) ? payload.files : []
153
+ if (files.length === 0) return send(res, 400, 'text/html', errorPage('未选择任何文件'))
154
+
155
+ const dir = mkdtempSync(join(tmpdir(), 'sw-up-'))
156
+ inc()
157
+ try {
158
+ for (const f of files) {
159
+ if (!f || typeof f.path !== 'string' || typeof f.content !== 'string') continue
160
+ // 路径安全:去掉绝对路径/.. 逃逸,落在临时目录内
161
+ const rel = normalize(f.path).replace(/^(\.\.(\/|\\|$))+/, '')
162
+ if (isAbsolute(rel) || rel.includes('..')) continue
163
+ const dest = join(dir, rel)
164
+ if (!dest.startsWith(dir)) continue
165
+ try { mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, f.content) } catch { /* skip */ }
166
+ }
167
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
168
+ const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)'
169
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName }))
170
+ } catch (e: any) {
171
+ send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))))
172
+ } finally {
173
+ dec()
174
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
175
+ }
176
+ }
177
+
123
178
  /** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
124
179
  function cloneRepo(url: string, dir: string): Promise<void> {
125
180
  return new Promise((res, rej) => {
@@ -144,27 +199,69 @@ function send(res: any, code: number, type: string, body: string) {
144
199
  }
145
200
 
146
201
  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>`
202
+ const urlForm = `
203
+ <form action="/scan" method="get" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='扫描中…(大仓库需 10–60 秒,请勿重复点击)';">
204
+ <label>${local ? '② ' : ''}公开仓库地址</label>
205
+ <input name="repo" placeholder="https://github.com/owner/repo"${local ? '' : ' autofocus'}>
206
+ <button type="submit">${local ? '体检该仓库 →' : '开始体检 →'}</button>
207
+ <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。大仓库可能超时——${local ? '此时改用上方「选择文件夹」更稳。' : '<b>大仓库 / 私有代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。'}</p>
208
+ </form>`
209
+
210
+ const uploadForm = local ? `
211
+ <form id="dirform">
212
+ <label>① 选择本地项目文件夹(推荐)</label>
213
+ <input type="file" id="dir" webkitdirectory directory multiple>
214
+ <button id="dbtn" type="submit">开始体检 →</button>
215
+ <p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
216
+ </form>
217
+ <div class="or">— 或 —</div>` : ''
218
+
154
219
  return page('ShellWard 合规体检', `
155
220
  <div class="hero">
156
221
  <div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
157
222
  <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>
223
+ <p class="sub">${local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
224
+ ${uploadForm}
225
+ ${urlForm}
163
226
  <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
164
227
  <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
165
- </div>`)
228
+ </div>
229
+ ${local ? UPLOAD_SCRIPT : ''}`)
166
230
  }
167
231
 
232
+ // 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
233
+ const UPLOAD_SCRIPT = `<script>
234
+ (function(){
235
+ 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;
237
+ var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
238
+ var form=document.getElementById('dirform'); if(!form) return;
239
+ form.addEventListener('submit', async function(e){
240
+ e.preventDefault();
241
+ var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
242
+ if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
243
+ btn.disabled=true; btn.textContent='读取中…';
244
+ var picked=[], total=0, root='';
245
+ for(var i=0;i<inp.files.length;i++){
246
+ var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
247
+ if(SKIP.test(rel)) continue;
248
+ var base=rel.split('/').pop();
249
+ if(!(EXT.test(rel)||ENV.test(rel)||DEP.test(base))) continue;
250
+ if(f.size>524288) continue;
251
+ if(picked.length>=3000||total>8388608) break;
252
+ total+=f.size; picked.push(f);
253
+ }
254
+ if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
255
+ btn.textContent='扫描中… ('+picked.length+' 个文件)';
256
+ 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(_){} }
257
+ try{
258
+ 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='开始体检 →'; }
261
+ });
262
+ })();
263
+ </script>`
264
+
168
265
  function errorPage(msg: string): string {
169
266
  return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
170
267
  <h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
@@ -189,8 +286,11 @@ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16
189
286
  input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
190
287
  .hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
191
288
  .hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
289
+ input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
192
290
  button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
193
291
  font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
292
+ button:disabled{background:#94a3b8;cursor:default}
293
+ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
194
294
  .foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
195
295
  .back{font-weight:600}
196
296
  </style></head><body>${body}</body></html>`