shellward 0.6.7 → 0.6.9

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
@@ -37,6 +37,8 @@ npx shellward scan
37
37
  合规得分: 63/100 [C]
38
38
  ```
39
39
 
40
+ 想在浏览器里看?`npx shellward scan --open`(扫完直接打开报告)或 `--serve`(本地 http://localhost 提供报告)——**数据全程不出本机**。
41
+
40
42
  `--json` 供 CI · `--ci` 发现 critical 时让构建失败 · `--html report.html` 导出可打印成 PDF 的报告(备案/审计存档)· 也可作 [GitHub Action](#github-action-pr-compliance-gate) 接入 PR 门禁。
41
43
 
42
44
  > 检测重点:**境外大模型端点与 SDK 依赖(数据出境——中国独有、英文工具没有的概念)**、硬编码密钥、文件中的中文 PII、`.env` 暴露。扫到境外模型(如 `openai` 依赖)时,**直接给出境内合规替代**(通义千问 / DeepSeek / Kimi / 智谱)及其 OpenAI 兼容 `base_url`——多数迁移只需改一个 `base_url`。
package/dist/cli.js CHANGED
@@ -8,8 +8,11 @@
8
8
  // npx shellward --help
9
9
  //
10
10
  // 设计目标:30 秒、零配置、出一张可截图的「你的项目」合规风险报告。
11
- import { resolve } from 'path';
11
+ import { resolve, join } from 'path';
12
12
  import { writeFileSync } from 'fs';
13
+ import { tmpdir } from 'os';
14
+ import { createServer } from 'http';
15
+ import { spawn } from 'child_process';
13
16
  import { ShellWard } from './core/engine.js';
14
17
  import { runProjectComplianceAudit } from './compliance/audit.js';
15
18
  import { renderComplianceReport, renderProjectFindings } from './compliance/report.js';
@@ -41,6 +44,8 @@ function runScan(args) {
41
44
  const ci = args.includes('--ci');
42
45
  const outPath = flagValue(args, '--out');
43
46
  const htmlPath = flagValue(args, '--html');
47
+ const open = args.includes('--open');
48
+ const serve = args.includes('--serve');
44
49
  const dirArg = args.find(a => !a.startsWith('-'));
45
50
  const root = resolve(dirArg || process.cwd());
46
51
  // 用环境变量解析 locale;layers/mode 用默认(代表「采用 ShellWard 默认部署」的合规覆盖)
@@ -52,6 +57,37 @@ function runScan(args) {
52
57
  const locale = resolveLocale(guard.config);
53
58
  const zh = locale === 'zh';
54
59
  const { report, scan } = runProjectComplianceAudit(guard.config, root);
60
+ // ===== 本地 web 视图(方便看;数据全程不出本机)=====
61
+ if (serve) {
62
+ const html = renderHtmlReport(report, scan, locale, { root });
63
+ const port = Number(flagValue(args, '--serve') || flagValue(args, '--port')) || 7777;
64
+ const server = createServer((_req, res) => {
65
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
66
+ res.end(html);
67
+ });
68
+ server.on('error', (e) => {
69
+ console.error(zh ? `无法启动本地服务: ${e?.message}` : `Failed to start server: ${e?.message}`);
70
+ process.exit(1);
71
+ });
72
+ server.listen(port, '127.0.0.1', () => {
73
+ const url = `http://localhost:${port}`;
74
+ process.stdout.write(zh
75
+ ? `🌐 本地合规报告: ${url} (得分 ${report.score}/100 [${report.grade}],Ctrl+C 退出;数据不出本机)\n`
76
+ : `🌐 Local compliance report: ${url} (score ${report.score}/100 [${report.grade}]; Ctrl+C to stop; nothing leaves your machine)\n`);
77
+ openBrowser(url);
78
+ });
79
+ return;
80
+ }
81
+ if (open) {
82
+ const html = renderHtmlReport(report, scan, locale, { root });
83
+ const file = join(tmpdir(), `shellward-report-${report.score}-${report.grade}.html`);
84
+ writeFileSync(file, html, 'utf-8');
85
+ process.stdout.write(zh
86
+ ? `🌐 已在浏览器打开合规报告: ${file} (得分 ${report.score}/100 [${report.grade}];数据不出本机)\n`
87
+ : `🌐 Opened compliance report in browser: ${file} (score ${report.score}/100 [${report.grade}])\n`);
88
+ openBrowser(file);
89
+ return;
90
+ }
55
91
  if (json) {
56
92
  process.stdout.write(JSON.stringify({
57
93
  root,
@@ -108,6 +144,19 @@ function runScan(args) {
108
144
  process.exit(1);
109
145
  }
110
146
  }
147
+ /** 跨平台在默认浏览器打开 URL 或本地文件(失败静默,不影响主流程) */
148
+ function openBrowser(target) {
149
+ const cmd = process.platform === 'darwin' ? 'open'
150
+ : process.platform === 'win32' ? 'cmd'
151
+ : 'xdg-open';
152
+ const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', target] : [target];
153
+ try {
154
+ const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true });
155
+ child.on('error', () => { });
156
+ child.unref();
157
+ }
158
+ catch { /* 打开失败不影响 */ }
159
+ }
111
160
  /** 取 `--flag value` 或 `--flag=value` 的值 */
112
161
  function flagValue(args, flag) {
113
162
  const i = args.indexOf(flag);
@@ -127,6 +176,8 @@ Usage:
127
176
  shellward scan --ci Exit non-zero if critical findings
128
177
  shellward scan --out f Export the full report to a Markdown file
129
178
  shellward scan --html f Export a self-contained HTML report (print to PDF)
179
+ shellward scan --open Scan and open the report in your browser (local)
180
+ shellward scan --serve Scan and serve the report at http://localhost (local)
130
181
  shellward mcp Start MCP server (stdio)
131
182
  shellward --help
132
183
 
@@ -142,6 +193,8 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
142
193
  shellward scan --ci 有 critical 发现时非零退出
143
194
  shellward scan --out 文件 导出完整报告为 Markdown(合规存档)
144
195
  shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
196
+ shellward scan --open 扫描并在浏览器打开报告(本地,方便看)
197
+ shellward scan --serve 扫描并在 http://localhost 提供报告(本地服务)
145
198
  shellward mcp 启动 MCP 服务器(stdio)
146
199
  shellward --help
147
200
 
@@ -1,73 +1,96 @@
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
  import { REGULATION_NAMES } from './regulations.js';
6
9
  import { suggestDomestic } from '../rules/domestic-alternatives.js';
7
10
  const STATUS = {
8
11
  pass: { zh: '合规', en: 'Pass', cls: 'pass' },
9
12
  warn: { zh: '部分', en: 'Partial', cls: 'warn' },
10
13
  fail: { zh: '不合规', en: 'Fail', cls: 'fail' },
11
- manual: { zh: '待确认', en: 'Manual', cls: 'manual' },
14
+ manual: { zh: '待确认', en: 'Review', cls: 'manual' },
12
15
  };
13
16
  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' },
17
+ overseas: { zh: '数据出境风险', en: 'Data export risk', icon: '🌐' },
18
+ secret: { zh: '硬编码密钥', en: 'Hardcoded secret', icon: '🔑' },
19
+ pii: { zh: '个人信息暴露', en: 'PII exposure', icon: '🪪' },
20
+ 'env-perm': { zh: '.env 权限', en: '.env permission', icon: '📂' },
18
21
  };
19
22
  const KIND_ORDER = ['overseas', 'secret', 'pii', 'env-perm'];
20
23
  const REG_ORDER = ['CSL', 'PIPL', 'MLPS', 'CBDT', 'GENAI'];
21
- const GRADE_COLOR = { A: '#15803d', B: '#65a30d', C: '#d97706', D: '#dc2626' };
24
+ const GRADE = {
25
+ A: { color: '#16a34a', zh: '优秀', en: 'Excellent' },
26
+ B: { color: '#65a30d', zh: '良好', en: 'Good' },
27
+ C: { color: '#d97706', zh: '及格', en: 'Fair' },
28
+ D: { color: '#dc2626', zh: '不及格', en: 'Poor' },
29
+ };
30
+ const SEV = {
31
+ critical: { zh: '严重', en: 'Critical' },
32
+ high: { zh: '高', en: 'High' },
33
+ medium: { zh: '中', en: 'Medium' },
34
+ };
22
35
  /** 生成自包含 HTML 合规报告 */
23
36
  export function renderHtmlReport(report, scan, locale, meta) {
24
37
  const zh = locale === 'zh';
25
38
  const t = (z, e) => (zh ? z : e);
26
- const gradeColor = GRADE_COLOR[report.grade] || '#475569';
39
+ const g = GRADE[report.grade] || { color: '#475569', zh: report.grade, en: report.grade };
27
40
  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>
41
+ const S = [];
42
+ // ===== 评分 Hero =====
43
+ S.push(`
44
+ <section class="hero">
45
+ <div class="gauge" style="--p:${report.score};--c:${g.color}">
46
+ <div class="gauge-in">
47
+ <div class="gscore">${report.score}<small>/100</small></div>
48
+ <div class="ggrade" style="color:${g.color}">${esc(report.grade)} · ${t(g.zh, g.en)}</div>
49
+ </div>
35
50
  </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>` : ''}
51
+ <div class="hero-side">
52
+ <div class="stat-row">
53
+ ${stat('pass', '🟢', t('合规', 'Pass'), report.passed)}
54
+ ${stat('warn', '🟡', t('部分', 'Partial'), report.warned)}
55
+ ${stat('fail', '🔴', t('不合规', 'Fail'), report.failed)}
56
+ ${stat('manual', '⚪', t('待确认', 'Review'), report.manual)}
57
+ </div>
58
+ ${report.projectPenalty ? `<div class="penalty">⚠ ${t('含项目实测风险扣分', 'Includes project-scan penalty')} <b>−${report.projectPenalty}</b></div>` : ''}
59
+ <p class="hero-note">${t('得分基于本次可静态观测的项目风险。⚪ 待确认项需把 ShellWard 部署为运行时防护或人工核验。', 'Score reflects statically-observable project risk. items need runtime deployment or manual review.')}</p>
45
60
  </div>
46
61
  </section>`);
47
62
  // ===== 项目实测风险 =====
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>`);
63
+ S.push(sectionHead('🔍', t('项目实测风险', 'Project Scan Findings'), t(`已扫描 ${scan.filesScanned} 个文件${scan.truncated ? '(已达上限)' : ''}`, `Scanned ${scan.filesScanned} files${scan.truncated ? ' (limit reached)' : ''}`)));
50
64
  if (scan.findings.length === 0) {
51
- sections.push(`<p class="ok">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。', 'No hardcoded secrets, PII, or overseas endpoints found.')}</p>`);
65
+ S.push(`<div class="empty">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。', 'No hardcoded secrets, PII, or overseas endpoints found in project files.')}</div>`);
52
66
  }
53
67
  else {
68
+ S.push('<div class="chips">');
69
+ for (const k of KIND_ORDER)
70
+ if (scan.counts[k] > 0) {
71
+ S.push(`<span class="chip"><b>${scan.counts[k]}</b> ${KIND[k].icon} ${t(KIND[k].zh, KIND[k].en)}</span>`);
72
+ }
73
+ S.push('</div>');
54
74
  for (const kind of KIND_ORDER) {
55
75
  const items = scan.findings.filter(f => f.kind === kind);
56
76
  if (items.length === 0)
57
77
  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>`);
78
+ S.push(`<h3 class="sub">${KIND[kind].icon} ${t(KIND[kind].zh, KIND[kind].en)} <span class="n">${items.length}</span></h3>`);
79
+ S.push('<table class="tbl"><tbody>');
61
80
  for (const f of items) {
62
81
  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>`);
82
+ S.push(`<tr>
83
+ <td class="loc"><code>${esc(loc)}</code></td>
84
+ <td>${esc(f.detail)}</td>
85
+ <td class="right">${sevPill(f.severity, zh)}</td></tr>`);
64
86
  }
65
- sections.push('</tbody></table>');
87
+ S.push('</tbody></table>');
66
88
  }
