- 文档对齐 1.2.7:feed/RSS 聚合改由官方链接管理插件订阅承担、Halo 要求统一 >=2.27.0、补齐设置分组文档、清理与代码不符的描述,并修复正文引用块留白 - 三个可选 API 设置项 help 只保留功能说明,隐私披露统一由 README「外部资源与隐私」与文档站承载;字段恢复按开关条件显示,修正配置块缩进 - audit:external 改为防回潮检查:字段存在、help 不得出现披露文案、README 必须保留隐私披露章节
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
/**
|
||
* 外部资源与隐私审计(发版前自动检查,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);
|
||
}
|