feat(安全+品牌): 对外产物发布脱敏(3注入点/2465文件0泄露) + Kole Cup 定稿
Regression / regression (push) Canceled after 0s

安全:CHANGELOG.md 是仓库内部变更流水(含服务器目录、镜像回滚标签、内网网段、
部署时序、AI 工作流用语),此前被 build-site.ps1 / precompute.mjs / build-mobile.mjs
原样注入站点,而站点是公网可下载的静态文件 —— 抓一次 /site/m/changelog.html
即可拿到内网地址段与服务器目录布局。

- 新增 tools/lib/redact-publish.mjs(零依赖,构建期过滤,不改 CHANGELOG.md,
  内部可追溯性完整保留)
- 接入 precompute.mjs(PC data.json/changelog.json)与 build-mobile.mjs
  (移动端更新日志页);build-site.ps1 不碰(ASCII-only 铁律)
- 只处理 changelog 字段:components[].sources 是规范实现源码,逐字保真
  (详情页代码区主动高亮注释,剥注释会破坏该功能)
- 实测消除:/opt/aurora-admin.prev-*、kole-ui-showcase:pre-*、docker compose、
  192.168.5.7、16 位产物指纹、1531/1531、并发会话/本会话/派子 agent
- settings.html 演示占位 IP 192.168.5.0/24(= 真实网段)改为 RFC 5737 的 192.0.2.0/24

品牌:Kole Cup 饮料杯标记定稿(几何 K → 圆角杯盖 + 杯身负空间 K,无吸管),
brand-mark.json 升 schemaVersion 3(paths 支持 { d, evenodd }),
verify:brand 增至 25 条(新增 B9b:负空间必须带 fill-rule)。

验证:发布集 2465 文件全量扫描 0 泄露;PC 回归 100%(1464/1464) ·
移动端 100%(807/807);门禁品牌 25 / 隔离 31 / 移动文档 12 / 示例 9 / 版本 40 / i18n 17 全绿;
已按 AGENTS §九 发布公网,2461/2461 逐字节一致,五项验收全过。

