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 +4 -2
- package/dist/cli.js +11 -0
- package/dist/compliance/html-report.d.ts +8 -0
- package/dist/compliance/html-report.js +185 -0
- package/dist/compliance/project-scan.js +0 -0
- package/package.json +1 -1
- package/src/cli.ts +10 -0
- package/src/compliance/html-report.ts +210 -0
- package/src/compliance/project-scan.ts +0 -0
package/README.md
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/shellward)
|
|
10
10
|
[](./LICENSE)
|
|
11
|
-
[](#performance)
|
|
12
12
|
[](#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, '&')
|
|
146
|
+
.replace(/</g, '<')
|
|
147
|
+
.replace(/>/g, '>')
|
|
148
|
+
.replace(/"/g, '"')
|
|
149
|
+
.replace(/'/g, ''');
|
|
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
|
+
"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, '&')
|
|
170
|
+
.replace(/</g, '<')
|
|
171
|
+
.replace(/>/g, '>')
|
|
172
|
+
.replace(/"/g, '"')
|
|
173
|
+
.replace(/'/g, ''')
|
|
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
|