From eba7ef55e6b5bda98113609a9b2c2fa6a6ebe32b Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 22 Oct 2026 11:55:15 +0800 Subject: [PATCH] fix(security): patch Node Rust and isolated Go service runtimes --- Dockerfile | 8 +- Dockerfile.base | 24 +++--- apps/detail-agent-pi/README.md | 4 + apps/detail-agent-pi/package-lock.json | 2 +- apps/detail-agent-pi/package.json | 2 +- .../scripts/package-client.mjs | 3 +- apps/detail-agent-pi/src/agent.mjs | 2 + apps/detail-agent-pi/src/desktop-host.mjs | 3 + apps/detail-agent-pi/src/runtime-policy.mjs | 7 ++ .../test/runtime-policy.test.mjs | 10 +++ apps/detail-photocraft/Cargo.lock | 10 +-- apps/detail-photocraft/JIANSUN.md | 2 + .../apps/photocraft/src/jiansun_backend.rs | 11 +++ .../apps/photocraft/src/jiansun_pi_runtime.rs | 2 +- build-base-image.ps1 | 26 ++----- docker-compose.dev.yml | 2 +- docker-compose.local-test.yml | 2 +- docs/runtime-security-upgrade-2026-10-22.md | 77 +++++++++++++++++++ package-lock.json | 3 + package.json | 7 +- scripts/safe-production-migrate.test.mjs | 2 +- .../security-runtime/go-rebuild.Dockerfile | 49 ++++++++++++ scripts/security-runtime/go-sources.json | 26 +++++++ .../security-runtime/prepare-go-context.mjs | 29 +++++++ scripts/security-runtime/tls-probe.go | 37 +++++++++ 25 files changed, 298 insertions(+), 52 deletions(-) create mode 100644 apps/detail-agent-pi/src/runtime-policy.mjs create mode 100644 apps/detail-agent-pi/test/runtime-policy.test.mjs create mode 100644 docs/runtime-security-upgrade-2026-10-22.md create mode 100644 scripts/security-runtime/go-rebuild.Dockerfile create mode 100644 scripts/security-runtime/go-sources.json create mode 100644 scripts/security-runtime/prepare-go-context.mjs create mode 100644 scripts/security-runtime/tls-probe.go diff --git a/Dockerfile b/Dockerfile index 1f247820..f4633055 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ # 基础镜像(使用 Alpine,手动处理 OpenSSL 兼容性) -# 2026-08-12 通过 `docker buildx imagetools inspect node:18-alpine` 核验的 +# 2026-10-22 核验 Node.js 安全更新,固定 Docker Hub 官方多架构清单。 # Docker Hub 官方多架构清单 digest。生产默认值必须是不可变引用;本地测试 # 如需改镜像源,仍可显式传入 BASE_IMAGE,但发布脚本不得覆盖此默认值。 -ARG BASE_IMAGE=node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e +ARG BASE_IMAGE=node:22.23.4-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 ARG ALPINE_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/alpine # Local/CI builds remain possible without Git metadata. Production deployment # supplies the exact SHA and rejects this sentinel before any container starts. ARG VCS_REF=0000000000000000000000000000000000000000 -ARG DETAIL_RENDERER_IMAGE=node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 +ARG DETAIL_RENDERER_IMAGE=node:22.23.4-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 FROM ${BASE_IMAGE} AS base # Fabric's Node renderer has a separate dependency tree and Node runtime. @@ -26,7 +26,7 @@ RUN node -e "require('canvas').createCanvas(1,1); console.log('isolated Canvas s FROM base AS deps ARG ALPINE_MIRROR RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ - apk add --no-cache libc6-compat python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev + apk add --no-cache libc6-compat python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev openssl WORKDIR /app # package-lock.json 是生产构建的必需输入,缺失时 COPY 直接失败。 diff --git a/Dockerfile.base b/Dockerfile.base index 62585eaf..b612d8a7 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -1,21 +1,15 @@ -# 由壹点3D素材 - 基础镜像(包含所有依赖) -# 用途:一次性构建,后续部署直接使用,零安装、零复制 +# 旧基础镜像入口仍可使用;依赖必须由当前锁文件在同一 Node ABI 下重建。 +# 不再导入历史 Node 18 的 APK / node_modules 缓存。 -ARG BASE_IMAGE=node:18-alpine +ARG BASE_IMAGE=node:22.23.4-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 FROM ${BASE_IMAGE} WORKDIR /app - -# 复制已解压的 Alpine 包并安装(一次性) -COPY cache/alpine-apk /tmp/alpine-apk -RUN echo "✅ 安装系统包(一次性)" && \ - apk add --no-network --allow-untrusted /tmp/alpine-apk/*.apk && \ - rm -rf /tmp/alpine-apk - -# 复制已解压的 node_modules(一次性) -COPY cache/node_modules ./node_modules -RUN echo "✅ node_modules 已就绪" && \ - chmod -R +x node_modules/.bin +RUN apk add --no-cache libc6-compat python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev openssl +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm_config_build_from_source=canvas npm_config_nodedir=/usr/local npm ci --no-audit --no-fund --legacy-peer-deps && \ + node -e "require('canvas').createCanvas(1,1); require('sharp'); console.log('native ABI verified')" # 复制 Prisma schema 并生成 Client(一次性) COPY prisma ./prisma @@ -26,6 +20,6 @@ RUN echo "✅ 生成 Prisma Client(一次性)" && \ # 标记为基础镜像 LABEL purpose="youyidian-3d-base" \ description="包含所有依赖的基础镜像" \ - version="1.0" + version="2.0-node22" CMD ["echo", "这是基础镜像,不应该直接运行"] diff --git a/apps/detail-agent-pi/README.md b/apps/detail-agent-pi/README.md index 36009b2e..70f3b791 100644 --- a/apps/detail-agent-pi/README.md +++ b/apps/detail-agent-pi/README.md @@ -35,6 +35,10 @@ ## 验证与本地打包 +Pi 的项目运行时固定为 Node 24.22.1 或同一 24.x 线的后续安全版本。 +开发时先安装已校验官方 SHA-256 的 Node 到 `runtime/`;不借用全局 Node。 +主进程、独立 SDK 入口及打包工具都校验版本,旧版本不会启动模型桥。 + ```powershell cd apps/detail-agent-pi npm ci --ignore-scripts diff --git a/apps/detail-agent-pi/package-lock.json b/apps/detail-agent-pi/package-lock.json index f8ccf6dd..1ddafa57 100644 --- a/apps/detail-agent-pi/package-lock.json +++ b/apps/detail-agent-pi/package-lock.json @@ -16,7 +16,7 @@ "quickjs-emscripten": "0.32.0" }, "engines": { - "node": ">=24.16.0" + "node": ">=24.22.1 <25" } }, "node_modules/@anthropic-ai/sdk": { diff --git a/apps/detail-agent-pi/package.json b/apps/detail-agent-pi/package.json index 89317c26..e6ad1905 100644 --- a/apps/detail-agent-pi/package.json +++ b/apps/detail-agent-pi/package.json @@ -8,7 +8,7 @@ "verify:live": "node scripts/acceptance.mjs" }, "engines": { - "node": ">=24.16.0" + "node": ">=24.22.1 <25" }, "dependencies": { "@earendil-works/pi-agent-core": "1.3.0", diff --git a/apps/detail-agent-pi/scripts/package-client.mjs b/apps/detail-agent-pi/scripts/package-client.mjs index af275745..44bb0463 100644 --- a/apps/detail-agent-pi/scripts/package-client.mjs +++ b/apps/detail-agent-pi/scripts/package-client.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; +import { assertPiNodeVersion } from '../src/runtime-policy.mjs'; // A local portable build. Never copy a profile, credential, project or request journal. const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -11,7 +12,7 @@ if (!binaryArg || !destinationArg) throw Error('指定已验证的原生程序 const destination = path.resolve(destinationArg), binary = path.resolve(binaryArg), node = path.resolve(nodeArg); if (destination === root || destination.startsWith(root + path.sep) || !path.isAbsolute(binary)) throw Error('输出目录必须独立于源码'); const version = execFileSync(node, ['--version'], { encoding: 'utf8', windowsHide: true }).trim(); -if (!/^v(?:2[4-9]|[3-9]\d)\./.test(version)) throw Error('本地运行组件需要 Node 24 或以上'); +assertPiNodeVersion(version); await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.mkdir(destination); // Refuse to overwrite an existing client or user data. await fs.copyFile(binary, path.join(destination, 'photocraft.exe')); diff --git a/apps/detail-agent-pi/src/agent.mjs b/apps/detail-agent-pi/src/agent.mjs index d84e9a4d..047d1f17 100644 --- a/apps/detail-agent-pi/src/agent.mjs +++ b/apps/detail-agent-pi/src/agent.mjs @@ -13,6 +13,7 @@ import { auditResponse } from './response-audit.mjs'; import { projectContext, promptChars } from './context-budget.mjs'; import { taskFor } from './rule-context.mjs'; import { responseUsage } from './usage.mjs'; +import { assertPiNodeVersion } from './runtime-policy.mjs'; const SYSTEM = `你是由壹点详情页的本地 Agent。使用中文。先看当前上下文;只有用户要求修改画布时才读取和操作画布。 只信当前注入的店铺/品类/品牌规则,不沿用另一店铺或旧规则;品牌规范不是产品参数的证据。 @@ -61,6 +62,7 @@ function toolsFor(host) { /** Coding Agent SDK owns the transcript/compaction; our host owns all I/O boundaries. */ export class DetailPiAgent { static async open({ store, broker, canvas, transport, protocolMode = 'native-tools', callBudget = 12, onEvent = () => {} }) { + assertPiNodeVersion(process.versions.node); if (!['native-tools', 'structured-json'].includes(protocolMode)) throw Error('工具协议模式无效'); if (!Number.isInteger(callBudget) || callBudget < 1 || callBudget > 12) throw Error('单轮模型调用预算无效'); const host = new DetailPiAgent(); diff --git a/apps/detail-agent-pi/src/desktop-host.mjs b/apps/detail-agent-pi/src/desktop-host.mjs index 812afb82..d3eccf87 100644 --- a/apps/detail-agent-pi/src/desktop-host.mjs +++ b/apps/detail-agent-pi/src/desktop-host.mjs @@ -5,6 +5,9 @@ import { ProjectStore, atomic } from './store.mjs'; import { StdioCanvas } from './stdio-canvas.mjs'; import path from 'node:path'; import { resolveRules } from './rules.mjs'; +import { assertPiNodeVersion } from './runtime-policy.mjs'; + +assertPiNodeVersion(process.versions.node); const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); let initial, serial = 0, closed = false; diff --git a/apps/detail-agent-pi/src/runtime-policy.mjs b/apps/detail-agent-pi/src/runtime-policy.mjs new file mode 100644 index 00000000..4f41d2df --- /dev/null +++ b/apps/detail-agent-pi/src/runtime-policy.mjs @@ -0,0 +1,7 @@ +/** Pi's supported Node 24 line must include the 2026-10-21 security patches. */ +export function assertPiNodeVersion(version) { + const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version || ''); + if (!match || Number(match[1]) !== 24 || Number(match[2]) < 22 || (Number(match[2]) === 22 && Number(match[3]) < 1)) { + throw Error('Agent运行组件需要Node 24.22.1或更新的24.x安全版本,请更新完整客户端'); + } +} diff --git a/apps/detail-agent-pi/test/runtime-policy.test.mjs b/apps/detail-agent-pi/test/runtime-policy.test.mjs new file mode 100644 index 00000000..a6ef9944 --- /dev/null +++ b/apps/detail-agent-pi/test/runtime-policy.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertPiNodeVersion } from '../src/runtime-policy.mjs'; + +test('client packaging and startup reject unpatched and unverified Node lines', () => { + for (const version of ['18.20.8', '22.23.4', '24.16.0', '24.22.0', '25.0.0', '26.12.1', 'v24.22.1-rc.1', 'garbage']) { + assert.throws(() => assertPiNodeVersion(version), /24.22.1/); + } + for (const version of ['v24.22.1', '24.22.2', '24.23.0']) assert.doesNotThrow(() => assertPiNodeVersion(version)); +}); diff --git a/apps/detail-photocraft/Cargo.lock b/apps/detail-photocraft/Cargo.lock index bccf6a5e..96e716b9 100644 --- a/apps/detail-photocraft/Cargo.lock +++ b/apps/detail-photocraft/Cargo.lock @@ -6114,9 +6114,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -6968,7 +6968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -7929,9 +7929,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] diff --git a/apps/detail-photocraft/JIANSUN.md b/apps/detail-photocraft/JIANSUN.md index 982cf2a1..5c0f2fc2 100644 --- a/apps/detail-photocraft/JIANSUN.md +++ b/apps/detail-photocraft/JIANSUN.md @@ -39,6 +39,8 @@ cargo xtask wasm “接手”、人工修改和切换文档会停止剩余自动操作。 - 正常“编辑画布”发送入口已由本地 Pi SDK 接管,保留现有原生界面。输入区选择项目的品牌/品类规则;完整版本包从素材库授权接口读取,本机项目固定快照。长历史压缩后仍能检索原文,重启恢复同一会话。规则细则与模板按任务加载,不整份重复注入。 - 本机包须包含 `agent/node.exe`、`agent/src/desktop-host.mjs` 和锁定依赖;缺组件会明确报错,不能退回旧Agent冒充接通。构建及隔离工具约束见 `../detail-agent-pi/README.md`。 + Node 使用 24.22.1 或同线后续安全版本。原生 HTTPS 依赖已更新至 + `rustls-webpki 0.103.16`、`webpki-root-certs 1.0.10`;连接仍执行完整证书校验。 - 右侧“生图”使用选中的产品图片及用户自己的 Key、所选生图模型,返回图片作为独立图层。 “重生成”只替换当前屏选中的一张图片,保留文字、字体和其他屏。按原图框等比裁切, 替换及必要的裁切蒙版在同一次撤销内;重复替换保持同一图框。 diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs index 15573f39..c6ef4efe 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs @@ -925,6 +925,17 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { mod tests { use super::*; #[test] + #[ignore = "read-only public TLS acceptance; no account, API Key or inference request"] + fn jiansun_security_revoked_intermediates_fail_tls_validation() { + let client = Client::builder().timeout(Duration::from_secs(15)).redirect(reqwest::redirect::Policy::none()).build().unwrap(); + assert!(client.head("https://nodejs.org/").send().is_ok(), "trusted control connection must work"); + for host in ["dv-g3b-emudhra.we-have-a-problem.com", "dv-g3b-aaa-emudhra.we-have-a-problem.com"] { + let error = client.head(format!("https://{host}/")).send().expect_err("compromised intermediate must be rejected"); + assert!(error.is_connect(), "failure must occur during connection verification"); + assert!(format!("{error:?}").contains("InvalidCertificate"), "must be certificate rejection, not an unrelated network failure: {error:?}"); + } + } + #[test] fn retained_results_accept_original_bodies_without_removing_the_image_array() { let image = json!({"created":1,"data":[{"b64_json":"test"}]}); assert_eq!(retained_body(image.clone()).unwrap(), image); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs index ef185e8f..44dafbf1 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs @@ -55,7 +55,7 @@ fn runtime() -> R<(PathBuf, PathBuf)> { #[cfg(debug_assertions)] { let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../detail-agent-pi/src/desktop-host.mjs"); - let node = PathBuf::from(if cfg!(windows) { "C:/Program Files/nodejs/node.exe" } else { "/usr/bin/node" }); + let node = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../detail-agent-pi/runtime").join(if cfg!(windows) { "node.exe" } else { "node" }); if script.is_file() && node.is_file() { return Ok((node, script)); } } Err("客户端缺少Agent运行组件,请保留项目并安装完整版本".into()) diff --git a/build-base-image.ps1 b/build-base-image.ps1 index 004bd2ea..d75f7b47 100644 --- a/build-base-image.ps1 +++ b/build-base-image.ps1 @@ -44,32 +44,18 @@ function Upload-File { $RemoteBuildDir = "/opt/youyidian-3d-base-build" -# 步骤 1:检查缓存 -Write-Host "📦 步骤 1:检查缓存文件" -ForegroundColor Magenta -$cacheCheck = Invoke-BuildServer -Command "test -d /opt/build-cache/alpine-apk && test -d /opt/build-cache/node_modules && echo 'OK' || echo 'MISSING'" -Silent -if ($cacheCheck -notmatch "OK") { - Write-Host "❌ 缓存文件不完整,请先运行 setup-build-cache.ps1" -ForegroundColor Red - exit 1 -} -Write-Host "✅ 缓存文件已就绪" -ForegroundColor Green +# 历史 Node 18 缓存不能跨 ABI 复用;由当前锁文件重新安装原生依赖。 +Write-Host "📦 步骤 1:核对依赖锁文件" -ForegroundColor Magenta +if (!(Test-Path 'package-lock.json')) { throw '缺少 package-lock.json,拒绝构建' } Write-Host "" # 步骤 2:准备构建目录 Write-Host "📦 步骤 2:准备构建目录" -ForegroundColor Magenta -Invoke-BuildServer -Command "mkdir -p $RemoteBuildDir/cache" -Silent | Out-Null - -# 使用硬链接复制缓存(零额外空间,瞬间完成) -Write-Host " 准备缓存文件(使用硬链接,零额外空间)..." -ForegroundColor Yellow -Invoke-BuildServer -Command @" -cd $RemoteBuildDir && \ -rm -rf cache/alpine-apk cache/node_modules 2>/dev/null || true && \ -mkdir -p cache && \ -cp -al /opt/build-cache/alpine-apk cache/ && \ -cp -al /opt/build-cache/node_modules cache/ && \ -echo 'OK' -"@ -Silent | Out-Null +Invoke-BuildServer -Command "mkdir -p $RemoteBuildDir" -Silent | Out-Null Upload-File -LocalPath "Dockerfile.base" -RemotePath "$RemoteBuildDir/Dockerfile" +Upload-File -LocalPath "package.json" -RemotePath "$RemoteBuildDir/package.json" +Upload-File -LocalPath "package-lock.json" -RemotePath "$RemoteBuildDir/package-lock.json" Upload-File -LocalPath "prisma/schema.prisma" -RemotePath "$RemoteBuildDir/schema.prisma" Invoke-BuildServer -Command "mkdir -p $RemoteBuildDir/prisma && mv $RemoteBuildDir/schema.prisma $RemoteBuildDir/prisma/" -Silent | Out-Null diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e819de9a..c3f5f99e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -7,7 +7,7 @@ services: build: target: dev args: - BASE_IMAGE: docker.m.daocloud.io/library/node:22-alpine + BASE_IMAGE: node:22.23.4-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 image: gongxiangp2-local-dev:latest environment: NODE_ENV: development diff --git a/docker-compose.local-test.yml b/docker-compose.local-test.yml index e96aa007..08f9828d 100644 --- a/docker-compose.local-test.yml +++ b/docker-compose.local-test.yml @@ -71,7 +71,7 @@ services: context: . dockerfile: Dockerfile args: - BASE_IMAGE: docker.m.daocloud.io/library/node:18-alpine + BASE_IMAGE: node:22.23.4-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 image: gongxiangp2-local-test:latest restart: unless-stopped depends_on: diff --git a/docs/runtime-security-upgrade-2026-10-22.md b/docs/runtime-security-upgrade-2026-10-22.md new file mode 100644 index 00000000..0ae6e986 --- /dev/null +++ b/docs/runtime-security-upgrade-2026-10-22.md @@ -0,0 +1,77 @@ +# 运行时安全升级与回滚 + +## 范围 + +| 组件 | 本次目标 | 边界 | +| --- | --- | --- | +| 素材库 Web、独立渲染进程 | Node 22.23.4,固定官方镜像清单 SHA-256 | 重新编译 Canvas / Sharp,保留现有应用、权限与存储契约 | +| 通知中心 | Node 22.23.4 | 独立仓库;临时 SQLite 回归,不发送真实通知 | +| 本机 Pi | Node 24.22.1 或同一 24.x 线后续安全版本 | 随客户端打包;主入口、SDK 入口、打包均拒绝旧版本 | +| 原生客户端 HTTPS | rustls-webpki 0.103.16、webpki-root-certs 1.0.10 | 保留 Rustls 校验,不关闭 TLS 验证 | +| MinIO | 原 2025-04-22 源码,Go 1.26.10 重编译 | 原协议、命令、数据卷与版本格式不变 | +| frpc | 原 0.67.0 源码,Go 1.26.10 重编译 | 配置不变,四个入口逐个切换、逐个检查 | +| TrueNAS 自带 Docker、系统 rclone | **另行安排 NAS 维护** | 用户已确认分开处理,本轮不改只读系统、不重启 Docker / NAS,不宣称已修复 | + +生产链路没有 PHP / PHP-FPM;不为本次更新新增 PHP。 +不修改其他项目的全局 Node、操作系统配置或模型上游。 + +## 依据与可重建性 + +- [Node 官方 2026-10 安全更新](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) +- [Rustls 证书验证修复](https://rustsec.org/advisories/RUSTSEC-2026-0382.html)、[内置证书修复](https://rustsec.org/advisories/RUSTSEC-2026-0383.html) +- [Go 官方证书信任修复 / CVE-2026-111943](https://go.dev/issue/82345) +- [TrueNAS 官方开发模式限制](https://www.truenas.com/docs/scale/systemsettings/advanced/developermode/) + +Go 源码的提交、归档哈希和版本在 `scripts/security-runtime/go-sources.json`; +构建镜像、参数和上游许可证保留在 `go-rebuild.Dockerfile` 的构建结果中。 +`GOTOOLCHAIN=local` 防止模块声明把工具链降回旧版本。自编译包不冒充上游新发行版。 + +```powershell +node scripts/security-runtime/prepare-go-context.mjs output/security-go-build +docker build --target artifacts -f output/security-go-build/Dockerfile output/security-go-build +``` + +`tls-probe.go` 只执行 TLS 握手:正常站点必须通过,两个失信证书样例必须被证书验证拒绝; +DNS、超时和网络错误不能算修复通过。不发送 HTTP、API Key 或业务数据。 +本机 Node 使用相同的正反例验证;Rust 的显式联网验收测试默认 ignored,须单独执行。 + +历史 `Dockerfile.base` 改为按锁文件重新安装原生依赖,禁止导入 Node 18 的二进制缓存。 +旧 `setup-build-cache.ps1` 不是当前生产链路;新基础镜像构建不读取它生成的缓存。 +开发 Compose 与本地验收 Compose 也固定同一安全版本,避免参数覆盖回旧 Node。 + +## 本地兼容门禁 + +已完成: + +- Node 22 下账号、素材权限、分片上传、下载、原图和报告外部接口、Agent 规则与异常恢复:15 个套件、155 项通过。 +- 渲染器:中文字体、图片比例、PNG、长图、可编辑 PSD、非法输入等 6 项通过。 +- Pi:31 项回归;客户端主程序:182 项通过、严格 Clippy 通过、31 个 crate 分层检查通过。 +- 真实本地 1440 × 12337 多屏文件:中文编辑、撤销重做、保存重开、分层 PSD 重读通过。 +- 更新后的本地客户端恢复原账户、56 条对话及原画布;原生模型桥只读检查通过。没有新增模型请求。 +- MinIO 在同一隔离测试卷上依次运行原版、修复版、回退原版;图片、PDF、ZIP、RAR 的逐字节校验、Range、分页、元数据均通过。 +- frpc 0.67.0 的配置校验、TLS、Token 鉴权和 TCP 转发在隔离网络通过。 +- Node、Rust、Go 均通过正常 HTTPS / 失信证书正反例。实际生产替换后须再次核对运行版本与证书行为。 + +图形界面和模型调用流程不在本次重写。没有用新的付费调用代替兼容回归;未知请求不重发。 + +## 数据保护与生产切换 + +备份位于生产机私有目录: +`/mnt/PoolOne/apps/youyidian-3d/security-upgrade-20261022/backup`。 +PostgreSQL 使用一致性逻辑备份并验证 TOC;通知 SQLite 使用在线备份并通过完整性检查。 +MinIO 数据集和上传素材数据集均建立 `security-upgrade-20261022` 快照并加 hold。 +环境、容器配置和原镜像保留供回滚;不得把这些敏感备份纳入 Git 或日志输出。 + +1. 只打包已提交的源代码,不把工作区无关改动混入发布。 +2. 镜像归档逐份校验哈希、Linux/amd64、真实 config ID 与版本标签。 +3. 记录 DB / Redis 容器身份、素材挂载、队列及未知请求指纹;有活动任务先等待。 +4. MinIO 与通知中心分别替换,故障立即切回原容器;禁止两个 MinIO 同时写生产数据卷。 +5. frpc 先用原服务环境校验配置,再逐个重启;每个入口核对原业务健康,失败恢复原二进制。 +6. Web 按现有增量发布流程部署。无数据库迁移、数据恢复、素材清理或自动补发模型。 +7. 检查公网页面、权限、健康端点、真实素材读取 / Range、通知队列、Node 和 Go 身份。 + +回滚优先恢复程序和镜像,**不能自动回滚素材 ZFS 快照**,避免覆盖切换后新增的用户文件。 +数据库引擎、库结构和用户文件均不属于本次修改范围。 + +本地构建与验收原始证据保留在 `output/security-upgrade-20261022/`。 +客户端包为本机未签名开发包,不能当作已签名正式安装程序分发。 diff --git a/package-lock.json b/package-lock.json index e41ecebe..3d150f58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,9 @@ }, "optionalDependencies": { "canvas": "^2.11.2" + }, + "engines": { + "node": ">=22.23.4 <23 || >=24.22.1 <25" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index ae455b03..ed002b42 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,11 @@ "canvas": "^2.11.2" }, "overrides": { - "fabric": { "canvas": "2.11.2" } + "fabric": { + "canvas": "2.11.2" + } + }, + "engines": { + "node": ">=22.23.4 <23 || >=24.22.1 <25" } } diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index f3b40ef3..36bc635d 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -907,7 +907,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.match( dockerfile, - /^ARG BASE_IMAGE=node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e$/m, + /^ARG BASE_IMAGE=node:22.23.4-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914$/m, ) assert.match(deps, /^COPY package\.json package-lock\.json \.\/$/m) assert.match(deps, /npm ci --prefer-offline --no-audit --no-fund --legacy-peer-deps/) diff --git a/scripts/security-runtime/go-rebuild.Dockerfile b/scripts/security-runtime/go-rebuild.Dockerfile new file mode 100644 index 00000000..96aee25e --- /dev/null +++ b/scripts/security-runtime/go-rebuild.Dockerfile @@ -0,0 +1,49 @@ +# Rebuild the deployed sources without changing service protocols or storage format. +ARG GO_IMAGE=golang:1.26.10-bookworm@sha256:632dff7351799a5a9954a48dc06670ade72ee44e8c351cdc0e5a57af2fea21af +ARG NODE_IMAGE=node:22.23.4-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 +FROM ${GO_IMAGE} AS source +ENV GOTOOLCHAIN=local CGO_ENABLED=0 GOMAXPROCS=2 GOMEMLIMIT=512MiB GOFLAGS=-p=1 +COPY *.tar.gz sources.sha256 /sources/ +RUN cd /sources && sha256sum -c sources.sha256 && \ + mkdir /frpc /rclone /minio /out && \ + tar -xzf frpc.tar.gz -C /frpc --strip-components=1 && \ + tar -xzf rclone.tar.gz -C /rclone --strip-components=1 && \ + tar -xzf minio.tar.gz -C /minio --strip-components=1 +COPY tls-probe.go /probe/main.go +RUN cd /probe && go build -trimpath -o /out/tls-probe main.go + +FROM ${NODE_IMAGE} AS frp-web +COPY --from=source /frpc/web /web +WORKDIR /web/frpc +RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund && npm run build +WORKDIR /web/frps +RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund && npm run build + +FROM source AS frpc +WORKDIR /frpc +COPY --from=frp-web /web/frpc/dist ./web/frpc/dist +COPY --from=frp-web /web/frps/dist ./web/frps/dist +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go build -trimpath -tags frpc -ldflags '-s -w' -o /out/frpc ./cmd/frpc && \ + go build -trimpath -tags frps -ldflags '-s -w' -o /out/frps ./cmd/frps && \ + go version -m /out/frpc > /out/frpc.buildinfo && \ + cp LICENSE /out/frpc.LICENSE + +FROM source AS rclone +WORKDIR /rclone +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go build -trimpath -ldflags '-s -w -X github.com/rclone/rclone/fs.Version=v1.67.0' -o /out/rclone . && \ + go version -m /out/rclone > /out/rclone.buildinfo && \ + cp COPYING /out/rclone.LICENSE + +FROM source AS minio +WORKDIR /minio +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go build -trimpath -tags kqueue -ldflags '-s -w -X github.com/minio/minio/cmd.Version=2025-04-22T22:12:26Z -X github.com/minio/minio/cmd.ReleaseTag=RELEASE.2025-04-22T22-12-26Z -X github.com/minio/minio/cmd.CopyrightYear=2025 -X github.com/minio/minio/cmd.CommitID=0d7408fc9969caf07de6a8c3a84f9fbb10a6739e -X github.com/minio/minio/cmd.ShortCommitID=0d7408fc9969' -o /out/minio . && \ + go version -m /out/minio > /out/minio.buildinfo && \ + cp LICENSE /out/minio.LICENSE + +FROM scratch AS artifacts +COPY --from=frpc /out/ /out/ +COPY --from=rclone /out/ /out/ +COPY --from=minio /out/ /out/ diff --git a/scripts/security-runtime/go-sources.json b/scripts/security-runtime/go-sources.json new file mode 100644 index 00000000..4bb2f388 --- /dev/null +++ b/scripts/security-runtime/go-sources.json @@ -0,0 +1,26 @@ +[ + { + "name": "frpc", + "repo": "fatedier/frp", + "tag": "v0.67.0", + "commit": "5f575b84420a19350467290c5c2bf9f4c7b27e57", + "bytes": 517108, + "sha256": "a6c6aa5d221f3810d76efdbee15f5784d0945f8e636861b236a22be0447a6791" + }, + { + "name": "rclone", + "repo": "rclone/rclone", + "tag": "v1.67.0", + "commit": "93e8a976ef686a4bd8e3afaf2016734c81881507", + "bytes": 17233524, + "sha256": "7d61dd139534892aa35611f7aac3915dee3f2d74561f494e4e215f56d52284b0" + }, + { + "name": "minio", + "repo": "minio/minio", + "tag": "RELEASE.2025-04-22T22-12-26Z", + "commit": "0d7408fc9969caf07de6a8c3a84f9fbb10a6739e", + "bytes": 23956843, + "sha256": "7eb30a913fea30f18069abf194e1e78e4983b558cc526911ae1c11396a9859a5" + } +] diff --git a/scripts/security-runtime/prepare-go-context.mjs b/scripts/security-runtime/prepare-go-context.mjs new file mode 100644 index 00000000..5efbd69b --- /dev/null +++ b/scripts/security-runtime/prepare-go-context.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createHash} from 'node:crypto'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const target = path.resolve(process.argv[2] || 'output/security-go-build'); +await fs.mkdir(target, {recursive:true}); +const pins = JSON.parse(await fs.readFile(path.join(here, 'go-sources.json'))); +const checks = []; +for (const pin of pins) { + const file = path.join(target, pin.name + '.tar.gz'); + let bytes; + try { bytes = await fs.readFile(file); } catch (error) { + if (error.code !== 'ENOENT') throw error; + const response = await fetch(`https://codeload.github.com/${pin.repo}/tar.gz/${pin.commit}`); + if (!response.ok) throw Error(`Source download: ${pin.name} HTTP ${response.status}`); + bytes = Buffer.from(await response.arrayBuffer()); + if (createHash('sha256').update(bytes).digest('hex') !== pin.sha256) throw Error(`Source checksum: ${pin.name}`); + await fs.writeFile(file, bytes, {flag:'wx'}); + } + if (createHash('sha256').update(bytes).digest('hex') !== pin.sha256) throw Error(`Source checksum: ${pin.name}`); + checks.push(`${pin.sha256} ${pin.name}.tar.gz`); +} +await fs.writeFile(path.join(target, 'sources.sha256'), checks.join('\n') + '\n'); +for (const [from,to] of [['go-rebuild.Dockerfile','Dockerfile'],['tls-probe.go','tls-probe.go']]) { + await fs.copyFile(path.join(here, from), path.join(target,to)); +} +console.log(JSON.stringify({target, sourcesVerified:pins.length, go:'1.26.10'})); diff --git a/scripts/security-runtime/tls-probe.go b/scripts/security-runtime/tls-probe.go new file mode 100644 index 00000000..fe6c53e2 --- /dev/null +++ b/scripts/security-runtime/tls-probe.go @@ -0,0 +1,37 @@ +// TLS-only acceptance: no HTTP requests, accounts, API Keys, or business data. +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "os" + "runtime" + "strings" + "time" +) + +func main() { + targets := []string{"nodejs.org", "dv-g3b-emudhra.we-have-a-problem.com", "dv-g3b-aaa-emudhra.we-have-a-problem.com"} + results := []map[string]any{} + passed := true + for i, host := range targets { + c, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", host+":443", &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}) + row := map[string]any{"host": host} + if err == nil { + c.Close() + row["result"] = "accepted" + if i != 0 { passed = false } + } else { + row["error"] = err.Error() + row["result"] = "inconclusive" + if strings.Contains(err.Error(), "x509:") { row["result"] = "certificate_rejected" } + if i == 0 || row["result"] != "certificate_rejected" { passed = false } + } + results = append(results, row) + } + b, _ := json.Marshal(map[string]any{"go":runtime.Version(), "httpRequests":0, "passed":passed, "results":results}) + fmt.Println(string(b)) + if !passed { os.Exit(1) } +}