feat(v1.2.6): 合并审核整改、确认完全原创并完善可选 API 披露
版本升至 1.2.6(1.2.5 审核被拒未发布,整改内容并入本版);README 新增原创声明(4.2.1/4.2.2 不适用);Hero 描述来源默认 none,选一言自动填入官方接口、清空回退本地句子库;文档列出两个 IP 定位候选接口与访客 IP 披露文案;含上一轮 4.4.2 内容渲染安全与 5.2 字体许可证修复。
This commit is contained in:
1 parent
dda86294b0
commit
c92429d139
18 files changed
+485
-63
No files matched your search
@@ -4,6 +4,8 @@
|
||||
* 对应 Halo 应用市场审核指南:
|
||||
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
|
||||
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
|
||||
* - 4.4.2 内容渲染安全:不得把站点内容用 th:utext 拼进 <script> 原始文本
|
||||
* - 5.2 / 2.2:第三方字体必须随包携带许可证全文;上架说明数字必须与配置定义一致
|
||||
* - 4.6 / 2.2:不得出现占位内容、无效链接
|
||||
*
|
||||
* 检查项:
|
||||
@@ -12,6 +14,8 @@
|
||||
* 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...)
|
||||
* 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹
|
||||
* 5. README 中的相对链接必须在安装包清单中真实存在
|
||||
* 6. 任何 <script> 开标签不得带 th:utext;字体许可证全文必须在制品清单中;
|
||||
* README 的设置分组 / 配置项数量必须与 settings.yaml 一致
|
||||
*
|
||||
* 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的,
|
||||
* 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。
|
||||
@@ -134,6 +138,63 @@ export function auditReadmeLinks(entryNames, readmePath = path.join(root, "READM
|
||||
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;
|
||||
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)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { problems, groupCount, fieldCount };
|
||||
}
|
||||
|
||||
function report(label, problems) {
|
||||
if (problems.length === 0) {
|
||||
console.log(`[audit] PASS ${label}`);
|
||||
@@ -146,11 +207,16 @@ function report(label, problems) {
|
||||
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.2 / 2.2(${compliance.groupCount} 组 / ${compliance.fieldCount} 配置项)`,
|
||||
compliance.problems
|
||||
);
|
||||
/* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */
|
||||
const distZip = path.join(root, "dist");
|
||||
const zip = fs.existsSync(distZip)
|
||||
@@ -162,6 +228,6 @@ if (isCli) {
|
||||
} else {
|
||||
console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)");
|
||||
}
|
||||
console.log(`[audit] 模板审计共 ${failures} 项问题`);
|
||||
console.log(`[audit] 审计共 ${failures} 项问题`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
+13
-2
@@ -10,7 +10,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { auditTemplates, auditReadmeLinks } from "./audit-external.mjs";
|
||||
import { auditTemplates, auditReadmeLinks, auditCompliance } from "./audit-external.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
@@ -235,6 +235,16 @@ if (linkAudit.problems.length) {
|
||||
fail("README 存在指向制品中不存在文件的相对链接");
|
||||
}
|
||||
|
||||
/* 4.4.2 / 5.2 / 2.2:内容渲染安全、字体许可证传递、上架说明数字一致性 */
|
||||
const entryNameSet = new Set(entries.map((entry) => entry.name));
|
||||
const complianceAudit = auditCompliance(entryNameSet);
|
||||
for (const problem of complianceAudit.problems) {
|
||||
console.error(`[package] ${problem}`);
|
||||
}
|
||||
if (complianceAudit.problems.length) {
|
||||
fail("合规审计未通过(4.4.2 内容渲染安全 / 5.2 许可证传递 / 2.2 上架信息)");
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(templatesDir, "logo.png"))) {
|
||||
fail("templates/logo.png 仍存在:应只保留压缩后的 templates/assets/logo.png");
|
||||
}
|
||||
@@ -243,7 +253,8 @@ if (fs.existsSync(logoFile) && fs.statSync(logoFile).size > 300 * 1024) {
|
||||
fail("templates/assets/logo.png 超过 300KB,请先压缩再打包");
|
||||
}
|
||||
console.log(
|
||||
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、logo 单份压缩)`
|
||||
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、` +
|
||||
`${complianceAudit.groupCount} 组 / ${complianceAudit.fieldCount} 配置项、logo 单份压缩、OFL 全文随包)`
|
||||
);
|
||||
|
||||
const outFile = path.join(root, "dist", `warm-island-${version}.zip`);
|
||||
|
||||
@@ -24,6 +24,17 @@ if (!fs.existsSync(path.join(assetsDir, "logo.png"))) {
|
||||
}
|
||||
console.log("[postbuild] logo.png 已去重,仅保留 templates/assets/logo.png");
|
||||
|
||||
// 1b. 第三方字体许可证全文必须随构建产物分发(审核指南 5.2:许可证传递 / 版权声明保留)
|
||||
const oflFile = path.join(assetsDir, "fonts", "LXGWWenKai", "OFL.txt");
|
||||
if (!fs.existsSync(oflFile)) {
|
||||
console.error(
|
||||
"[postbuild] 未找到 templates/assets/fonts/LXGWWenKai/OFL.txt" +
|
||||
"(public/assets/fonts/LXGWWenKai/OFL.txt 应随构建复制)"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("[postbuild] LXGW WenKai 的 OFL 1.1 许可证全文已随包");
|
||||
|
||||
// 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖)
|
||||
if (fs.existsSync(compatDir)) {
|
||||
let copied = 0;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 结构化数据 / 内容渲染安全冒烟测试(审核指南 4.4.2)
|
||||
*
|
||||
* 从构建产物 templates/post.html 提取 JSON-LD 生成脚本,用桩 DOM 执行,验证:
|
||||
* - 输出的 JSON-LD 合法、字段完整、可选字段缺省时不产生非法 JSON
|
||||
* - 标题里注入的脚本闭合标签只作为普通字符串保留,不会变成可执行内容
|
||||
* - 写入方式为 textContent,且模板中的 JSON-LD 标签初始为空
|
||||
*
|
||||
* 仅用 Node 内置模块,无浏览器 / 第三方依赖;需先执行 pnpm build。
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const postHtmlPath = path.join(root, "templates", "post.html");
|
||||
|
||||
if (!fs.existsSync(postHtmlPath)) {
|
||||
console.error("[jsonld-smoke] 未找到 templates/post.html,请先执行 pnpm build");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(postHtmlPath, "utf8");
|
||||
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
|
||||
const code = blocks.find((block) => block.includes("wi-article-jsonld"));
|
||||
if (!code) {
|
||||
console.error("[jsonld-smoke] 未找到 JSON-LD 生成脚本");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✔ ${name}`);
|
||||
pass++;
|
||||
} catch (error) {
|
||||
console.error(` ✘ ${name} -> ${error.message}`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
function render(values) {
|
||||
const target = { textContent: "" };
|
||||
const document = {
|
||||
querySelector: (selector) =>
|
||||
values[selector] == null ? null : { getAttribute: () => values[selector] },
|
||||
getElementById: (id) => (id === "wi-article-jsonld" ? target : null),
|
||||
title: "fallback title",
|
||||
};
|
||||
let executed = false;
|
||||
vm.runInNewContext(
|
||||
code,
|
||||
{ document, JSON, console, eval: () => (executed = true) },
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
return { raw: target.textContent, executed };
|
||||
}
|
||||
|
||||
const base = {
|
||||
'meta[property="og:title"]': "普通标题",
|
||||
'meta[property="og:url"]': "https://example.com/archives/a",
|
||||
'meta[property="article:published_time"]': "2026-11-11",
|
||||
'meta[name="author"]': "暖心向阳335",
|
||||
'meta[property="og:site_name"]': "WarmIsland",
|
||||
'meta[property="og:image"]': "https://example.com/cover.png",
|
||||
'meta[property="og:description"]': '含"引号"与\n换行的摘要',
|
||||
};
|
||||
|
||||
check("正常文章生成合法 JSON-LD", () => {
|
||||
const data = JSON.parse(render(base).raw);
|
||||
if (data["@type"] !== "Article" || data.headline !== "普通标题") {
|
||||
throw new Error("标题 / 类型不符");
|
||||
}
|
||||
if (data.author.name !== "暖心向阳335") throw new Error("作者不符");
|
||||
if (!data.description.includes("\n")) throw new Error("换行丢失");
|
||||
if (data.image !== base['meta[property="og:image"]']) throw new Error("封面丢失");
|
||||
});
|
||||
|
||||
check("模板里的 JSON-LD 标签初始为空(动态内容不经 HTML 解析)", () => {
|
||||
const match = html.match(/<script\b[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/i);
|
||||
if (!match) throw new Error("未找到 JSON-LD 标签");
|
||||
if (match[1].trim() !== "") throw new Error("标签内仍有静态 / 拼接内容,可能被解析为 HTML");
|
||||
});
|
||||
|
||||
check("脚本只用 textContent 写入,不含 innerHTML / document.write / eval", () => {
|
||||
if (!code.includes(".textContent = JSON.stringify")) throw new Error("未使用 textContent 安全赋值");
|
||||
if (/innerHTML|document\.write|\beval\s*\(/.test(code)) throw new Error("存在不安全写入方式");
|
||||
});
|
||||
|
||||
check("标题含脚本闭合标签时只作为 JSON 字符串保留,不产生动态执行", () => {
|
||||
const injected = "</" + "script><img src=x onerror=alert(1)>";
|
||||
const result = render({ ...base, 'meta[property="og:title"]': injected });
|
||||
const data = JSON.parse(result.raw);
|
||||
if (data.headline !== injected || typeof data.headline !== "string") {
|
||||
throw new Error("标题被篡改或不是纯字符串");
|
||||
}
|
||||
if (result.executed) throw new Error("发生动态执行");
|
||||
});
|
||||
|
||||
check("缺失可选字段时不产生 undefined / 非法 JSON", () => {
|
||||
const sparse = { ...base };
|
||||
delete sparse['meta[property="og:image"]'];
|
||||
delete sparse['meta[property="og:description"]'];
|
||||
const data = JSON.parse(render(sparse).raw);
|
||||
if ("image" in data || "description" in data) throw new Error("空字段未剔除");
|
||||
});
|
||||
|
||||
check("全部 meta 缺失时仍输出合法 JSON", () => {
|
||||
const data = JSON.parse(render({}).raw);
|
||||
if (data["@context"] !== "https://schema.org" || data.author.name !== "") {
|
||||
throw new Error("兜底失败");
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[jsonld-smoke] 内容渲染安全 ${pass} 项通过 / ${fail} 项失败`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in new issue
Block a user