67
89
  }
68
90
  // ===== 境内合规替代建议 =====
69
91
  const overseas = scan.findings.filter(f => f.kind === 'overseas');
70
92
  if (overseas.length > 0) {
93
+ S.push(sectionHead('✅', t('境内合规替代建议', 'Domestic Compliance Alternatives'), t('把数据出境风险变成可执行的迁移路径', 'Turn data-export risk into a migration path')));
71
94
  const seen = new Set();
72
95
  const providers = [];
73
96
  for (const f of overseas) {
@@ -77,42 +100,46 @@ export function renderHtmlReport(report, scan, locale, meta) {
77
100
  seen.add(k);
78
101
  providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en });
79
102
  }
80
- sections.push(`<h2>✅ ${t('境内合规替代建议', 'Domestic Compliance Alternatives')}</h2>`);
81
- sections.push('<ul class="migrate">');
103
+ S.push('<div class="migrate">');
82
104
  for (const p of providers) {
83
105
  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>`);
106
+ const low = (zh ? s.difficulty_zh : s.difficulty_en).startsWith(zh ? '低' : 'Low');
107
+ S.push(`<div class="mrow"><b>${esc(zh ? s.overseas_zh : s.overseas_en)}</b>
108
+ <span class="mtag ${low ? 'low' : 'mid'}">${t('迁移', 'Migrate')}: ${esc(zh ? s.difficulty_zh : s.difficulty_en)}</span></div>`);
85
109
  }
86
- sections.push('</ul>');
110
+ S.push('</div>');
87
111
  const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives;
88
- sections.push('<table class="findings"><thead><tr>'
112
+ S.push('<table class="tbl alts"><thead><tr>'
89
113
  + `<th>${t('境内模型', 'Domestic model')}</th><th>${t('厂商', 'Vendor')}</th><th>${t('OpenAI 兼容 base_url', 'OpenAI-compatible base_url')}</th></tr></thead><tbody>`);
90
114
  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>`);
