shellward 0.6.8 → 0.7.0
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 +5 -1
- package/dist/cli.js +11 -0
- package/dist/compliance/html-report.js +222 -84
- package/dist/web/scan-server.d.ts +14 -0
- package/dist/web/scan-server.js +193 -0
- package/package.json +1 -1
- package/src/cli.ts +12 -0
- package/src/compliance/html-report.ts +236 -85
- package/src/web/scan-server.ts +201 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// src/compliance/html-report.ts — HTML
|
|
1
|
+
// src/compliance/html-report.ts — HTML 合规报告(合规包)
|
|
2
2
|
//
|
|
3
3
|
// 终端输出给开发者看;这份 HTML 给法务/合规/测评机构看 —— 可在浏览器打开、
|
|
4
4
|
// 打印成 PDF、用于等保/PIPL 备案存档。自包含(内联 CSS、零外部依赖、无需联网)。
|
|
5
|
+
//
|
|
6
|
+
// 设计目标:专业、可信、克制——环形评分仪表、语义化状态药丸、severity 彩色标签、
|
|
7
|
+
// 卡片化分组、品牌色克制使用、清晰层级。
|
|
5
8
|
|
|
6
9
|
import { REGULATION_NAMES } from './regulations.js'
|
|
7
10
|
import type { Regulation } from './regulations.js'
|
|
@@ -13,19 +16,29 @@ const STATUS: Record<ControlStatus, { zh: string; en: string; cls: string }> = {
|
|
|
13
16
|
pass: { zh: '合规', en: 'Pass', cls: 'pass' },
|
|
14
17
|
warn: { zh: '部分', en: 'Partial', cls: 'warn' },
|
|
15
18
|
fail: { zh: '不合规', en: 'Fail', cls: 'fail' },
|
|
16
|
-
manual: { zh: '待确认', en: '
|
|
19
|
+
manual: { zh: '待确认', en: 'Review', cls: 'manual' },
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
const KIND: Record<FindingKind, { zh: string; en: string }> = {
|
|
20
|
-
overseas: { zh: '数据出境风险', en: 'Data export risk' },
|
|
21
|
-
secret: { zh: '硬编码密钥', en: 'Hardcoded secret' },
|
|
22
|
-
pii: { zh: '个人信息暴露', en: 'PII exposure' },
|
|
23
|
-
'env-perm': { zh: '.env 权限', en: '.env permission' },
|
|
22
|
+
const KIND: Record<FindingKind, { zh: string; en: string; icon: string }> = {
|
|
23
|
+
overseas: { zh: '数据出境风险', en: 'Data export risk', icon: '🌐' },
|
|
24
|
+
secret: { zh: '硬编码密钥', en: 'Hardcoded secret', icon: '🔑' },
|
|
25
|
+
pii: { zh: '个人信息暴露', en: 'PII exposure', icon: '🪪' },
|
|
26
|
+
'env-perm': { zh: '.env 权限', en: '.env permission', icon: '📂' },
|
|
24
27
|
}
|
|
25
28
|
const KIND_ORDER: FindingKind[] = ['overseas', 'secret', 'pii', 'env-perm']
|
|
26
29
|
const REG_ORDER: Regulation[] = ['CSL', 'PIPL', 'MLPS', 'CBDT', 'GENAI']
|
|
27
30
|
|
|
28
|
-
const
|
|
31
|
+
const GRADE: Record<string, { color: string; zh: string; en: string }> = {
|
|
32
|
+
A: { color: '#16a34a', zh: '优秀', en: 'Excellent' },
|
|
33
|
+
B: { color: '#65a30d', zh: '良好', en: 'Good' },
|
|
34
|
+
C: { color: '#d97706', zh: '及格', en: 'Fair' },
|
|
35
|
+
D: { color: '#dc2626', zh: '不及格', en: 'Poor' },
|
|
36
|
+
}
|
|
37
|
+
const SEV: Record<string, { zh: string; en: string }> = {
|
|
38
|
+
critical: { zh: '严重', en: 'Critical' },
|
|
39
|
+
high: { zh: '高', en: 'High' },
|
|
40
|
+
medium: { zh: '中', en: 'Medium' },
|
|
41
|
+
}
|
|
29
42
|
|
|
30
43
|
export interface HtmlReportMeta {
|
|
31
44
|
/** 扫描的项目根 */
|
|
@@ -41,53 +54,69 @@ export function renderHtmlReport(
|
|
|
41
54
|
): string {
|
|
42
55
|
const zh = locale === 'zh'
|
|
43
56
|
const t = (z: string, e: string) => (zh ? z : e)
|
|
44
|
-
const
|
|
57
|
+
const g = GRADE[report.grade] || { color: '#475569', zh: report.grade, en: report.grade }
|
|
45
58
|
const when = report.generatedAt.slice(0, 19).replace('T', ' ')
|
|
46
59
|
|
|
47
|
-
const
|
|
60
|
+
const S: string[] = []
|
|
48
61
|
|
|
49
|
-
// =====
|
|
50
|
-
|
|
51
|
-
<section class="
|
|
52
|
-
<div class="gauge" style="--c:${
|
|
53
|
-
<div class="
|
|
54
|
-
|
|
62
|
+
// ===== 评分 Hero =====
|
|
63
|
+
S.push(`
|
|
64
|
+
<section class="hero">
|
|
65
|
+
<div class="gauge" style="--p:${report.score};--c:${g.color}">
|
|
66
|
+
<div class="gauge-in">
|
|
67
|
+
<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>
|
|
69
|
+
</div>
|
|
55
70
|
</div>
|
|
56
|
-
<div class="
|
|
57
|
-
<div class="
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
</
|
|
64
|
-
|
|
71
|
+
<div class="hero-side">
|
|
72
|
+
<div class="stat-row">
|
|
73
|
+
${stat('pass', '🟢', t('合规', 'Pass'), report.passed)}
|
|
74
|
+
${stat('warn', '🟡', t('部分', 'Partial'), report.warned)}
|
|
75
|
+
${stat('fail', '🔴', t('不合规', 'Fail'), report.failed)}
|
|
76
|
+
${stat('manual', '⚪', t('待确认', 'Review'), report.manual)}
|
|
77
|
+
</div>
|
|
78
|
+
${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>
|
|
65
82
|
</div>
|
|
66
83
|
</section>`)
|
|
67
84
|
|
|
68
85
|
// ===== 项目实测风险 =====
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
S.push(sectionHead('🔍', t('项目实测风险', 'Project Scan Findings'),
|
|
87
|
+
t(`已扫描 ${scan.filesScanned} 个文件${scan.truncated ? '(已达上限)' : ''}`,
|
|
88
|
+
`Scanned ${scan.filesScanned} files${scan.truncated ? ' (limit reached)' : ''}`)))
|
|
89
|
+
|
|
71
90
|
if (scan.findings.length === 0) {
|
|
72
|
-
|
|
91
|
+
S.push(`<div class="empty">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。',
|
|
92
|
+
'No hardcoded secrets, PII, or overseas endpoints found in project files.')}</div>`)
|
|
73
93
|
} else {
|
|
94
|
+
S.push('<div class="chips">')
|
|
95
|
+
for (const k of KIND_ORDER) if (scan.counts[k] > 0) {
|
|
96
|
+
S.push(`<span class="chip"><b>${scan.counts[k]}</b> ${KIND[k].icon} ${t(KIND[k].zh, KIND[k].en)}</span>`)
|
|
97
|
+
}
|
|
98
|
+
S.push('</div>')
|
|
74
99
|
for (const kind of KIND_ORDER) {
|
|
75
100
|
const items = scan.findings.filter(f => f.kind === kind)
|
|
76
101
|
if (items.length === 0) continue
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
+ `<th>${t('位置', 'Location')}</th><th>${t('说明', 'Detail')}</th><th>${t('严重度', 'Severity')}</th></tr></thead><tbody>`)
|
|
102
|
+
S.push(`<h3 class="sub">${KIND[kind].icon} ${t(KIND[kind].zh, KIND[kind].en)} <span class="n">${items.length}</span></h3>`)
|
|
103
|
+
S.push('<table class="tbl"><tbody>')
|
|
80
104
|
for (const f of items) {
|
|
81
105
|
const loc = f.line ? `${f.file}:${f.line}` : f.file
|
|
82
|
-
|
|
106
|
+
S.push(`<tr>
|
|
107
|
+
<td class="loc"><code>${esc(loc)}</code></td>
|
|
108
|
+
<td>${esc(f.detail)}</td>
|
|
109
|
+
<td class="right">${sevPill(f.severity, zh)}</td></tr>`)
|
|
83
110
|
}
|
|
84
|
-
|
|
111
|
+
S.push('</tbody></table>')
|
|
85
112
|
}
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
// ===== 境内合规替代建议 =====
|
|
89
116
|
const overseas = scan.findings.filter(f => f.kind === 'overseas')
|
|
90
117
|
if (overseas.length > 0) {
|
|
118
|
+
S.push(sectionHead('✅', t('境内合规替代建议', 'Domestic Compliance Alternatives'),
|
|
119
|
+
t('把数据出境风险变成可执行的迁移路径', 'Turn data-export risk into a migration path')))
|
|
91
120
|
const seen = new Set<string>()
|
|
92
121
|
const providers: { key: string; zh?: string; en?: string }[] = []
|
|
93
122
|
for (const f of overseas) {
|
|
@@ -96,45 +125,51 @@ export function renderHtmlReport(
|
|
|
96
125
|
seen.add(k)
|
|
97
126
|
providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en })
|
|
98
127
|
}
|
|
99
|
-
|
|
100
|
-
sections.push('<ul class="migrate">')
|
|
128
|
+
S.push('<div class="migrate">')
|
|
101
129
|
for (const p of providers) {
|
|
102
130
|
const s = suggestDomestic(p.key, p.zh, p.en)
|
|
103
|
-
|
|
131
|
+
const low = (zh ? s.difficulty_zh : s.difficulty_en).startsWith(zh ? '低' : 'Low')
|
|
132
|
+
S.push(`<div class="mrow"><b>${esc(zh ? s.overseas_zh : s.overseas_en)}</b>
|
|
133
|
+
<span class="mtag ${low ? 'low' : 'mid'}">${t('迁移', 'Migrate')}: ${esc(zh ? s.difficulty_zh : s.difficulty_en)}</span></div>`)
|
|
104
134
|
}
|
|
105
|
-
|
|
135
|
+
S.push('</div>')
|
|
106
136
|
const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives
|
|
107
|
-
|
|
137
|
+
S.push('<table class="tbl alts"><thead><tr>'
|
|
108
138
|
+ `<th>${t('境内模型', 'Domestic model')}</th><th>${t('厂商', 'Vendor')}</th><th>${t('OpenAI 兼容 base_url', 'OpenAI-compatible base_url')}</th></tr></thead><tbody>`)
|
|
109
139
|
for (const m of alts) {
|
|
110
|
-
|
|
140
|
+
S.push(`<tr><td><b>${esc(zh ? m.name_zh : m.name_en)}</b></td><td class="muted">${esc(m.vendor_zh)}</td><td class="loc"><code>${esc(m.baseUrl)}</code></td></tr>`)
|
|
111
141
|
}
|
|
112
|
-
|
|
113
|
-
|
|
142
|
+
S.push('</tbody></table>')
|
|
143
|
+
S.push(`<p class="note">💡 ${t('对使用 openai SDK 的项目:通常仅需把 base_url 与 api_key 换成上表任一境内模型即可,业务代码无需改动。',
|
|
144
|
+
'For openai-SDK projects: usually just swap base_url + api_key — no code change.')}</p>`)
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
// ===== 控制项明细 =====
|
|
117
|
-
|
|
148
|
+
S.push(sectionHead('📋', t('合规控制项明细', 'Compliance Controls'),
|
|
149
|
+
t('按法规分组;⚪ 项为运行时/人工核验', 'By regulation; ⚪ = runtime / manual review')))
|
|
118
150
|
const grouped = groupBy(report.results)
|
|
119
151
|
for (const reg of REG_ORDER) {
|
|
120
152
|
const items = grouped[reg]
|
|
121
153
|
if (!items || items.length === 0) continue
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
154
|
+
const p = items.filter(r => r.status === 'pass').length
|
|
155
|
+
const f = items.filter(r => r.status === 'fail').length
|
|
156
|
+
S.push(`<div class="reg">
|
|
157
|
+
<div class="reg-head"><span>${esc(zh ? REGULATION_NAMES[reg].zh : REGULATION_NAMES[reg].en)}</span>
|
|
158
|
+
<span class="reg-mini">${p ? `<i class="d pass"></i>${p}` : ''}${f ? `<i class="d fail"></i>${f}` : ''}</span></div>
|
|
159
|
+
<table class="tbl ctrl"><tbody>`)
|
|
125
160
|
for (const r of items) {
|
|
126
161
|
const st = STATUS[r.status]
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
S.push(`<tr class="${st.cls}">
|
|
163
|
+
<td class="st">${statusPill(r.status, zh)}</td>
|
|
164
|
+
<td class="ttl"><b>${esc(zh ? r.control.title_zh : r.control.title_en)}</b><span class="art">${esc(r.control.article)}</span></td>
|
|
165
|
+
<td class="${r.status === 'manual' ? 'faint' : ''}">${esc(zh ? r.detail_zh : r.detail_en)}</td></tr>`)
|
|
131
166
|
}
|
|
132
|
-
|
|
167
|
+
S.push('</tbody></table></div>')
|
|
133
168
|
}
|
|
134
169
|
|
|
135
170
|
const disclaimer = t(
|
|
136
171
|
'本报告由 ShellWard 合规网关自动生成,帮助评估并满足合规技术要求,不构成法律意见,亦不替代算法备案/定级备案/PIA 等主体责任。⚪ 待确认项需结合业务人工判定。',
|
|
137
|
-
'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice.')
|
|
172
|
+
'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice. ⚪ items require manual review.')
|
|
138
173
|
|
|
139
174
|
return `<!DOCTYPE html>
|
|
140
175
|
<html lang="${zh ? 'zh-CN' : 'en'}">
|
|
@@ -147,16 +182,37 @@ export function renderHtmlReport(
|
|
|
147
182
|
<body>
|
|
148
183
|
<main>
|
|
149
184
|
<header>
|
|
150
|
-
<
|
|
151
|
-
<
|
|
185
|
+
<div class="brand">🛡️ Shell<span>Ward</span> <em>${t('合规网关', 'Compliance Gateway')}</em></div>
|
|
186
|
+
<h1>${t('AI 应用合规体检报告', 'AI Application Compliance Report')}</h1>
|
|
187
|
+
<p class="meta">${t('生成', 'Generated')}: ${esc(when)} UTC · ${t('扫描目录', 'Path')}: <code>${esc(meta.root)}</code></p>
|
|
152
188
|
</header>
|
|
153
|
-
${
|
|
189
|
+
${S.join('\n')}
|
|
154
190
|
<footer>${esc(disclaimer)}</footer>
|
|
155
191
|
</main>
|
|
156
192
|
</body>
|
|
157
193
|
</html>`
|
|
158
194
|
}
|
|
159
195
|
|
|
196
|
+
// ===== 小组件 =====
|
|
197
|
+
|
|
198
|
+
function stat(cls: string, icon: string, label: string, n: number): string {
|
|
199
|
+
return `<div class="stat ${cls}"><div class="sn">${n}</div><div class="sl">${icon} ${label}</div></div>`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sectionHead(icon: string, title: string, sub: string): string {
|
|
203
|
+
return `<div class="shead"><h2>${icon} ${esc(title)}</h2><span>${esc(sub)}</span></div>`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function statusPill(s: ControlStatus, zh: boolean): string {
|
|
207
|
+
const st = STATUS[s]
|
|
208
|
+
return `<span class="pill ${st.cls}">${zh ? st.zh : st.en}</span>`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sevPill(sev: string, zh: boolean): string {
|
|
212
|
+
const s = SEV[sev] || { zh: sev, en: sev }
|
|
213
|
+
return `<span class="sev ${sev}">${zh ? s.zh : s.en}</span>`
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
function groupBy(results: ControlResult[]): Record<Regulation, ControlResult[]> {
|
|
161
217
|
const out = {} as Record<Regulation, ControlResult[]>
|
|
162
218
|
for (const r of results) (out[r.control.regulation] ||= []).push(r)
|
|
@@ -174,37 +230,132 @@ function esc(s: string): string {
|
|
|
174
230
|
}
|
|
175
231
|
|
|
176
232
|
const CSS = `
|
|
177
|
-
:root{
|
|
233
|
+
:root{
|
|
234
|
+
--ink:#0f172a;--muted:#64748b;--faint:#94a3b8;--line:#eaeef4;--bg:#eef1f6;--card:#fff;
|
|
235
|
+
--brand:#cb0000;
|
|
236
|
+
--pass:#16a34a;--pass-bg:#dcfce7;--warn:#b45309;--warn-bg:#fef3c7;
|
|
237
|
+
--fail:#dc2626;--fail-bg:#fee2e2;--manual:#64748b;--manual-bg:#eef2f7;
|
|
238
|
+
}
|
|
178
239
|
*{box-sizing:border-box}
|
|
179
|
-
body{margin:0;background:var(--bg);color:var(--ink);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
240
|
+
body{margin:0;background:var(--bg);color:var(--ink);
|
|
241
|
+
font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
|
242
|
+
-webkit-font-smoothing:antialiased}
|
|
243
|
+
main{max-width:880px;margin:28px auto;background:var(--card);border-radius:16px;
|
|
244
|
+
box-shadow:0 1px 3px rgba(15,23,42,.06),0 12px 32px rgba(15,23,42,.07);overflow:hidden}
|
|
245
|
+
header{padding:30px 36px 22px;background:linear-gradient(180deg,#fafbfd,#fff);border-bottom:1px solid var(--line)}
|
|
246
|
+
.brand{font-size:13px;font-weight:700;color:var(--ink);letter-spacing:.2px}
|
|
247
|
+
.brand span{color:var(--brand)}
|
|
248
|
+
.brand em{font-style:normal;color:var(--faint);font-weight:500;margin-left:4px}
|
|
249
|
+
h1{font-size:25px;margin:10px 0 6px;letter-spacing:-.3px}
|
|
185
250
|
.meta{color:var(--muted);font-size:13px;margin:0}
|
|
186
|
-
code{background:#f1f5f9;padding:1px
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.
|
|
191
|
-
|
|
192
|
-
.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.
|
|
201
|
-
.
|
|
202
|
-
.
|
|
203
|
-
.
|
|
204
|
-
.
|
|
205
|
-
.
|
|
206
|
-
.
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
|
|
251
|
+
.meta code{background:#f1f5f9;padding:1px 6px;border-radius:5px;font-size:12px}
|
|
252
|
+
section,.reg{padding:0 36px}
|
|
253
|
+
|
|
254
|
+
/* Hero 评分 */
|
|
255
|
+
.hero{display:flex;gap:32px;align-items:center;margin:26px 36px;padding:26px 28px;
|
|
256
|
+
background:linear-gradient(135deg,#f8fafc,#f1f5f9);border:1px solid var(--line);border-radius:14px}
|
|
257
|
+
.gauge{--p:0;--c:#475569;flex:none;width:148px;height:148px;border-radius:50%;
|
|
258
|
+
background:conic-gradient(var(--c) calc(var(--p)*1%),#e4e9f1 0);
|
|
259
|
+
display:grid;place-items:center;box-shadow:inset 0 0 0 1px rgba(15,23,42,.04)}
|
|
260
|
+
.gauge-in{width:116px;height:116px;border-radius:50%;background:#fff;display:flex;flex-direction:column;
|
|
261
|
+
align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(15,23,42,.08)}
|
|
262
|
+
.gscore{font-size:42px;font-weight:800;line-height:1;letter-spacing:-1px}
|
|
263
|
+
.gscore small{font-size:15px;font-weight:500;color:var(--faint)}
|
|
264
|
+
.ggrade{font-size:14px;font-weight:700;margin-top:6px}
|
|
265
|
+
.hero-side{flex:1;min-width:0}
|
|
266
|
+
.stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
|
267
|
+
.stat{background:#fff;border:1px solid var(--line);border-radius:10px;padding:10px 12px;text-align:center}
|
|
268
|
+
.stat .sn{font-size:22px;font-weight:800;line-height:1}
|
|
269
|
+
.stat .sl{font-size:12px;color:var(--muted);margin-top:3px;white-space:nowrap}
|
|
270
|
+
.stat.pass .sn{color:var(--pass)}.stat.warn .sn{color:var(--warn)}
|
|
271
|
+
.stat.fail .sn{color:var(--fail)}.stat.manual .sn{color:var(--manual)}
|
|
272
|
+
.penalty{margin-top:12px;display:inline-block;background:var(--fail-bg);color:var(--fail);
|
|
273
|
+
font-size:12.5px;font-weight:600;padding:5px 12px;border-radius:8px}
|
|
274
|
+
.hero-note{margin:12px 0 0;font-size:12.5px;color:var(--muted);line-height:1.55}
|
|
275
|
+
|
|
276
|
+
/* 段标题 */
|
|
277
|
+
.shead{display:flex;align-items:baseline;gap:12px;margin:34px 36px 14px;
|
|
278
|
+
padding-bottom:10px;border-bottom:2px solid var(--line)}
|
|
279
|
+
.shead h2{font-size:18px;margin:0;font-weight:700}
|
|
280
|
+
.shead span{font-size:12.5px;color:var(--faint)}
|
|
281
|
+
.sub{font-size:14px;font-weight:700;color:var(--ink);margin:18px 36px 8px}
|
|
282
|
+
.sub .n{display:inline-block;background:#eef2f7;color:var(--muted);font-size:12px;
|
|
283
|
+
padding:0 8px;border-radius:999px;margin-left:4px;font-weight:600}
|
|
284
|
+
.empty{margin:8px 36px;padding:16px 18px;background:var(--pass-bg);color:var(--pass);
|
|
285
|
+
border-radius:10px;font-weight:600;font-size:14px}
|
|
286
|
+
|
|
287
|
+
/* chips 概览 */
|
|
288
|
+
.chips{display:flex;flex-wrap:wrap;gap:8px;margin:6px 36px 4px}
|
|
289
|
+
.chip{background:#f1f5f9;border:1px solid var(--line);border-radius:999px;
|
|
290
|
+
padding:5px 13px;font-size:13px;color:var(--muted)}
|
|
291
|
+
.chip b{color:var(--ink);font-size:14px;margin-right:2px}
|
|
292
|
+
|
|
293
|
+
/* 表格 */
|
|
294
|
+
.tbl{width:calc(100% - 72px);margin:4px 36px 6px;border-collapse:separate;border-spacing:0;font-size:13.5px}
|
|
295
|
+
.tbl td,.tbl th{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
|
|
296
|
+
.tbl th{background:#f8fafc;color:var(--muted);font-weight:600;font-size:12.5px;
|
|
297
|
+
border-bottom:1px solid #e2e8f0}
|
|
298
|
+
.tbl tbody tr:hover{background:#fafbfd}
|
|
299
|
+
.tbl .right{text-align:right;white-space:nowrap}
|
|
300
|
+
.tbl .muted{color:var(--muted)}
|
|
301
|
+
.tbl .faint{color:var(--faint);font-size:13px}
|
|
302
|
+
.loc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;
|
|
303
|
+
background:#f1f5f9;color:#0f172a;padding:2px 7px;border-radius:5px;white-space:nowrap}
|
|
304
|
+
.alts th:first-child,.alts td:first-child{width:120px}
|
|
305
|
+
|
|
306
|
+
/* severity 标签 */
|
|
307
|
+
.sev{display:inline-block;font-size:11.5px;font-weight:700;padding:2px 9px;border-radius:999px}
|
|
308
|
+
.sev.critical{background:#fee2e2;color:#b91c1c}
|
|
309
|
+
.sev.high{background:#ffedd5;color:#c2410c}
|
|
310
|
+
.sev.medium{background:#fef3c7;color:#b45309}
|
|
311
|
+
|
|
312
|
+
/* 状态药丸 */
|
|
313
|
+
.pill{display:inline-block;font-size:12px;font-weight:700;padding:3px 11px;border-radius:999px;white-space:nowrap}
|
|
314
|
+
.pill.pass{background:var(--pass-bg);color:var(--pass)}
|
|
315
|
+
.pill.warn{background:var(--warn-bg);color:var(--warn)}
|
|
316
|
+
.pill.fail{background:var(--fail-bg);color:var(--fail)}
|
|
317
|
+
.pill.manual{background:var(--manual-bg);color:var(--manual)}
|
|
318
|
+
|
|
319
|
+
/* 境内替代 */
|
|
320
|
+
.migrate{margin:6px 36px 10px;display:flex;flex-direction:column;gap:8px}
|
|
321
|
+
.mrow{display:flex;align-items:center;gap:12px;font-size:14px}
|
|
322
|
+
.mtag{font-size:12px;font-weight:600;padding:3px 10px;border-radius:8px}
|
|
323
|
+
.mtag.low{background:var(--pass-bg);color:var(--pass)}
|
|
324
|
+
.mtag.mid{background:var(--warn-bg);color:var(--warn)}
|
|
325
|
+
.note{margin:8px 36px 4px;font-size:12.5px;color:var(--muted);background:#f8fafc;
|
|
326
|
+
border-left:3px solid var(--brand);padding:10px 14px;border-radius:0 8px 8px 0}
|
|
327
|
+
|
|
328
|
+
/* 法规分组 */
|
|
329
|
+
.reg{margin:14px 36px;padding:0;border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
|
330
|
+
.reg-head{display:flex;justify-content:space-between;align-items:center;
|
|
331
|
+
padding:11px 16px;background:#f8fafc;font-weight:700;font-size:14px;border-bottom:1px solid var(--line)}
|
|
332
|
+
.reg-mini{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted);font-weight:600}
|
|
333
|
+
.reg-mini .d{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:middle}
|
|
334
|
+
.reg-mini .d.pass{background:var(--pass)}.reg-mini .d.fail{background:var(--fail)}
|
|
335
|
+
.reg .tbl{width:100%;margin:0}
|
|
336
|
+
.reg .tbl td{padding:10px 16px}
|
|
337
|
+
.reg .tbl tr:last-child td{border-bottom:0}
|
|
338
|
+
.ctrl .st{width:78px}
|
|
339
|
+
.ctrl .ttl{width:210px}
|
|
340
|
+
.ctrl .ttl b{display:block;font-weight:600;font-size:13.5px}
|
|
341
|
+
.ctrl .art{display:block;color:var(--faint);font-size:11.5px;margin-top:2px}
|
|
342
|
+
.ctrl tr.fail{background:#fef6f6}
|
|
343
|
+
|
|
344
|
+
footer{margin-top:30px;padding:20px 36px 30px;border-top:1px solid var(--line);
|
|
345
|
+
color:var(--faint);font-size:11.5px;line-height:1.6;background:#fafbfd}
|
|
346
|
+
|
|
347
|
+
@media(max-width:640px){
|
|
348
|
+
main{margin:0;border-radius:0}
|
|
349
|
+
.hero{flex-direction:column;text-align:center;margin:18px}
|
|
350
|
+
.stat-row{grid-template-columns:repeat(2,1fr)}
|
|
351
|
+
section,.shead,.sub,.chips,.tbl,.migrate,.note,.reg{margin-left:16px;margin-right:16px}
|
|
352
|
+
.tbl{width:calc(100% - 32px)}
|
|
353
|
+
}
|
|
354
|
+
@media print{
|
|
355
|
+
body{background:#fff}
|
|
356
|
+
main{box-shadow:none;margin:0;max-width:none;border-radius:0}
|
|
357
|
+
.hero{background:#f8fafc}
|
|
358
|
+
.reg,.tbl tbody tr{break-inside:avoid}
|
|
359
|
+
h2,.shead{break-after:avoid}
|
|
360
|
+
}
|
|
210
361
|
`
|
|
@@ -0,0 +1,201 @@
|
|
|
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 } from 'fs'
|
|
19
|
+
import { tmpdir } from 'os'
|
|
20
|
+
import { join, resolve } 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
|
+
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
|
+
// 本地路径扫描:仅本地模式开放
|
|
63
|
+
if (path) {
|
|
64
|
+
if (!opts.local) 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
|
+
} catch (e: any) {
|
|
74
|
+
send(res, 500, 'text/html', errorPage('内部错误:' + esc(e?.message || String(e))))
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
server.on('error', (e: any) => {
|
|
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
|
+
} else {
|
|
87
|
+
console.log(`🌐 ShellWard 公开仓库合规扫描: ${url} (监听 ${host}:${opts.port})\n 贴公开仓库 URL 即可体检。私有代码请用本地 CLI: npx shellward scan`)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleRepo(res: any, repo: string, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
|
|
93
|
+
const v = validateRepoUrl(repo)
|
|
94
|
+
if (!v.ok) return send(res, 400, 'text/html', errorPage(v.reason))
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), 'sw-web-'))
|
|
96
|
+
inc()
|
|
97
|
+
try {
|
|
98
|
+
await cloneRepo(v.url, dir)
|
|
99
|
+
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
|
|
100
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url }))
|
|
101
|
+
} catch (e: any) {
|
|
102
|
+
send(res, 502, 'text/html', errorPage('克隆/扫描失败:' + esc(e?.message || String(e)) + '。请确认是可公开访问的仓库。'))
|
|
103
|
+
} finally {
|
|
104
|
+
dec()
|
|
105
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function handleLocal(res: any, path: string, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
|
|
110
|
+
const root = resolve(path)
|
|
111
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
112
|
+
return send(res, 400, 'text/html', errorPage('路径不存在或不是目录:' + esc(root)))
|
|
113
|
+
}
|
|
114
|
+
inc()
|
|
115
|
+
try {
|
|
116
|
+
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, root)
|
|
117
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root }))
|
|
118
|
+
} finally {
|
|
119
|
+
dec()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
|
|
124
|
+
function cloneRepo(url: string, dir: string): Promise<void> {
|
|
125
|
+
return new Promise((res, rej) => {
|
|
126
|
+
const p = spawn('git', ['clone', '--depth', '1', '--single-branch', '--no-tags', url, dir], {
|
|
127
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'true' },
|
|
128
|
+
timeout: CLONE_TIMEOUT_MS,
|
|
129
|
+
stdio: 'ignore',
|
|
130
|
+
})
|
|
131
|
+
p.on('error', rej)
|
|
132
|
+
p.on('close', (code, signal) => {
|
|
133
|
+
if (signal) return rej(new Error('克隆超时'))
|
|
134
|
+
code === 0 ? res() : rej(new Error('克隆失败 (git exit ' + code + ')'))
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ===== 页面 =====
|
|
140
|
+
|
|
141
|
+
function send(res: any, code: number, type: string, body: string) {
|
|
142
|
+
res.writeHead(code, { 'Content-Type': type + '; charset=utf-8', 'X-Content-Type-Options': 'nosniff' })
|
|
143
|
+
res.end(body)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
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>`
|
|
154
|
+
return page('ShellWard 合规体检', `
|
|
155
|
+
<div class="hero">
|
|
156
|
+
<div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
|
|
157
|
+
<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>
|
|
163
|
+
<p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
|
|
164
|
+
<a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
|
|
165
|
+
</div>`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function errorPage(msg: string): string {
|
|
169
|
+
return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
|
|
170
|
+
<h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
|
|
171
|
+
<p><a class="back" href="/">← 返回重试</a></p></div>`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function page(title: string, body: string): string {
|
|
175
|
+
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
|
176
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
|
|
177
|
+
<style>
|
|
178
|
+
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;
|
|
179
|
+
background:linear-gradient(135deg,#eef1f6,#e2e8f0);color:#0f172a;
|
|
180
|
+
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
|
181
|
+
.hero{background:#fff;max-width:560px;width:92%;margin:40px;padding:40px;border-radius:18px;
|
|
182
|
+
box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}
|
|
183
|
+
.logo{font-weight:800;font-size:15px}.logo span{color:#cb0000}
|
|
184
|
+
h1{font-size:30px;margin:14px 0 8px;letter-spacing:-.5px}
|
|
185
|
+
.sub{color:#64748b;margin:0 0 26px}
|
|
186
|
+
form{display:flex;flex-direction:column;gap:10px;text-align:left}
|
|
187
|
+
label{font-size:13px;font-weight:600;color:#475569}
|
|
188
|
+
input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16px;width:100%}
|
|
189
|
+
input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
|
|
190
|
+
.hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
|
|
191
|
+
.hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
|
|
192
|
+
button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
|
|
193
|
+
font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
|
|
194
|
+
.foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
|
|
195
|
+
.back{font-weight:600}
|
|
196
|
+
</style></head><body>${body}</body></html>`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function esc(s: string): string {
|
|
200
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
201
|
+
}
|