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 +1 -1
- package/dist/compliance/audit.d.ts +4 -0
- package/dist/compliance/audit.js +2 -0
- package/dist/compliance/html-report.js +16 -3
- package/dist/compliance/project-scan.js +0 -0
- package/dist/compliance/report.js +25 -7
- package/dist/web/scan-server.js +136 -18
- package/package.json +1 -1
- package/src/compliance/audit.ts +6 -0
- package/src/compliance/html-report.ts +18 -5
- package/src/compliance/project-scan.ts +0 -0
- package/src/compliance/report.ts +24 -7
- package/src/web/scan-server.ts +118 -18
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/shellward)
|
|
10
10
|
[](./LICENSE)
|
|
11
|
-
[](#performance)
|
|
12
12
|
[](#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;
|
package/dist/compliance/audit.js
CHANGED
|
@@ -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)} · ${
|
|
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('
|
|
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">${
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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} | ⚪
|
|
42
|
-
: `🟢 Pass ${report.passed} | 🟡 Partial ${report.warned} | 🔴 Fail ${report.failed} | ⚪
|
|
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
|
package/dist/web/scan-server.js
CHANGED
|
@@ -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 =
|
|
25
|
-
const MAX_CONCURRENT =
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
:
|
|
147
|
-
|
|
148
|
-
|
|
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 ? '
|
|
154
|
-
|
|
155
|
-
|
|
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.
|
|
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": [
|
package/src/compliance/audit.ts
CHANGED
|
@@ -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)} · ${
|
|
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('
|
|
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">${
|
|
80
|
-
|
|
81
|
-
|
|
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
|
package/src/compliance/report.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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} | ⚪
|
|
52
|
-
: `🟢 Pass ${report.passed} | 🟡 Partial ${report.warned} | 🔴 Fail ${report.failed} | ⚪
|
|
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 项,按严重度) =====
|
package/src/web/scan-server.ts
CHANGED
|
@@ -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 =
|
|
27
|
-
const MAX_CONCURRENT =
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 ? '
|
|
159
|
-
|
|
160
|
-
|
|
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>`
|