115
+ 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>`);
92
116
  }
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>`);
117
+ S.push('</tbody></table>');
118
+ S.push(`<p class="note">💡 ${t('对使用 openai SDK 的项目:通常仅需把 base_url 与 api_key 换成上表任一境内模型即可,业务代码无需改动。', 'For openai-SDK projects: usually just swap base_url + api_key — no code change.')}</p>`);
95
119
  }
96
120
  // ===== 控制项明细 =====
97
- sections.push(`<h2>📋 ${t('合规控制项明细', 'Compliance Controls')}</h2>`);
121
+ S.push(sectionHead('📋', t('合规控制项明细', 'Compliance Controls'), t('按法规分组;⚪ 项为运行时/人工核验', 'By regulation; ⚪ = runtime / manual review')));
98
122
  const grouped = groupBy(report.results);
99
123
  for (const reg of REG_ORDER) {
100
124
  const items = grouped[reg];
101
125
  if (!items || items.length === 0)
102
126
  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>`);
127
+ const p = items.filter(r => r.status === 'pass').length;
128
+ const f = items.filter(r => r.status === 'fail').length;
129
+ S.push(`<div class="reg">
130
+ <div class="reg-head"><span>${esc(zh ? REGULATION_NAMES[reg].zh : REGULATION_NAMES[reg].en)}</span>
131
+ <span class="reg-mini">${p ? `<i class="d pass"></i>${p}` : ''}${f ? `<i class="d fail"></i>${f}` : ''}</span></div>
132
+ <table class="tbl ctrl"><tbody>`);
106
133
  for (const r of items) {
107
134
  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>`);
