shellward 0.6.3 → 0.6.5

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,9 +8,11 @@
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-242%20passing-brightgreen)](#performance)
11
+ [![tests](https://img.shields.io/badge/tests-256%20passing-brightgreen)](#performance)
12
12
  [![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](#performance)
13
13
 
14
+ **🌐 Website: https://jnmetacode.github.io/shellward/**
15
+
14
16
  [English](#demo) | [中文](#中文)
15
17
 
16
18
  ## 30-Second Compliance Scan
@@ -34,7 +36,7 @@ Outputs a red/yellow/green scorecard mapped to 网安法 / PIPL / 等保2.0 /
34
36
  合规得分: 75/100 [B] 🟢 8 | 🟡 3 | 🔴 1 | ⚪ 2
35
37
  ```
36
38
 
37
- `npx shellward scan --json` for CI · `--ci` to fail the build on critical findings · see [GitHub Action](#github-action-pr-compliance-gate).
39
+ `npx shellward scan --json` for CI · `--ci` to fail the build on critical findings · `--html report.html` for a self-contained report you can print to PDF for 备案/audit · see [GitHub Action](#github-action-pr-compliance-gate).
38
40
 
39
41
  > Detects overseas-LLM endpoints (**data-export risk** — a China-only concept English tools ignore), hardcoded secrets, Chinese PII in files, and `.env` exposure. When it finds an overseas model (e.g. an `openai` dependency), it **prescribes domestic compliant alternatives** (通义千问 / DeepSeek / Kimi / 智谱) with their OpenAI-compatible `base_url` — most migrations are just a `base_url` swap.
40
42
 
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { writeFileSync } from 'fs';
13
13
  import { ShellWard } from './core/engine.js';
14
14
  import { runProjectComplianceAudit } from './compliance/audit.js';
15
15
  import { renderComplianceReport, renderProjectFindings } from './compliance/report.js';
16
+ import { renderHtmlReport } from './compliance/html-report.js';
16
17
  import { resolveLocale } from './types.js';
17
18
  const argv = process.argv.slice(2);
18
19
  const wantsHelp = argv.includes('--help') || argv.includes('-h') || argv[0] === 'help';
@@ -39,6 +40,7 @@ function runScan(args) {
39
40
  const json = args.includes('--json');
40
41
  const ci = args.includes('--ci');
41
42
  const outPath = flagValue(args, '--out');
43
+ const htmlPath = flagValue(args, '--html');
42
44
  const dirArg = args.find(a => !a.startsWith('-'));
43
45
  const root = resolve(dirArg || process.cwd());
44
46
  // 用环境变量解析 locale;layers/mode 用默认(代表「采用 ShellWard 默认部署」的合规覆盖)
@@ -67,6 +69,13 @@ function runScan(args) {
67
69
  })),
68
70
  }, null, 2) + '\n');
69
71
  }
72
+ else if (htmlPath) {
73
+ const html = renderHtmlReport(report, scan, locale, { root });
74
+ writeFileSync(resolve(htmlPath), html, 'utf-8');
75
+ process.stdout.write(zh
76
+ ? `✅ HTML 合规报告已导出: ${resolve(htmlPath)}\n 得分 ${report.score}/100 [${report.grade}],浏览器打开可打印成 PDF,供备案/审计存档。\n`
77
+ : `✅ HTML compliance report exported: ${resolve(htmlPath)}\n Score ${report.score}/100 [${report.grade}]. Open in a browser, print to PDF.\n`);
78
+ }
70
79
  else {
71
80
  // 头条:项目实测风险(关于「你的项目」)+ 合规映射评分卡
72
81
  const body = [
@@ -117,6 +126,7 @@ Usage:
117
126
  shellward scan --json Output JSON (for CI)
118
127
  shellward scan --ci Exit non-zero if critical findings
119
128
  shellward scan --out f Export the full report to a Markdown file
129
+ shellward scan --html f Export a self-contained HTML report (print to PDF)
120
130
  shellward mcp Start MCP server (stdio)
121
131
  shellward --help
122
132
 
@@ -131,6 +141,7 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
131
141
  shellward scan --json 输出 JSON(CI 用)
132
142
  shellward scan --ci 有 critical 发现时非零退出
133
143
  shellward scan --out 文件 导出完整报告为 Markdown(合规存档)
144
+ shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
134
145
  shellward mcp 启动 MCP 服务器(stdio)
135
146
  shellward --help
136
147
 
@@ -0,0 +1,8 @@
1
+ import type { ComplianceReport } from './audit.js';
2
+ import type { ProjectScanResult } from './project-scan.js';
3
+ export interface HtmlReportMeta {
4
+ /** 扫描的项目根 */
5
+ root: string;
6
+ }
7
+ /** 生成自包含 HTML 合规报告 */
8
+ export declare function renderHtmlReport(report: ComplianceReport, scan: ProjectScanResult, locale: 'zh' | 'en', meta: HtmlReportMeta): string;
@@ -0,0 +1,185 @@
1
+ // src/compliance/html-report.ts — HTML 合规报告(合规包雏形)
2
+ //
3
+ // 终端输出给开发者看;这份 HTML 给法务/合规/测评机构看 —— 可在浏览器打开、
4
+ // 打印成 PDF、用于等保/PIPL 备案存档。自包含(内联 CSS、零外部依赖、无需联网)。
5
+ import { REGULATION_NAMES } from './regulations.js';
6
+ import { suggestDomestic } from '../rules/domestic-alternatives.js';
7
+ const STATUS = {
8
+ pass: { zh: '合规', en: 'Pass', cls: 'pass' },
9
+ warn: { zh: '部分', en: 'Partial', cls: 'warn' },
10
+ fail: { zh: '不合规', en: 'Fail', cls: 'fail' },
11
+ manual: { zh: '待确认', en: 'Manual', cls: 'manual' },
12
+ };
13
+ const KIND = {
14
+ overseas: { zh: '数据出境风险', en: 'Data export risk' },
15
+ secret: { zh: '硬编码密钥', en: 'Hardcoded secret' },
16
+ pii: { zh: '个人信息暴露', en: 'PII exposure' },
17
+ 'env-perm': { zh: '.env 权限', en: '.env permission' },
18
+ };
19
+ const KIND_ORDER = ['overseas', 'secret', 'pii', 'env-perm'];
20
+ const REG_ORDER = ['CSL', 'PIPL', 'MLPS', 'CBDT', 'GENAI'];
21
+ const GRADE_COLOR = { A: '#15803d', B: '#65a30d', C: '#d97706', D: '#dc2626' };
22
+ /** 生成自包含 HTML 合规报告 */
23
+ export function renderHtmlReport(report, scan, locale, meta) {
24
+ const zh = locale === 'zh';
25
+ const t = (z, e) => (zh ? z : e);
26
+ const gradeColor = GRADE_COLOR[report.grade] || '#475569';
27
+ const when = report.generatedAt.slice(0, 19).replace('T', ' ');
28
+ const sections = [];
29
+ // ===== 评分卡 =====
30
+ sections.push(`
31
+ <section class="score-card">
32
+ <div class="gauge" style="--c:${gradeColor}">
33
+ <div class="score">${report.score}<span>/100</span></div>
34
+ <div class="grade" style="color:${gradeColor}">${esc(report.grade)}</div>
35
+ </div>
36
+ <div class="summary">
37
+ <div class="bar"><div class="fill" style="width:${report.score}%;background:${gradeColor}"></div></div>
38
+ <ul class="counts">
39
+ <li class="pass">🟢 ${t('合规', 'Pass')} ${report.passed}</li>
40
+ <li class="warn">🟡 ${t('部分', 'Partial')} ${report.warned}</li>
41
+ <li class="fail">🔴 ${t('不合规', 'Fail')} ${report.failed}</li>
42
+ <li class="manual">⚪ ${t('待确认', 'Manual')} ${report.manual}</li>
43
+ </ul>
44
+ ${report.projectPenalty ? `<p class="penalty">${t('含项目实测风险扣分', 'Includes project-scan penalty')} −${report.projectPenalty}</p>` : ''}
45
+ </div>
46
+ </section>`);
47
+ // ===== 项目实测风险 =====
48
+ sections.push(`<h2>🔍 ${t('项目实测风险', 'Project Scan Findings')}</h2>`);
49
+ sections.push(`<p class="muted">${t('已扫描', 'Scanned')} ${scan.filesScanned} ${t('个文件', 'files')}${scan.truncated ? t('(已达上限)', ' (limit reached)') : ''}</p>`);
50
+ if (scan.findings.length === 0) {
51
+ sections.push(`<p class="ok">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。', 'No hardcoded secrets, PII, or overseas endpoints found.')}</p>`);
52
+ }
53
+ else {
54
+ for (const kind of KIND_ORDER) {
55
+ const items = scan.findings.filter(f => f.kind === kind);
56
+ if (items.length === 0)
57
+ continue;
58
+ sections.push(`<h3>${t(KIND[kind].zh, KIND[kind].en)} (${items.length})</h3>`);
59
+ sections.push('<table class="findings"><thead><tr>'
60
+ + `<th>${t('位置', 'Location')}</th><th>${t('说明', 'Detail')}</th><th>${t('严重度', 'Severity')}</th></tr></thead><tbody>`);
61
+ for (const f of items) {
62
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
63
+ sections.push(`<tr><td class="loc">${esc(loc)}</td><td>${esc(f.detail)}</td><td class="sev-${f.severity}">${f.severity}</td></tr>`);
64
+ }
65
+ sections.push('</tbody></table>');
66
+ }
67
+ }
68
+ // ===== 境内合规替代建议 =====
69
+ const overseas = scan.findings.filter(f => f.kind === 'overseas');
70
+ if (overseas.length > 0) {
71
+ const seen = new Set();
72
+ const providers = [];
73
+ for (const f of overseas) {
74
+ const k = (f.endpointId || f.provider_en || f.provider_zh || '').toLowerCase();
75
+ if (!k || seen.has(k))
76
+ continue;
77
+ seen.add(k);
78
+ providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en });
79
+ }
80
+ sections.push(`<h2>✅ ${t('境内合规替代建议', 'Domestic Compliance Alternatives')}</h2>`);
81
+ sections.push('<ul class="migrate">');
82
+ for (const p of providers) {
83
+ const s = suggestDomestic(p.key, p.zh, p.en);
84
+ sections.push(`<li><b>${esc(zh ? s.overseas_zh : s.overseas_en)}</b> → ${esc(zh ? s.difficulty_zh : s.difficulty_en)}</li>`);
85
+ }
86
+ sections.push('</ul>');
87
+ const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives;
88
+ sections.push('<table class="findings"><thead><tr>'
89
+ + `<th>${t('境内模型', 'Domestic model')}</th><th>${t('厂商', 'Vendor')}</th><th>${t('OpenAI 兼容 base_url', 'OpenAI-compatible base_url')}</th></tr></thead><tbody>`);
90
+ for (const m of alts) {
91
+ sections.push(`<tr><td>${esc(zh ? m.name_zh : m.name_en)}</td><td>${esc(m.vendor_zh)}</td><td class="loc">${esc(m.baseUrl)}</td></tr>`);
92
+ }
93
+ sections.push('</tbody></table>');
94
+ sections.push(`<p class="muted">${t('对使用 openai SDK 的项目:通常仅需把 base_url 与 api_key 换成上表任一境内模型即可,业务代码无需改动。', 'For openai-SDK projects: usually just swap base_url + api_key — no code change.')}</p>`);
95
+ }
96
+ // ===== 控制项明细 =====
97
+ sections.push(`<h2>📋 ${t('合规控制项明细', 'Compliance Controls')}</h2>`);
98
+ const grouped = groupBy(report.results);
99
+ for (const reg of REG_ORDER) {
100
+ const items = grouped[reg];
101
+ if (!items || items.length === 0)
102
+ continue;
103
+ sections.push(`<h3>${esc(zh ? REGULATION_NAMES[reg].zh : REGULATION_NAMES[reg].en)}</h3>`);
104
+ sections.push('<table class="controls"><thead><tr>'
105
+ + `<th>${t('状态', 'Status')}</th><th>${t('控制项', 'Control')}</th><th>${t('条款', 'Article')}</th><th>${t('结论', 'Result')}</th></tr></thead><tbody>`);
106
+ for (const r of items) {
107
+ const st = STATUS[r.status];
108
+ sections.push(`<tr><td><span class="badge ${st.cls}">${zh ? st.zh : st.en}</span></td>`
109
+ + `<td>${esc(zh ? r.control.title_zh : r.control.title_en)}</td>`
110
+ + `<td class="loc">${esc(r.control.article)}</td>`
111
+ + `<td>${esc(zh ? r.detail_zh : r.detail_en)}</td></tr>`);
112
+ }
113
+ sections.push('</tbody></table>');
114
+ }
115
+ const disclaimer = t('本报告由 ShellWard 合规网关自动生成,帮助评估并满足合规技术要求,不构成法律意见,亦不替代算法备案/定级备案/PIA 等主体责任。⚪ 待确认项需结合业务人工判定。', 'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice.');
116
+ return `<!DOCTYPE html>
117
+ <html lang="${zh ? 'zh-CN' : 'en'}">
118
+ <head>
119
+ <meta charset="utf-8">
120
+ <meta name="viewport" content="width=device-width, initial-scale=1">
121
+ <title>${t('AI 应用合规体检报告', 'AI Compliance Report')}</title>
122
+ <style>${CSS}</style>
123
+ </head>
124
+ <body>
125
+ <main>
126
+ <header>
127
+ <h1>🛡️ ${t('AI 应用合规体检报告', 'AI Application Compliance Report')}</h1>
128
+ <p class="meta">${t('生成时间', 'Generated')}: ${esc(when)} UTC | ${t('扫描目录', 'Scanned')}: <code>${esc(meta.root)}</code></p>
129
+ </header>
130
+ ${sections.join('\n')}
131
+ <footer>${esc(disclaimer)}</footer>
132
+ </main>
133
+ </body>
134
+ </html>`;
135
+ }
136
+ function groupBy(results) {
137
+ const out = {};
138
+ for (const r of results)
139
+ (out[r.control.regulation] ||= []).push(r);
140
+ return out;
141
+ }
142
+ /** HTML 转义,防止文件路径/详情里的特殊字符破坏结构 */
143
+ function esc(s) {
144
+ return String(s)
145
+ .replace(/&/g, '&amp;')
146
+ .replace(/</g, '&lt;')
147
+ .replace(/>/g, '&gt;')
148
+ .replace(/"/g, '&quot;')
149
+ .replace(/'/g, '&#39;');
150
+ }
151
+ const CSS = `
152
+ :root{--ink:#1e293b;--muted:#64748b;--line:#e2e8f0;--bg:#f8fafc}
153
+ *{box-sizing:border-box}
154
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
155
+ main{max-width:920px;margin:0 auto;padding:32px 24px;background:#fff}
156
+ header{border-bottom:2px solid var(--line);padding-bottom:16px;margin-bottom:24px}
157
+ h1{font-size:24px;margin:0 0 6px}
158
+ h2{font-size:19px;margin:32px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
159
+ h3{font-size:15px;margin:20px 0 8px;color:var(--muted)}
160
+ .meta{color:var(--muted);font-size:13px;margin:0}
161
+ code{background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:13px}
162
+ .score-card{display:flex;gap:28px;align-items:center;background:#f1f5f9;border-radius:12px;padding:24px}
163
+ .gauge{text-align:center;min-width:120px}
164
+ .gauge .score{font-size:44px;font-weight:700;color:var(--c)}
165
+ .gauge .score span{font-size:18px;color:var(--muted);font-weight:400}
166
+ .gauge .grade{font-size:28px;font-weight:700}
167
+ .summary{flex:1}
168
+ .bar{height:14px;background:#e2e8f0;border-radius:7px;overflow:hidden;margin-bottom:12px}
169
+ .bar .fill{height:100%}
170
+ .counts{list-style:none;display:flex;gap:18px;flex-wrap:wrap;padding:0;margin:0;font-size:14px}
171
+ .penalty{color:#dc2626;font-size:13px;margin:8px 0 0}
172
+ table{width:100%;border-collapse:collapse;margin:8px 0 4px;font-size:13.5px}
173
+ th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
174
+ th{background:#f8fafc;font-weight:600;color:var(--muted)}
175
+ .loc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:#0f172a;white-space:nowrap}
176
+ .sev-critical{color:#dc2626;font-weight:600}.sev-high{color:#ea580c}.sev-medium{color:#d97706}
177
+ .badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}
178
+ .badge.pass{background:#dcfce7;color:#15803d}.badge.warn{background:#fef9c3;color:#a16207}
179
+ .badge.fail{background:#fee2e2;color:#b91c1c}.badge.manual{background:#e2e8f0;color:#475569}
180
+ .muted{color:var(--muted);font-size:13px}
181
+ .ok{color:#15803d;font-weight:600}
182
+ .migrate{margin:8px 0}
183
+ footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}
184
+ @media print{body{background:#fff}main{max-width:none;padding:0}h2{break-after:avoid}table{break-inside:auto}tr{break-inside:avoid}}
185
+ `;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellward",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "mcpName": "io.github.jnMetaCode/shellward",
5
5
  "description": "AI agent security & MCP security middleware — prompt injection detection, AI firewall, runtime guardrails & data-loss prevention for LLM tool calls. 8-layer defense against data exfiltration & dangerous commands. Zero dependencies. SDK + OpenClaw plugin. Supports LangChain, AutoGPT, Claude Code, Cursor, OpenAI Agents, Hermes Agent.",
6
6
  "keywords": [
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import { writeFileSync } from 'fs'
14
14
  import { ShellWard } from './core/engine.js'
15
15
  import { runProjectComplianceAudit } from './compliance/audit.js'
16
16
  import { renderComplianceReport, renderProjectFindings } from './compliance/report.js'
17
+ import { renderHtmlReport } from './compliance/html-report.js'
17
18
  import { resolveLocale } from './types.js'
18
19
 
19
20
  const argv = process.argv.slice(2)
@@ -46,6 +47,7 @@ function runScan(args: string[]) {
46
47
  const json = args.includes('--json')
47
48
  const ci = args.includes('--ci')
48
49
  const outPath = flagValue(args, '--out')
50
+ const htmlPath = flagValue(args, '--html')
49
51
  const dirArg = args.find(a => !a.startsWith('-'))
50
52
  const root = resolve(dirArg || process.cwd())
51
53
 
@@ -76,6 +78,12 @@ function runScan(args: string[]) {
76
78
  id: r.control.id, regulation: r.control.regulation, status: r.status,
77
79
  })),
78
80
  }, null, 2) + '\n')
81
+ } else if (htmlPath) {
82
+ const html = renderHtmlReport(report, scan, locale, { root })
83
+ writeFileSync(resolve(htmlPath), html, 'utf-8')
84
+ process.stdout.write(zh
85
+ ? `✅ HTML 合规报告已导出: ${resolve(htmlPath)}\n 得分 ${report.score}/100 [${report.grade}],浏览器打开可打印成 PDF,供备案/审计存档。\n`
86
+ : `✅ HTML compliance report exported: ${resolve(htmlPath)}\n Score ${report.score}/100 [${report.grade}]. Open in a browser, print to PDF.\n`)
79
87
  } else {
80
88
  // 头条:项目实测风险(关于「你的项目」)+ 合规映射评分卡
81
89
  const body = [
@@ -127,6 +135,7 @@ Usage:
127
135
  shellward scan --json Output JSON (for CI)
128
136
  shellward scan --ci Exit non-zero if critical findings
129
137
  shellward scan --out f Export the full report to a Markdown file
138
+ shellward scan --html f Export a self-contained HTML report (print to PDF)
130
139
  shellward mcp Start MCP server (stdio)
131
140
  shellward --help
132
141
 
@@ -140,6 +149,7 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
140
149
  shellward scan --json 输出 JSON(CI 用)
141
150
  shellward scan --ci 有 critical 发现时非零退出
142
151
  shellward scan --out 文件 导出完整报告为 Markdown(合规存档)
152
+ shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
143
153
  shellward mcp 启动 MCP 服务器(stdio)
144
154
  shellward --help
145
155
 
@@ -0,0 +1,210 @@
1
+ // src/compliance/html-report.ts — HTML 合规报告(合规包雏形)
2
+ //
3
+ // 终端输出给开发者看;这份 HTML 给法务/合规/测评机构看 —— 可在浏览器打开、
4
+ // 打印成 PDF、用于等保/PIPL 备案存档。自包含(内联 CSS、零外部依赖、无需联网)。
5
+
6
+ import { REGULATION_NAMES } from './regulations.js'
7
+ import type { Regulation } from './regulations.js'
8
+ import type { ComplianceReport, ControlResult, ControlStatus } from './audit.js'
9
+ import type { ProjectScanResult, FindingKind } from './project-scan.js'
10
+ import { suggestDomestic } from '../rules/domestic-alternatives.js'
11
+
12
+ const STATUS: Record<ControlStatus, { zh: string; en: string; cls: string }> = {
13
+ pass: { zh: '合规', en: 'Pass', cls: 'pass' },
14
+ warn: { zh: '部分', en: 'Partial', cls: 'warn' },
15
+ fail: { zh: '不合规', en: 'Fail', cls: 'fail' },
16
+ manual: { zh: '待确认', en: 'Manual', cls: 'manual' },
17
+ }
18
+
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' },
24
+ }
25
+ const KIND_ORDER: FindingKind[] = ['overseas', 'secret', 'pii', 'env-perm']
26
+ const REG_ORDER: Regulation[] = ['CSL', 'PIPL', 'MLPS', 'CBDT', 'GENAI']
27
+
28
+ const GRADE_COLOR: Record<string, string> = { A: '#15803d', B: '#65a30d', C: '#d97706', D: '#dc2626' }
29
+
30
+ export interface HtmlReportMeta {
31
+ /** 扫描的项目根 */
32
+ root: string
33
+ }
34
+
35
+ /** 生成自包含 HTML 合规报告 */
36
+ export function renderHtmlReport(
37
+ report: ComplianceReport,
38
+ scan: ProjectScanResult,
39
+ locale: 'zh' | 'en',
40
+ meta: HtmlReportMeta,
41
+ ): string {
42
+ const zh = locale === 'zh'
43
+ const t = (z: string, e: string) => (zh ? z : e)
44
+ const gradeColor = GRADE_COLOR[report.grade] || '#475569'
45
+ const when = report.generatedAt.slice(0, 19).replace('T', ' ')
46
+
47
+ const sections: string[] = []
48
+
49
+ // ===== 评分卡 =====
50
+ sections.push(`
51
+ <section class="score-card">
52
+ <div class="gauge" style="--c:${gradeColor}">
53
+ <div class="score">${report.score}<span>/100</span></div>
54
+ <div class="grade" style="color:${gradeColor}">${esc(report.grade)}</div>
55
+ </div>
56
+ <div class="summary">
57
+ <div class="bar"><div class="fill" style="width:${report.score}%;background:${gradeColor}"></div></div>
58
+ <ul class="counts">
59
+ <li class="pass">🟢 ${t('合规', 'Pass')} ${report.passed}</li>
60
+ <li class="warn">🟡 ${t('部分', 'Partial')} ${report.warned}</li>
61
+ <li class="fail">🔴 ${t('不合规', 'Fail')} ${report.failed}</li>
62
+ <li class="manual">⚪ ${t('待确认', 'Manual')} ${report.manual}</li>
63
+ </ul>
64
+ ${report.projectPenalty ? `<p class="penalty">${t('含项目实测风险扣分', 'Includes project-scan penalty')} −${report.projectPenalty}</p>` : ''}
65
+ </div>
66
+ </section>`)
67
+
68
+ // ===== 项目实测风险 =====
69
+ sections.push(`<h2>🔍 ${t('项目实测风险', 'Project Scan Findings')}</h2>`)
70
+ sections.push(`<p class="muted">${t('已扫描', 'Scanned')} ${scan.filesScanned} ${t('个文件', 'files')}${scan.truncated ? t('(已达上限)', ' (limit reached)') : ''}</p>`)
71
+ if (scan.findings.length === 0) {
72
+ sections.push(`<p class="ok">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。', 'No hardcoded secrets, PII, or overseas endpoints found.')}</p>`)
73
+ } else {
74
+ for (const kind of KIND_ORDER) {
75
+ const items = scan.findings.filter(f => f.kind === kind)
76
+ if (items.length === 0) continue
77
+ sections.push(`<h3>${t(KIND[kind].zh, KIND[kind].en)} (${items.length})</h3>`)
78
+ sections.push('<table class="findings"><thead><tr>'
79
+ + `<th>${t('位置', 'Location')}</th><th>${t('说明', 'Detail')}</th><th>${t('严重度', 'Severity')}</th></tr></thead><tbody>`)
80
+ for (const f of items) {
81
+ const loc = f.line ? `${f.file}:${f.line}` : f.file
82
+ sections.push(`<tr><td class="loc">${esc(loc)}</td><td>${esc(f.detail)}</td><td class="sev-${f.severity}">${f.severity}</td></tr>`)
83
+ }
84
+ sections.push('</tbody></table>')
85
+ }
86
+ }
87
+
88
+ // ===== 境内合规替代建议 =====
89
+ const overseas = scan.findings.filter(f => f.kind === 'overseas')
90
+ if (overseas.length > 0) {
91
+ const seen = new Set<string>()
92
+ const providers: { key: string; zh?: string; en?: string }[] = []
93
+ for (const f of overseas) {
94
+ const k = (f.endpointId || f.provider_en || f.provider_zh || '').toLowerCase()
95
+ if (!k || seen.has(k)) continue
96
+ seen.add(k)
97
+ providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en })
98
+ }
99
+ sections.push(`<h2>✅ ${t('境内合规替代建议', 'Domestic Compliance Alternatives')}</h2>`)
100
+ sections.push('<ul class="migrate">')
101
+ for (const p of providers) {
102
+ const s = suggestDomestic(p.key, p.zh, p.en)
103
+ sections.push(`<li><b>${esc(zh ? s.overseas_zh : s.overseas_en)}</b> → ${esc(zh ? s.difficulty_zh : s.difficulty_en)}</li>`)
104
+ }
105
+ sections.push('</ul>')
106
+ const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives
107
+ sections.push('<table class="findings"><thead><tr>'
108
+ + `<th>${t('境内模型', 'Domestic model')}</th><th>${t('厂商', 'Vendor')}</th><th>${t('OpenAI 兼容 base_url', 'OpenAI-compatible base_url')}</th></tr></thead><tbody>`)
109
+ for (const m of alts) {
110
+ sections.push(`<tr><td>${esc(zh ? m.name_zh : m.name_en)}</td><td>${esc(m.vendor_zh)}</td><td class="loc">${esc(m.baseUrl)}</td></tr>`)
111
+ }
112
+ sections.push('</tbody></table>')
113
+ sections.push(`<p class="muted">${t('对使用 openai SDK 的项目:通常仅需把 base_url 与 api_key 换成上表任一境内模型即可,业务代码无需改动。', 'For openai-SDK projects: usually just swap base_url + api_key — no code change.')}</p>`)
114
+ }
115
+
116
+ // ===== 控制项明细 =====
117
+ sections.push(`<h2>📋 ${t('合规控制项明细', 'Compliance Controls')}</h2>`)
118
+ const grouped = groupBy(report.results)
119
+ for (const reg of REG_ORDER) {
120
+ const items = grouped[reg]
121
+ if (!items || items.length === 0) continue
122
+ sections.push(`<h3>${esc(zh ? REGULATION_NAMES[reg].zh : REGULATION_NAMES[reg].en)}</h3>`)
123
+ sections.push('<table class="controls"><thead><tr>'
124
+ + `<th>${t('状态', 'Status')}</th><th>${t('控制项', 'Control')}</th><th>${t('条款', 'Article')}</th><th>${t('结论', 'Result')}</th></tr></thead><tbody>`)
125
+ for (const r of items) {
126
+ const st = STATUS[r.status]
127
+ sections.push(`<tr><td><span class="badge ${st.cls}">${zh ? st.zh : st.en}</span></td>`
128
+ + `<td>${esc(zh ? r.control.title_zh : r.control.title_en)}</td>`
129
+ + `<td class="loc">${esc(r.control.article)}</td>`
130
+ + `<td>${esc(zh ? r.detail_zh : r.detail_en)}</td></tr>`)
131
+ }
132
+ sections.push('</tbody></table>')
133
+ }
134
+
135
+ const disclaimer = t(
136
+ '本报告由 ShellWard 合规网关自动生成,帮助评估并满足合规技术要求,不构成法律意见,亦不替代算法备案/定级备案/PIA 等主体责任。⚪ 待确认项需结合业务人工判定。',
137
+ 'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice.')
138
+
139
+ return `<!DOCTYPE html>
140
+ <html lang="${zh ? 'zh-CN' : 'en'}">
141
+ <head>
142
+ <meta charset="utf-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <title>${t('AI 应用合规体检报告', 'AI Compliance Report')}</title>
145
+ <style>${CSS}</style>
146
+ </head>
147
+ <body>
148
+ <main>
149
+ <header>
150
+ <h1>🛡️ ${t('AI 应用合规体检报告', 'AI Application Compliance Report')}</h1>
151
+ <p class="meta">${t('生成时间', 'Generated')}: ${esc(when)} UTC | ${t('扫描目录', 'Scanned')}: <code>${esc(meta.root)}</code></p>
152
+ </header>
153
+ ${sections.join('\n')}
154
+ <footer>${esc(disclaimer)}</footer>
155
+ </main>
156
+ </body>
157
+ </html>`
158
+ }
159
+
160
+ function groupBy(results: ControlResult[]): Record<Regulation, ControlResult[]> {
161
+ const out = {} as Record<Regulation, ControlResult[]>
162
+ for (const r of results) (out[r.control.regulation] ||= []).push(r)
163
+ return out
164
+ }
165
+
166
+ /** HTML 转义,防止文件路径/详情里的特殊字符破坏结构 */
167
+ function esc(s: string): string {
168
+ return String(s)
169
+ .replace(/&/g, '&amp;')
170
+ .replace(/</g, '&lt;')
171
+ .replace(/>/g, '&gt;')
172
+ .replace(/"/g, '&quot;')
173
+ .replace(/'/g, '&#39;')
174
+ }
175
+
176
+ const CSS = `
177
+ :root{--ink:#1e293b;--muted:#64748b;--line:#e2e8f0;--bg:#f8fafc}
178
+ *{box-sizing:border-box}
179
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
180
+ main{max-width:920px;margin:0 auto;padding:32px 24px;background:#fff}
181
+ header{border-bottom:2px solid var(--line);padding-bottom:16px;margin-bottom:24px}
182
+ h1{font-size:24px;margin:0 0 6px}
183
+ h2{font-size:19px;margin:32px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
184
+ h3{font-size:15px;margin:20px 0 8px;color:var(--muted)}
185
+ .meta{color:var(--muted);font-size:13px;margin:0}
186
+ code{background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:13px}
187
+ .score-card{display:flex;gap:28px;align-items:center;background:#f1f5f9;border-radius:12px;padding:24px}
188
+ .gauge{text-align:center;min-width:120px}
189
+ .gauge .score{font-size:44px;font-weight:700;color:var(--c)}
190
+ .gauge .score span{font-size:18px;color:var(--muted);font-weight:400}
191
+ .gauge .grade{font-size:28px;font-weight:700}
192
+ .summary{flex:1}
193
+ .bar{height:14px;background:#e2e8f0;border-radius:7px;overflow:hidden;margin-bottom:12px}
194
+ .bar .fill{height:100%}
195
+ .counts{list-style:none;display:flex;gap:18px;flex-wrap:wrap;padding:0;margin:0;font-size:14px}
196
+ .penalty{color:#dc2626;font-size:13px;margin:8px 0 0}
197
+ table{width:100%;border-collapse:collapse;margin:8px 0 4px;font-size:13.5px}
198
+ th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
199
+ th{background:#f8fafc;font-weight:600;color:var(--muted)}
200
+ .loc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:#0f172a;white-space:nowrap}
201
+ .sev-critical{color:#dc2626;font-weight:600}.sev-high{color:#ea580c}.sev-medium{color:#d97706}
202
+ .badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}
203
+ .badge.pass{background:#dcfce7;color:#15803d}.badge.warn{background:#fef9c3;color:#a16207}
204
+ .badge.fail{background:#fee2e2;color:#b91c1c}.badge.manual{background:#e2e8f0;color:#475569}
205
+ .muted{color:var(--muted);font-size:13px}
206
+ .ok{color:#15803d;font-weight:600}
207
+ .migrate{margin:8px 0}
208
+ footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}
209
+ @media print{body{background:#fff}main{max-width:none;padding:0}h2{break-after:avoid}table{break-inside:auto}tr{break-inside:avoid}}
210
+ `
Binary file