已知未处理(既有缺口,ROADMAP S7-P28 已记录):data.mobile.json 的
meta.generated 为墙上时钟,会让 CI 的「生成物可复现」断言在重跑构建后永远非空;
该 CI 流水线本身亦从未通过(无 runner)。
This commit is contained in:
aurora-admin
2026-09-23 07:31:06 +08:00
parent 5ee5d829a4
commit b59247231a
68 changed files with 347 additions and 117 deletions
+7 -2
View File
@@ -32,6 +32,8 @@ import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative, sep } from 'node:path';
import { generatedData, readBrandSpec, validateBrandSpec } from './lib/brand-mark.mjs';
/* 发布脱敏:从 CHANGELOG.md 抽出的段落会进对外页面,先滤掉基础设施细节(见模块头注释)。 */
import { redactText } from './lib/redact-publish.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
@@ -1160,7 +1162,10 @@ console.log(`[build-mobile] site/m/component/ ← ${components.length} 个组件
/* ---------- 4.5 site/m 的五个指南页(与 PC 同名同序)---------- */
/* 更新日志:从仓库根 CHANGELOG.md 提取「标题含 Mobile / 移动端」的段落,转成静态 HTML。
不手抄 —— 站点与仓库两处说法不一致是这类页面的典型失败模式。 */
不手抄 —— 站点与仓库两处说法不一致是这类页面的典型失败模式。
出口脱敏:CHANGELOG 是内部流水,含部署目录 / 镜像标签 / 内网网段 / 部署时序,
而本页是公网可下载的静态文件;在**读入口**过一遍,下游(代码块 / 列表 / 引用)
拿到的就都是干净文本。CHANGELOG.md 本身不改。 */
function inlineMd(s) {
return esc(s)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
@@ -1168,7 +1173,7 @@ function inlineMd(s) {
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
}
function mobileChangelogHtml() {
const src = read(join(ROOT, 'CHANGELOG.md'));
const src = redactText(read(join(ROOT, 'CHANGELOG.md')));
const lines = src.split(/\r?\n/);
const blocks = [];
let cur = null;
+23 -11
View File
@@ -5,13 +5,23 @@ export const BRAND_SPEC_REL = '.design_library/kole-ui/brand/brand-mark.json';
export const BRAND_DATA_REL = 'site/brand-mark.generated.json';
export const BRAND_ASSET_REL = 'site/assets/kole-mark.svg';
export const BRAND_MONO_REL = 'site/assets/kole-mark-mono.svg';
export const BRAND_SCHEMA_VERSION = 2;
export const BRAND_SCHEMA_VERSION = 3;
export function readBrandSpec(root) {
const file = path.join(root, BRAND_SPEC_REL);
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
}
/** 路径项归一化:允许写成字符串(简化)或对象 { d, evenodd }(负空间需要 evenodd)。 */
function normalizePaths(raw) {
if (!Array.isArray(raw)) return [];
return raw.map((item) => {
if (typeof item === 'string') return { d: item, evenodd: false };
if (item && typeof item.d === 'string') return { d: item.d, evenodd: !!item.evenodd };
return { d: '', evenodd: false };
});
}
export function validateBrandSpec(spec) {
const errors = [];
if (!spec || spec.schemaVersion !== BRAND_SCHEMA_VERSION) {
@@ -20,9 +30,9 @@ export function validateBrandSpec(spec) {
if (!spec || spec.viewBox !== '0 0 24 24') errors.push('viewBox 必须为 0 0 24 24');
const g = spec?.geometry || {};
const paths = g.paths;
if (!Array.isArray(paths) || !paths.length || paths.some((d) => typeof d !== 'string' || !d.trim())) {
errors.push('geometry.paths 必须包含至少一条非空路径');
const paths = normalizePaths(g.paths);
if (!paths.length || paths.some((p) => !p.d.trim())) {
errors.push('geometry.paths 必须包含至少一条非空路径(字符串或 { d, evenodd })');
}
if (g.mode !== 'fill' && g.mode !== 'stroke') errors.push('geometry.mode 必须为 fill 或 stroke');
if (g.mode === 'stroke') {
@@ -31,7 +41,7 @@ export function validateBrandSpec(spec) {
if (g.linejoin !== 'round') errors.push('stroke 模式的 geometry.linejoin 必须为 round');
}
if (g.opacities !== undefined) {
if (!Array.isArray(g.opacities) || g.opacities.length !== (paths || []).length) {
if (!Array.isArray(g.opacities) || g.opacities.length !== paths.length) {
errors.push('geometry.opacities 必须与 paths 等长');
} else if (g.opacities.some((v) => typeof v !== 'number' || !(v > 0) || v > 1)) {
errors.push('geometry.opacities 取值必须在 (0, 1] 区间');
@@ -47,13 +57,14 @@ export function validateBrandSpec(spec) {
return errors;
}
/** 逐路径生成 <path>:实心模式按 opacities 给 fill-opacity;描边模式交给外层 <g> 统一给描边属性。 */
/** 逐路径生成 <path>:负空间路径必须带 fill-rule="evenodd",否则挖空不会生效。 */
function pathsMarkup(spec) {
const { paths, mode, opacities } = spec.geometry;
return paths
.map((d, i) => {
const { mode, opacities } = spec.geometry;
return normalizePaths(spec.geometry.paths)
.map((p, i) => {
const opacity = mode === 'fill' && opacities && opacities[i] != null ? ` fill-opacity="${opacities[i]}"` : '';
return `<path d="${d}"${opacity}/>`;
const rule = p.evenodd ? ' fill-rule="evenodd"' : '';
return `<path d="${p.d}"${rule}${opacity}/>`;
})
.join('');
}
@@ -93,7 +104,8 @@ export function generatedData(spec) {
viewBox: spec.viewBox,
geometry: {
mode: spec.geometry.mode,
paths: spec.geometry.paths,
/* 归一化后再写出:字符串项补成 { d, evenodd } 对象,保证下游拿到的结构稳定一致。 */
paths: normalizePaths(spec.geometry.paths),
...(spec.geometry.opacities ? { opacities: spec.geometry.opacities } : {}),
...(spec.geometry.mode === 'stroke'
? {
+157
View File
@@ -0,0 +1,157 @@
/* 发布脱敏(redaction)—— 构建期把「仅供仓库内部」的基础设施细节从对外产物中滤掉。
*
* 为什么需要它(实测):
* CHANGELOG.md 是仓库内部的变更流水,里面忠实记录了部署过程(服务器目录名、
* 镜像回滚标签、docker compose 命令、产物哈希、并发会话轮次)。这些内容被
* build-site.ps1 / tools/precompute.mjs / tools/build-mobile.mjs 原样注入站点,
* 而站点是**公网可下载的静态文件**(https://kole-ui.mymoyu.top)——
* 抓一次 /site/m/changelog.html 就能拿到内网地址段、服务器目录布局与部署时序。
*
* 2026-09-23 线上实证命中:/opt/aurora-admin.prev-20260920-0712、
* kole-ui-showcase:pre-20260920-0710、docker compose build、192.168.5.7。
*
* 设计原则:
* 1) **不改 CHANGELOG.md** —— 它是仓库内部真源,脱敏不能牺牲内部可追溯性。
* 过滤只发生在「写进对外产物」这一步,因此每个注入点都要过一遍 redact()。
* 2) **白名单式替换**,不做全文删除 —— 把敏感片段换成不泄露事实的等价说法,
* 保持句子可读(「按 AGENTS §九 标准流程发布」),而不是留一堆空洞。
* 3) **零依赖**,与仓库铁律 1 一致。
*
* 用法(三处注入点都必须调用):
* import { redactText, redactDeep } from './lib/redact-publish.mjs';
* writeFileSync(out, redactText(src)); // 文本产物(HTML / JSON 字符串)
* const safe = redactDeep(dataObject); // 结构化数据(data.json 的 changelog 段)
*/
'use strict';
/* 每条规则:[匹配, 替换]。顺序有意义 —— 更具体的写在前面,
避免「/opt/xxx.prev-yyyy」被更宽的规则先吃掉一半。 */
const RULES = [
/* --- 服务器目录与路径 --- */
[/\/opt\/[\w.@-]+(?:\/[\w.-]+)*/g, '部署目录'],
[/\b(?:cd|ls|mv|tar|scp|rsync)\s+\/opt\/[\w.-]+(?:\/[\w.-]+)*/g, '部署命令'],
/* --- 内网地址段(保留「有内网部署」这一事实,不保留具体网段) ---
三段式与四段式必须分开写:192.168.x.x 是 192.168 + 两段,
10.x.x.x / 172.16-31.x.x 是首段 + 三段,写在一起会多消耗一个八位组而永不匹配
(2026-09-23 自检抓到:合并写法让 192.168.5.7 漏过替换)。
CIDR 后缀 /24 一并吃掉,避免留下「内网地址/24」这种残句。 */
[/\b192\.168\.\d{1,3}\.\d{1,3}(?:\/\d{1,2})?\b/g, '内网地址'],
[/\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:\/\d{1,2})?\b/g, '内网地址'],
[/\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}(?:\/\d{1,2})?\b/g, '内网地址'],
/* --- 镜像 / 容器名(含回滚标签与摘要) --- */
[/\bkole-ui-showcase(?::[\w.-]+)?/g, '站点镜像'],
[/\bdocker\s+tag\s+[\w./:-]+\s+[\w./:-]+/gi, '镜像打标命令'],
[/\bdocker\s+compose\s+(?:build|up|down|ps|logs)\b[^\n]*/gi, '容器编排命令'],
[/\bdocker\s+(?:build|run|rmi|push|save|load)\b[^\n]*/gi, '容器命令'],
/* --- 部署主机与登录 --- */
[/\broot@[\w.-]+/g, '部署账号'],
[/\b(?:ssh|scp)\s+(?:-o\s+\S+\s+)*[\w.-]+@[\w.-]+/g, '部署传输命令'],
/* --- 隧道 / 私有域名(公网域名本身是公开的,保留;只滤掉隧道与私有源) --- */
[/\bfrpc?\b/gi, '隧道'],
/* --- 发布过程指纹:产物哈希与文件数(可供离线比对,属内部细节) ---
只吃「裸的 16 位十六进制」,且要求前后不是字母数字(避免切进 URL、ID、色值)。
文件数「1531/1531」按 context 替换:只在同时出现对账词时才算指纹。 */
[/\b(?<![0-9a-zA-Z])[0-9a-f]{16}(?![0-9a-zA-Z])/g, '产物指纹'],
[/(?:逐文件哈希对账|哈希(?:一致|对账)|树哈希(?:与本地一致)?)[^\n。;]{0,20}?\d+\s*\/\s*\d+/g, '逐文件哈希对账一致'],
/* 部署包规模:「(1531 文件 / 5.8 MB,1.0 MB gzip)」——同一个发布过程的产物指纹。 */
[/[((]\s*\d+\s*文件\s*\/\s*[\d.]+\s*MB[^))]*[))]/g, '(产物规模)'],
[/[((]\s*\d+\s*文件\s*\/\s*[\d.]+\s*MB\b[^\n。;]*/g, '(产物规模)'],
/* --- 会话 / 轮次用语(暴露开发方式,非技术事实) ---
注意匹配顺序:先长词后短词,否则「本会话」先被短规则吃掉一半。 */
[/(?:并发会话|并行会话|另一个会话|另一个子\s*agent|上一轮的两个子\s*agent|子\s*agent\s*并存)/g, '并行改动'],
[/(?:本会话|本次会话|这一轮|本轮(?!改动)|上一轮|前一批|本批次)/g, '本轮改动'],
[/(?:派子\s*agent|派活)/g, '批次派发'],
[/\/son\b/g, '批次派发'],
[/\/agi\b/g, '全自主模式'],
];
/* 关键词命中检测:只在这些词出现时才做替换,避免误伤正常文案
(例如组件演示里的「待办表格」不该被当成敏感信息处理)。 */
const TRIGGERS = [
/\/opt\//, /\b192\.168\./, /\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, /\b172\.(?:1[6-9]|2\d|3[01])\./,
/kole-ui-showcase/, /\bdocker\b/i, /root@/, /\bfrpc?\b/i,
/* 会话/轮次用语必须和 RULES 里的那一组保持同步 ——
漏一个就会出现「规则写好了但永远不触发」的静默漏网(2026-09-23 实测踩到:
`**本会话直接产出的一批**` 因 TRIGGERS 缺词而整行未过滤)。 */
/并发会话|并行会话|另一个会话|本会话|本次会话|这一轮|本轮|上一轮|前一批|本批次|派子\s*agent|派活/,
/\/son\b/, /\/agi\b/,
/\b(?<![0-9a-zA-Z])[0-9a-f]{16}(?![0-9a-zA-Z])/,
/* 对账句式本身也是信号:即使没带 16 位哈希,「逐文件哈希对账 1531/1531」
照样暴露了产物规模,需要过滤掉文件数。 */
/哈希对账|树哈希|哈希一致/,
/* 部署包规模:「(1531 文件 / 5.8 MB,1.0 MB gzip)」 */
/\d+\s*文件\s*\/\s*[\d.]+\s*MB/,
];
/** 文本脱敏:命中触发词才逐条替换,否则原样返回。 */
export function redactText(s) {
if (typeof s !== 'string' || !s) return s;
if (!TRIGGERS.some((re) => re.test(s))) return s;
let out = s;
for (const [re, to] of RULES) out = out.replace(re, to);
/* 清理替换后可能出现的重复与空洞(「部署目录/部署目录」「→ →」) */
out = out.replace(/(部署目录)(?:\s*→\s*\1)+/g, '$1');
out = out.replace(/([,、])\1+/g, '$1');
/* 指纹后面的产物规模一并吃掉:「产物指纹,1531 文件」→「产物指纹」
注意 markdown 反引号可能夹在中间(`f00d…`,1531 文件),要跨过它们。 */
out = out.replace(/产物指纹(`*)[,,]\s*`*\d+\s*(?:个)?\s*文件/g, '产物指纹');
/* 文件数被吃掉后可能留下空括号?(「…对账 0 差异(1531/1531)」→「…对账一致)」)
—— 去掉紧跟在替换结果后的孤立右括号。 */
out = out.replace(/(逐文件哈希对账一致)\s*[))]/g, '$1');
/* 「`pack-deploy --tar`(1531 文件 / 5.8 MB…)」整块被换掉后,
前一个 `→` 箭头会悬空成「→ (产物规模)→」,收掉重复箭头。 */
out = out.replace(/(产物规模)\s*→\s*/g, '(产物规模)→ ');
/* 替换后被 markdown 反引号包住会留下不配对的引号:「(`产物指纹)」→「(产物指纹)」
直接把「反引号+替换词」整体规整成裸词。 */
out = out.replace(/`(产物指纹|产物规模|部署目录|内网地址|站点镜像|容器编排命令|并行改动|本轮改动|批次派发|全自主模式|逐文件哈希对账一致|容器命令|镜像打标命令|部署命令|部署账号|部署传输命令)`/g, '$1');
/* 相邻替换词造成的重复:「批次派发 批次派发推进」→「批次派发推进」
(`/son` 与「派活」是同一个动作的两种写法,替换后撞在一起)。 */
const REPL = '产物指纹|产物规模|部署目录|内网地址|站点镜像|容器编排命令|并行改动|本轮改动|批次派发|全自主模式|逐文件哈希对账一致|容器命令|镜像打标命令|部署命令|部署账号|部署传输命令';
out = out.replace(new RegExp(`(${REPL})\\s+(?=\\1)`, 'g'), '');
/* 注意:这里**不能**再有「删掉行尾替换词」的规则 ——
那会把「→ 替换 → 容器编排命令」整句砍成「→ 替换 →」,属于有损删除
(2026-09-23 实测:加了该规则后第二次调用结果与第一次不同,且丢信息)。 */
/* 收尾:替换词原来被 markdown 反引号包着时,替换后可能只剩单边引号
(「(`产物指纹)」)。紧贴替换词的反引号一律去掉 —— 替换词本身已是干净词,
不需要代码样式;而正文里真正的代码跨度(如 `pack-deploy --tar`)不与替换词相邻,不受影响。 */
out = out.replace(new RegExp('`(?=' + REPL + ')', 'g'), '');
out = out.replace(new RegExp('(?<=' + REPL + ')`', 'g'), '');
/* 括号里被整块替换后留下悬空的连接符:「(目录 + 镜像 站点镜像)」→「(站点镜像)」
「(备份目录 部署目录)」→「(部署目录)」。 */
out = out.replace(new RegExp(`[((]\\s*(?:[++、]|—+|-{1,2}|→)\\s*(?:[\\w\\u4e00-\\u9fa5-]{0,8}\\s*)?(${REPL})\\s*[))]`, 'g'), '($1)');
out = out.replace(new RegExp(`[((]\\s*(${REPL})\\s*(?:[++、]|—+|-{1,2}|→)\\s*[\\w\\u4e00-\\u9fa5-]{0,8}\\s*[))]`, 'g'), '($1)');
/* 括号内只剩连接符或空白 → 整个括号删掉。 */
out = out.replace(/[((]\s*(?:[++、]|—+|-{1,2}|→|\s)+\s*[))]/g, '');
/* 「本轮改动两个并行改动时」这类撞词(两个替换词描述同一场合)收成一句。 */
out = out.replace(/(本轮改动|并行改动)(?:两个|多个)?(本轮改动|并行改动)/g, '多个改动并行');
return out;
}
/** 结构化数据脱敏:递归遍历对象/数组的所有字符串值。返回**新对象**,不改入参。 */
export function redactDeep(v) {
if (typeof v === 'string') return redactText(v);
if (Array.isArray(v)) return v.map(redactDeep);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v)) o[k] = redactDeep(v[k]);
return o;
}
return v;
}
/** 自检:用于 verify-redaction.mjs 与门禁断言。 */
export const _selfTest = [
['部署侧(192.168.5.7)的镜像已在仓库改好', '内网地址'],
['备份目录 /opt/aurora-admin.prev-20260920-0712', '部署目录'],
['镜像 kole-ui-showcase:pre-20260920-0710 保留回滚点', '站点镜像'],
['按 AGENTS §九 发布 → docker compose build && up -d', '容器编排命令'],
['暂存目录逐文件哈希对账 0 差异(1531/1531)', '产物指纹'],
['并发会话 07:36 重建了薄壳与 app.js', '并行改动'],
['继续 /son 派活推进清单覆盖', '批次派发'],
];
+8
View File
@@ -30,6 +30,10 @@ import path from 'path';
构建期绝不对 frameworks/ 里的内容做动态求值 —— 旧实现把数组字面量交给函数构造器求值,
投毒的 .vue 因此可在构建机上执行任意 JS。 */
import { parseStringLiteralArray } from './lib/parse-string-literal-array.mjs';
/* 发布脱敏:CHANGELOG 段会被原样注入对外产物(data.json / changelog.json),
而它是仓库内部变更流水,含服务器目录、镜像回滚标签、内网网段与部署时序。
过滤只发生在「写产物」这一步,CHANGELOG.md 自身保持完整(见该模块头注释)。 */
import { redactText, redactDeep } from './lib/redact-publish.mjs';
/* 逐示例用法片段(构建期;运行时只读结果):
extractExamples 切场景 / extractClassPropMap 提 class↔prop / buildSnippets 生成四端用法 */
import {
@@ -51,6 +55,10 @@ if (!existsSync(DATA_JSON)) {
const data = JSON.parse(readFileSync(DATA_JSON, 'utf8'));
const beforeKb = Math.round(statSync(DATA_JSON).size / 1024);
/* 变更日志对外脱敏:它是从 CHANGELOG.md 抽的仓库内部流水,含部署目录、镜像标签、
内网网段与部署时序;产物是公网可下载的静态文件。
只处理 changelog —— components[].sources 是规范实现源码,必须逐字保真。 */
if (Array.isArray(data.changelog)) data.changelog = redactDeep(data.changelog);
const SOURCE_KINDS = ['html', 'css', 'jsx', 'vue2', 'vue3'];
const expectedSourcePaths = [];
+11
View File
@@ -53,6 +53,17 @@ check(favicon === expectedFavicon, 'B6 favicon 是规格生成物');
check(mono === expectedMono, 'B7 单色标记是规格生成物');
check(data === expectedData, 'B8 generated JSON 是规格生成物');
check(!/<image\b|(?:href|src)=["']https?:\/\//i.test(favicon + mono), 'B9 品牌 SVG 不含外部资源');
/* 负空间断言:品牌图形靠 evenodd 挖空 K(杯身实心块 + K 反向子路径)。
若生成期漏掉 fill-rule,K 会被填成实心 —— 图标仍然"渲染成功",
只是变成一块实心杯子,**不会报任何错**。所以这条必须卡住。 */
const negativePaths = (spec.geometry.paths || []).filter((p) => p && typeof p === 'object' && p.evenodd);
const subpathCount = (d) => (String(d).match(/M/gi) || []).length;
check(
negativePaths.length === 0
|| (favicon.includes('fill-rule="evenodd"') && mono.includes('fill-rule="evenodd"')),
'B9b 负空间路径带 fill-rule="evenodd"(否则 K 会被填实)',
negativePaths.length ? `${negativePaths.length} 条负空间路径 · 子路径 ${negativePaths.map((p) => subpathCount(p.d)).join('+')}` : '无负空间路径'
);
check((favicon.match(/<path\b/g) || []).length === spec.geometry.paths.length, 'B10 favicon 完整包含全部几何路径', `${spec.geometry.paths.length} 条`);
check((mono.match(/<path\b/g) || []).length === spec.geometry.paths.length, 'B11 单色标记完整包含全部几何路径', `${spec.geometry.paths.length} 条`);
const faviconPaint = spec.geometry.mode === 'fill' ? `fill="${spec.favicon.foreground}"` : `stroke="${spec.favicon.foreground}"`;