135
+ S.push(`<tr class="${st.cls}">
136
+ <td class="st">${statusPill(r.status, zh)}</td>
137
+ <td class="ttl"><b>${esc(zh ? r.control.title_zh : r.control.title_en)}</b><span class="art">${esc(r.control.article)}</span></td>
138
+ <td class="${r.status === 'manual' ? 'faint' : ''}">${esc(zh ? r.detail_zh : r.detail_en)}</td></tr>`);
112
139
  }
113
- sections.push('</tbody></table>');
140
+ S.push('</tbody></table></div>');
114
141
  }
115
- const disclaimer = t('本报告由 ShellWard 合规网关自动生成,帮助评估并满足合规技术要求,不构成法律意见,亦不替代算法备案/定级备案/PIA 等主体责任。⚪ 待确认项需结合业务人工判定。', 'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice.');
142
+ const disclaimer = t('本报告由 ShellWard 合规网关自动生成,帮助评估并满足合规技术要求,不构成法律意见,亦不替代算法备案/定级备案/PIA 等主体责任。⚪ 待确认项需结合业务人工判定。', 'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice. ⚪ items require manual review.');
116
143
  return `<!DOCTYPE html>
117
144
  <html lang="${zh ? 'zh-CN' : 'en'}">
118
145
  <head>
@@ -124,15 +151,31 @@ export function renderHtmlReport(report, scan, locale, meta) {
124
151
  <body>
125
152
  <main>
126
153
  <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>
154
+ <div class="brand">🛡️ Shell<span>Ward</span> <em>${t('合规网关', 'Compliance Gateway')}</em></div>
155
+ <h1>${t('AI 应用合规体检报告', 'AI Application Compliance Report')}</h1>
156
+ <p class="meta">${t('生成', 'Generated')}: ${esc(when)} UTC&nbsp;&nbsp;·&nbsp;&nbsp;${t('扫描目录', 'Path')}: <code>${esc(meta.root)}</code></p>
129
157
  </header>
130
- ${sections.join('\n')}
158
+ ${S.join('\n')}
131
159
  <footer>${esc(disclaimer)}</footer>
132
160
  </main>
133
161
  </body>
134
162
  </html>`;
