Files
halo-theme-WarmIsland/scripts/audit-external.mjs
T
nxxy335 5c7c6325b1 feat(v1.2.7): 全面校准文档并精简三个可选 API 配置项文案
- 文档对齐 1.2.7:feed/RSS 聚合改由官方链接管理插件订阅承担、Halo 要求统一 >=2.27.0、补齐设置分组文档、清理与代码不符的描述,并修复正文引用块留白
- 三个可选 API 设置项 help 只保留功能说明,隐私披露统一由 README「外部资源与隐私」与文档站承载;字段恢复按开关条件显示,修正配置块缩进
- audit:external 改为防回潮检查:字段存在、help 不得出现披露文案、README 必须保留隐私披露章节
2026-11-11 22:34:32 +08:00

272 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 外部资源与隐私审计(发版前自动检查,Node 内置模块,无第三方依赖)
*
* 对应 Halo 应用市场审核指南:
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
* - 4.4.2 内容渲染安全:不得把站点内容用 th:utext 拼进 <script> 原始文本
* - 5.1 / 5.2 / 2.2:第三方字体必须随包携带许可证全文;可选 API 的数据流向与隐私披露
* 由 README「外部资源与隐私」及文档站承载;上架说明数字必须与配置定义一致
* - 4.6 / 2.2:不得出现占位内容、无效链接
*
* 检查项:
* 1. 模板中不得有指向第三方的 <link rel=preconnect|dns-prefetch|preload>
* 2. 模板中不得有 src/href 直接指向 http(s) 的 script/link/img/iframe/source/video/audio
* 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...)
* 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹
* 5. README 中的相对链接必须在安装包清单中真实存在
* 6. 任何 <script> 开标签不得带 th:utext;字体许可证全文必须在制品清单中;
* README 的设置分组 / 配置项数量必须与 settings.yaml 一致
* 7. 三个可选 API 字段存在且 help 为精简功能说明;隐私披露文案不得回潮到设置界面(5.1)
*
* 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的,
* 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
/** 曾经造成未披露外部请求的服务域名,出现即视为回归 */
const BLOCKED_HOSTS = [
"cdn.jsdelivr.net",
"unpkg.com",
"cdnjs.cloudflare.com",
"fonts.googleapis.com",
"fonts.gstatic.com",
"v1.hitokoto.cn",
"myip.ipip.net",
"r.inews.qq.com",
];
const REMOTE_LOAD_RE =
/<(?:script|link|img|iframe|source|video|audio)\b[^>]*\b(?:src|href)\s*=\s*["']https?:\/\/[^"']+["']/gi;
const PRECONNECT_RE =
/<link\b[^>]*\brel\s*=\s*["'](?:preconnect|dns-prefetch|preload)["'][^>]*\bhref\s*=\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_FETCH_RE = /\b(?:fetch|importScripts)\s*\(\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_IMPORT_RE = /\bimport\s*\(\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_CSS_URL_RE = /url\(\s*["']?https?:\/\/[^)"']+/gi;
const AUTOLOADER_RE = /\b(?:prism-autoloader|languages_path|Prism\.plugins\.autoloader)\b/gi;
function listFiles(dir, exts, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
/* 字体目录只有本地 woff2 与版权注释,不参与审计 */
if (entry.name === "fonts") continue;
listFiles(abs, exts, out);
} else if (exts.some((ext) => entry.name.endsWith(ext))) {
out.push(abs);
}
}
return out;
}
export function auditTemplates(templatesDir = path.join(root, "templates")) {
const problems = [];
const files = listFiles(templatesDir, [".html", ".js", ".css"]);
const htmlFiles = files.filter((f) => f.endsWith(".html"));
/* 对外报告的模板数只算 templates/ 根目录下的 Halo 页面模板;
templates/error/ 下的错误页 HTML 同样参与审计,但不计入模板数。 */
const htmlCount = htmlFiles.filter((f) => path.dirname(f) === templatesDir).length;
if (htmlFiles.length === 0) {
problems.push("未找到任何 templates/*.html,构建产物不完整");
return { fileCount: 0, htmlCount: 0, problems };
}
for (const file of files) {
const rel = path.relative(root, file).replace(/\\/g, "/");
const text = fs.readFileSync(file, "utf8");
const isHtml = file.endsWith(".html");
const rules = isHtml
? [
["远程预连接 / 预加载", PRECONNECT_RE],
["远程脚本 / 样式 / 媒体", REMOTE_LOAD_RE],
["硬编码外部 fetch", REMOTE_FETCH_RE],
["远程动态 import", REMOTE_IMPORT_RE],
["autoloader 动态加载", AUTOLOADER_RE],
]
: [
["硬编码外部 fetch", REMOTE_FETCH_RE],
["远程动态 import", REMOTE_IMPORT_RE],
["CSS 远程 url()", REMOTE_CSS_URL_RE],
];
for (const [label, re] of rules) {
re.lastIndex = 0;
const hit = re.exec(text);
if (hit) {
problems.push(`${rel}: ${label} → ${hit[0].slice(0, 110)}`);
}
}
for (const host of BLOCKED_HOSTS) {
if (isHtml && text.includes(host)) {
problems.push(`${rel}: 出现已移除的第三方服务域名 ${host}`);
}
}
}
return { fileCount: files.length, htmlCount, problems };
}
export function auditReadmeLinks(entryNames, readmePath = path.join(root, "README.md")) {
const problems = [];
if (!fs.existsSync(readmePath)) return ["README.md 不存在"];
const readme = fs.readFileSync(readmePath, "utf8");
const linkRe = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
let match;
let checked = 0;
while ((match = linkRe.exec(readme)) !== null) {
const href = match[1];
if (/^(?:https?:|mailto:|#)/.test(href)) continue;
let target = href.split("#")[0];
if (!target) continue;
try {
target = decodeURIComponent(target);
} catch (e) {
/* 保留原值 */
}
target = target.replace(/^\.\//, "");
checked++;
if (!entryNames.has(target)) {
problems.push(`README.md 相对链接在安装包中不存在:${href}(期望 ${target})`);
}
}
return { problems, checked };
}
/**
* 合规审计:审核指南 4.4.2(内容渲染安全)、5.2(许可证传递)、2.2(上架信息准确)
*
* @param {Set<string>|null} entryNames 打包清单;传入时额外校验许可证确实进了制品
*/
export function auditCompliance(entryNames = null) {
const problems = [];
const templatesDir = path.join(root, "templates");
const oflRel = "templates/assets/fonts/LXGWWenKai/OFL.txt";
// 1. <script> 是原始文本元素,th:utext 不转义且可被脚本闭合标签截断(4.4.2)
const scriptTagRe = /<script\b[^>]*>/gi;
for (const file of listFiles(templatesDir, [".html"])) {
const rel = path.relative(root, file).replace(/\\/g, "/");
const text = fs.readFileSync(file, "utf8");
scriptTagRe.lastIndex = 0;
let hit;
while ((hit = scriptTagRe.exec(text)) !== null) {
if (/\bth:utext\b/i.test(hit[0])) {
problems.push(`${rel}: <script> 开标签使用了 th:utext(4.4.2 内容注入风险)`);
}
}
}
// 2. SIL OFL 1.1 要求随字体软件附带版权声明与许可证全文(5.2)
const oflPath = path.join(root, oflRel);
if (entryNames && !entryNames.has(oflRel)) {
problems.push(`安装包缺少 ${oflRel}(5.2 字体许可证传递)`);
}
if (!fs.existsSync(oflPath)) {
problems.push(`构建产物缺少 ${oflRel}(5.2 字体许可证传递)`);
} else {
const ofl = fs.readFileSync(oflPath, "utf8");
if (!/SIL OPEN FONT LICENSE Version 1\.1/.test(ofl) || !/Copyright 2021-2026 LXGW/.test(ofl)) {
problems.push(`${oflRel} 内容不完整:需包含上游版权声明与 OFL 1.1 全文`);
}
}
// 3. 上架说明里的配置数量必须与 settings.yaml 一致(2.2 / 4.6)
const settingsPath = path.join(root, "settings.yaml");
const readmePath = path.join(root, "README.md");
let groupCount = 0;
let fieldCount = 0;
let conciseCount = 0;
if (fs.existsSync(settingsPath) && fs.existsSync(readmePath)) {
const settings = fs.readFileSync(settingsPath, "utf8");
const readme = fs.readFileSync(readmePath, "utf8");
groupCount = (settings.match(/^\s*-\s+group:/gm) || []).length;
fieldCount = (settings.match(/^\s*-\s+\$formkit:/gm) || []).length;
const expected = `${groupCount} 个设置分组、${fieldCount} 个配置项`;
if (!readme.includes(expected)) {
problems.push(`README 配置数量与 settings.yaml 不一致,应为「${expected}」(2.2)`);
}
// 4. 5.1 隐私披露:完整的数据流向说明统一由 README「外部资源与隐私」章节及文档站承载,
// 设置界面的三个可选 API 字段只保留精简功能说明,不得回潮出现冗长披露文案。
const apiFields = [
"welcome_popup_api",
"welcome_popup_fallback_api",
"hero_hitokoto_api",
];
const bannedSnippets = [
"需自行在站点隐私说明中披露",
"填写后会把访客 IP 发送给该第三方服务换取地理位置",
"填写后同样会把访客 IP 发送给该第三方服务换取地理位置",
"只有填写后才会向该地址发起请求",
];
const blocks = settings.split(/(?=^[ \t]*-[ \t]+\$formkit:)/m).slice(1);
for (const name of apiFields) {
const block = blocks.find((b) => new RegExp(`^\\s+name:[ \\t]*${name}[ \\t]*$`, "m").test(b));
if (!block) {
problems.push(`settings.yaml 缺少可选 API 配置项 ${name}(5.1 隐私披露)`);
continue;
}
const helpLine = block.split(/\r?\n/).find((line) => /^\s+help:/.test(line)) || "";
if (!helpLine.trim()) {
problems.push(`${name} 缺少 help 功能说明(5.1)`);
continue;
}
if (bannedSnippets.some((snippet) => helpLine.includes(snippet))) {
problems.push(`${name} 的 help 出现了已移除的隐私披露文案(5.1)`);
} else {
conciseCount++;
}
}
if (!readme.includes("外部资源与隐私") || !readme.includes("访客 IP")) {
problems.push("README 缺少可选 API 的隐私披露章节(5.1)");
}
}
return { problems, groupCount, fieldCount, conciseCount };
}
function report(label, problems) {
if (problems.length === 0) {
console.log(`[audit] PASS ${label}`);
return 0;
}
for (const p of problems) console.error(`[audit] FAIL ${label}: ${p}`);
return problems.length;
}
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCli) {
const templates = auditTemplates();
const compliance = auditCompliance();
let failures = 0;
failures += report(
`模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`,
templates.problems
);
failures += report(
`合规审计 4.4.2 / 5.1 / 5.2 / 2.2(${compliance.groupCount} 组 / ${compliance.fieldCount} 配置项 / ${compliance.conciseCount} 条精简提示)`,
compliance.problems
);
/* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */
const distZip = path.join(root, "dist");
const zip = fs.existsSync(distZip)
? fs.readdirSync(distZip).filter((f) => f.endsWith(".zip") && f.includes("warm-island"))
: [];
if (zip.length) {
/* 简单口径:zip 存在时仅提示需通过 pnpm package 做完整链接校验 */
console.log(`[audit] 提示:README 链接校验在 pnpm package 内执行(发现 ${zip.length} 个安装包)`);
} else {
console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)");
}
console.log(`[audit] 审计共 ${failures} 项问题`);
process.exit(failures ? 1 : 0);
}