135
163
  }
164
+ // ===== 小组件 =====
165
+ function stat(cls, icon, label, n) {
166
+ return `<div class="stat ${cls}"><div class="sn">${n}</div><div class="sl">${icon} ${label}</div></div>`;
167
+ }
168
+ function sectionHead(icon, title, sub) {
169
+ return `<div class="shead"><h2>${icon} ${esc(title)}</h2><span>${esc(sub)}</span></div>`;
170
+ }
171
+ function statusPill(s, zh) {
172
+ const st = STATUS[s];
173
+ return `<span class="pill ${st.cls}">${zh ? st.zh : st.en}</span>`;
174
+ }
175
+ function sevPill(sev, zh) {
176
+ const s = SEV[sev] || { zh: sev, en: sev };
177
+ return `<span class="sev ${sev}">${zh ? s.zh : s.en}</span>`;
178
+ }
136
179
  function groupBy(results) {
137
180
  const out = {};
138
181
  for (const r of results)
@@ -149,37 +192,132 @@ function esc(s) {
149
192
  .replace(/'/g, '&#39;');
150
193
  }
151
194
  const CSS = `
152
- :root{--ink:#1e293b;--muted:#64748b;--line:#e2e8f0;--bg:#f8fafc}
195
+ :root{
196
+ --ink:#0f172a;--muted:#64748b;--faint:#94a3b8;--line:#eaeef4;--bg:#eef1f6;--card:#fff;
197
+ --brand:#cb0000;
198
+ --pass:#16a34a;--pass-bg:#dcfce7;--warn:#b45309;--warn-bg:#fef3c7;
199
+ --fail:#dc2626;--fail-bg:#fee2e2;--manual:#64748b;--manual-bg:#eef2f7;
200
+ }
153
201
  *{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)}
202
+ body{margin:0;background:var(--bg);color:var(--ink);
203
+ font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
204
+ -webkit-font-smoothing:antialiased}
205
+ main{max-width:880px;margin:28px auto;background:var(--card);border-radius:16px;
206
+ box-shadow:0 1px 3px rgba(15,23,42,.06),0 12px 32px rgba(15,23,42,.07);overflow:hidden}
207
+ header{padding:30px 36px 22px;background:linear-gradient(180deg,#fafbfd,#fff);border-bottom:1px solid var(--line)}
208
+ .brand{font-size:13px;font-weight:700;color:var(--ink);letter-spacing:.2px}
209
+ .brand span{color:var(--brand)}
210
+ .brand em{font-style:normal;color:var(--faint);font-weight:500;margin-left:4px}
211
+ h1{font-size:25px;margin:10px 0 6px;letter-spacing:-.3px}
160
212
  .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}}
213
+ .meta code{background:#f1f5f9;padding:1px 6px;border-radius:5px;font-size:12px}
214
+ section,.reg{padding:0 36px}
215
+
216
+ /* Hero 评分 */
217
+ .hero{display:flex;gap:32px;align-items:center;margin:26px 36px;padding:26px 28px;
218
+ background:linear-gradient(135deg,#f8fafc,#f1f5f9);border:1px solid var(--line);border-radius:14px}
219
+ .gauge{--p:0;--c:#475569;flex:none;width:148px;height:148px;border-radius:50%;
220
+ background:conic-gradient(var(--c) calc(var(--p)*1%),#e4e9f1 0);
221
+ display:grid;place-items:center;box-shadow:inset 0 0 0 1px rgba(15,23,42,.04)}
222
+ .gauge-in{width:116px;height:116px;border-radius:50%;background:#fff;display:flex;flex-direction:column;
223
+ align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(15,23,42,.08)}
224
+ .gscore{font-size:42px;font-weight:800;line-height:1;letter-spacing:-1px}
225
+ .gscore small{font-size:15px;font-weight:500;color:var(--faint)}
226
+ .ggrade{font-size:14px;font-weight:700;margin-top:6px}
227
+ .hero-side{flex:1;min-width:0}
228
+ .stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
229
+ .stat{background:#fff;border:1px solid var(--line);border-radius:10px;padding:10px 12px;text-align:center}
230
+ .stat .sn{font-size:22px;font-weight:800;line-height:1}
231
+ .stat .sl{font-size:12px;color:var(--muted);margin-top:3px;white-space:nowrap}
232
+ .stat.pass .sn{color:var(--pass)}.stat.warn .sn{color:var(--warn)}
233
+ .stat.fail .sn{color:var(--fail)}.stat.manual .sn{color:var(--manual)}
234
+ .penalty{margin-top:12px;display:inline-block;background:var(--fail-bg);color:var(--fail);
235
+ font-size:12.5px;font-weight:600;padding:5px 12px;border-radius:8px}
236
+ .hero-note{margin:12px 0 0;font-size:12.5px;color:var(--muted);line-height:1.55}
237
+
238
+ /* 段标题 */
239
+ .shead{display:flex;align-items:baseline;gap:12px;margin:34px 36px 14px;
240
+ padding-bottom:10px;border-bottom:2px solid var(--line)}
241
+ .shead h2{font-size:18px;margin:0;font-weight:700}
242
+ .shead span{font-size:12.5px;color:var(--faint)}
243
+ .sub{font-size:14px;font-weight:700;color:var(--ink);margin:18px 36px 8px}
244
+ .sub .n{display:inline-block;background:#eef2f7;color:var(--muted);font-size:12px;
245
+ padding:0 8px;border-radius:999px;margin-left:4px;font-weight:600}
246
+ .empty{margin:8px 36px;padding:16px 18px;background:var(--pass-bg);color:var(--pass);
247
+ border-radius:10px;font-weight:600;font-size:14px}
248
+
249
+ /* chips 概览 */
250
+ .chips{display:flex;flex-wrap:wrap;gap:8px;margin:6px 36px 4px}
251
+ .chip{background:#f1f5f9;border:1px solid var(--line);border-radius:999px;
252
+ padding:5px 13px;font-size:13px;color:var(--muted)}
253
+ .chip b{color:var(--ink);font-size:14px;margin-right:2px}
254
+
255
+ /* 表格 */
256
+ .tbl{width:calc(100% - 72px);margin:4px 36px 6px;border-collapse:separate;border-spacing:0;font-size:13.5px}
257
+ .tbl td,.tbl th{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
258
+ .tbl th{background:#f8fafc;color:var(--muted);font-weight:600;font-size:12.5px;
259
+ border-bottom:1px solid #e2e8f0}
260
+ .tbl tbody tr:hover{background:#fafbfd}
261
+ .tbl .right{text-align:right;white-space:nowrap}
262
+ .tbl .muted{color:var(--muted)}
263
+ .tbl .faint{color:var(--faint);font-size:13px}
264
+ .loc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;
265
+ background:#f1f5f9;color:#0f172a;padding:2px 7px;border-radius:5px;white-space:nowrap}
266
+ .alts th:first-child,.alts td:first-child{width:120px}
267
+
268
+ /* severity 标签 */
269
+ .sev{display:inline-block;font-size:11.5px;font-weight:700;padding:2px 9px;border-radius:999px}
270
+ .sev.critical{background:#fee2e2;color:#b91c1c}
271
+ .sev.high{background:#ffedd5;color:#c2410c}
272
+ .sev.medium{background:#fef3c7;color:#b45309}
273
+
274
+ /* 状态药丸 */
275
+ .pill{display:inline-block;font-size:12px;font-weight:700;padding:3px 11px;border-radius:999px;white-space:nowrap}
276
+ .pill.pass{background:var(--pass-bg);color:var(--pass)}
277
+ .pill.warn{background:var(--warn-bg);color:var(--warn)}
278
+ .pill.fail{background:var(--fail-bg);color:var(--fail)}
279
+ .pill.manual{background:var(--manual-bg);color:var(--manual)}
280
+
281
+ /* 境内替代 */
282
+ .migrate{margin:6px 36px 10px;display:flex;flex-direction:column;gap:8px}
283
+ .mrow{display:flex;align-items:center;gap:12px;font-size:14px}
284
+ .mtag{font-size:12px;font-weight:600;padding:3px 10px;border-radius:8px}
285
+ .mtag.low{background:var(--pass-bg);color:var(--pass)}
286
+ .mtag.mid{background:var(--warn-bg);color:var(--warn)}
287
+ .note{margin:8px 36px 4px;font-size:12.5px;color:var(--muted);background:#f8fafc;
288
+ border-left:3px solid var(--brand);padding:10px 14px;border-radius:0 8px 8px 0}
289
+
290
+ /* 法规分组 */
291
+ .reg{margin:14px 36px;padding:0;border:1px solid var(--line);border-radius:12px;overflow:hidden}
292
+ .reg-head{display:flex;justify-content:space-between;align-items:center;
293
+ padding:11px 16px;background:#f8fafc;font-weight:700;font-size:14px;border-bottom:1px solid var(--line)}
294
+ .reg-mini{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted);font-weight:600}
295
+ .reg-mini .d{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:middle}
296
+ .reg-mini .d.pass{background:var(--pass)}.reg-mini .d.fail{background:var(--fail)}
297
+ .reg .tbl{width:100%;margin:0}
298
+ .reg .tbl td{padding:10px 16px}
299
+ .reg .tbl tr:last-child td{border-bottom:0}
300
+ .ctrl .st{width:78px}
301
+ .ctrl .ttl{width:210px}
302
+ .ctrl .ttl b{display:block;font-weight:600;font-size:13.5px}
303
+ .ctrl .art{display:block;color:var(--faint);font-size:11.5px;margin-top:2px}
304
+ .ctrl tr.fail{background:#fef6f6}
305
+
306
+ footer{margin-top:30px;padding:20px 36px 30px;border-top:1px solid var(--line);
307
+ color:var(--faint);font-size:11.5px;line-height:1.6;background:#fafbfd}
308
+
309
+ @media(max-width:640px){
310
+ main{margin:0;border-radius:0}
311
+ .hero{flex-direction:column;text-align:center;margin:18px}
312
+ .stat-row{grid-template-columns:repeat(2,1fr)}
313
+ section,.shead,.sub,.chips,.tbl,.migrate,.note,.reg{margin-left:16px;margin-right:16px}
314
+ .tbl{width:calc(100% - 32px)}
315
+ }
316
+ @media print{
317
+ body{background:#fff}
318
+ main{box-shadow:none;margin:0;max-width:none;border-radius:0}
319
+ .hero{background:#f8fafc}
320
+ .reg,.tbl tbody tr{break-inside:avoid}
321
+ h2,.shead{break-after:avoid}
322
+ }
185
323
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellward",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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
@@ -9,8 +9,11 @@
9
9
  //
10
10
  // 设计目标:30 秒、零配置、出一张可截图的「你的项目」合规风险报告。
11
11
 
12
- import { resolve } from 'path'
12
+ import { resolve, join } from 'path'
13
13
  import { writeFileSync } from 'fs'
14
+ import { tmpdir } from 'os'
15
+ import { createServer } from 'http'
16
+ import { spawn } from 'child_process'
14
17
  import { ShellWard } from './core/engine.js'
15
18
  import { runProjectComplianceAudit } from './compliance/audit.js'
16
19
  import { renderComplianceReport, renderProjectFindings } from './compliance/report.js'
@@ -48,6 +51,8 @@ function runScan(args: string[]) {
48
51
  const ci = args.includes('--ci')
49
52
  const outPath = flagValue(args, '--out')
50
53
  const htmlPath = flagValue(args, '--html')
54
+ const open = args.includes('--open')
55
+ const serve = args.includes('--serve')
51
56
  const dirArg = args.find(a => !a.startsWith('-'))
52
57
  const root = resolve(dirArg || process.cwd())
53
58
 
@@ -62,6 +67,39 @@ function runScan(args: string[]) {
62
67
 
63
68
  const { report, scan } = runProjectComplianceAudit(guard.config, root)
64
69
 
70
+ // ===== 本地 web 视图(方便看;数据全程不出本机)=====
71
+ if (serve) {
72
+ const html = renderHtmlReport(report, scan, locale, { root })
73
+ const port = Number(flagValue(args, '--serve') || flagValue(args, '--port')) || 7777
74
+ const server = createServer((_req, res) => {
75
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
76
+ res.end(html)
77
+ })
78
+ server.on('error', (e: any) => {
79
+ console.error(zh ? `无法启动本地服务: ${e?.message}` : `Failed to start server: ${e?.message}`)
80
+ process.exit(1)
81
+ })
82
+ server.listen(port, '127.0.0.1', () => {
83
+ const url = `http://localhost:${port}`
84
+ process.stdout.write(zh
85
+ ? `🌐 本地合规报告: ${url} (得分 ${report.score}/100 [${report.grade}],Ctrl+C 退出;数据不出本机)\n`
86
+ : `🌐 Local compliance report: ${url} (score ${report.score}/100 [${report.grade}]; Ctrl+C to stop; nothing leaves your machine)\n`)
87
+ openBrowser(url)
88
+ })
89
+ return
90
+ }
91
+
92
+ if (open) {
93
+ const html = renderHtmlReport(report, scan, locale, { root })
94
+ const file = join(tmpdir(), `shellward-report-${report.score}-${report.grade}.html`)
95
+ writeFileSync(file, html, 'utf-8')
96
+ process.stdout.write(zh
97
+ ? `🌐 已在浏览器打开合规报告: ${file} (得分 ${report.score}/100 [${report.grade}];数据不出本机)\n`
98
+ : `🌐 Opened compliance report in browser: ${file} (score ${report.score}/100 [${report.grade}])\n`)
99
+ openBrowser(file)
100
+ return
101
+ }
102
+
65
103
  if (json) {
66
104
  process.stdout.write(JSON.stringify({
67
105
  root,
@@ -117,6 +155,19 @@ function runScan(args: string[]) {
117
155
  }
118
156
  }
119
157
 
158
+ /** 跨平台在默认浏览器打开 URL 或本地文件(失败静默,不影响主流程) */
159
+ function openBrowser(target: string): void {
160
+ const cmd = process.platform === 'darwin' ? 'open'
161
+ : process.platform === 'win32' ? 'cmd'
162
+ : 'xdg-open'
163
+ const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', target] : [target]
164
+ try {
165
+ const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true })
166
+ child.on('error', () => {})
167
+ child.unref()
168
+ } catch { /* 打开失败不影响 */ }
169
+ }
170
+
120
171
  /** 取 `--flag value` 或 `--flag=value` 的值 */
121
172
  function flagValue(args: string[], flag: string): string | undefined {
122
173
  const i = args.indexOf(flag)
@@ -136,6 +187,8 @@ Usage:
136
187
  shellward scan --ci Exit non-zero if critical findings
137
188
  shellward scan --out f Export the full report to a Markdown file
138
189
  shellward scan --html f Export a self-contained HTML report (print to PDF)
190
+ shellward scan --open Scan and open the report in your browser (local)
191
+ shellward scan --serve Scan and serve the report at http://localhost (local)
139
192
  shellward mcp Start MCP server (stdio)
140
193
  shellward --help
141
194
 
@@ -150,6 +203,8 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
150
203
  shellward scan --ci 有 critical 发现时非零退出
151
204
  shellward scan --out 文件 导出完整报告为 Markdown(合规存档)
152
205
  shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
206
+ shellward scan --open 扫描并在浏览器打开报告(本地,方便看)
207
+ shellward scan --serve 扫描并在 http://localhost 提供报告(本地服务)
153
208
  shellward mcp 启动 MCP 服务器(stdio)
154
209
  shellward --help
155
210
 
@@ -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: 'Manual', cls: 'manual' },
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 GRADE_COLOR: Record<string, string> = { A: '#15803d', B: '#65a30d', C: '#d97706', D: '#dc2626' }
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 gradeColor = GRADE_COLOR[report.grade] || '#475569'
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 sections: string[] = []
60
+ const S: string[] = []
48
61
 
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>
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="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>` : ''}
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
- 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>`)
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
- sections.push(`<p class="ok">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。', 'No hardcoded secrets, PII, or overseas endpoints found.')}</p>`)
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
- 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>`)
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
- sections.push(`<tr><td class="loc">${esc(loc)}</td><td>${esc(f.detail)}</td><td class="sev-${f.severity}">${f.severity}</td></tr>`)
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
- sections.push('</tbody></table>')
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
- sections.push(`<h2>✅ ${t('境内合规替代建议', 'Domestic Compliance Alternatives')}</h2>`)
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
- sections.push(`<li><b>${esc(zh ? s.overseas_zh : s.overseas_en)}</b> → ${esc(zh ? s.difficulty_zh : s.difficulty_en)}</li>`)
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
- sections.push('</ul>')
135
+ S.push('</div>')
106
136
  const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives
107
- sections.push('<table class="findings"><thead><tr>'
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
- 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>`)
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
- 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>`)
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
- sections.push(`<h2>📋 ${t('合规控制项明细', 'Compliance Controls')}</h2>`)
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
- 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>`)
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
- 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>`)
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
- sections.push('</tbody></table>')
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
- <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>
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&nbsp;&nbsp;·&nbsp;&nbsp;${t('扫描目录', 'Path')}: <code>${esc(meta.root)}</code></p>
152
188
  </header>
153
- ${sections.join('\n')}
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{--ink:#1e293b;--muted:#64748b;--line:#e2e8f0;--bg:#f8fafc}
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);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)}
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 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}}
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
  `