fix(security): patch Node Rust and isolated Go service runtimes

This commit is contained in:
WuYongJie 2026-10-22 11:55:15 +08:00
parent 1b6425b552
commit eba7ef55e6
25 changed files with 298 additions and 52 deletions

View File

@ -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 直接失败。

View File

@ -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", "这是基础镜像,不应该直接运行"]

View File

@ -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

View File

@ -16,7 +16,7 @@
"quickjs-emscripten": "0.32.0"
},
"engines": {
"node": ">=24.16.0"
"node": ">=24.22.1 <25"
}
},
"node_modules/@anthropic-ai/sdk": {

View File

@ -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",

View File

@ -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'));

View File

@ -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();

View File

@ -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;

View File

@ -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安全版本,请更新完整客户端');
}
}

View File

@ -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));
});

View File

@ -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",
]

View File

@ -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、所选生图模型,返回图片作为独立图层。
“重生成”只替换当前屏选中的一张图片,保留文字、字体和其他屏。按原图框等比裁切,
替换及必要的裁切蒙版在同一次撤销内;重复替换保持同一图框。

View File

@ -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);

View File

@ -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())

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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/`。
客户端包为本机未签名开发包,不能当作已签名正式安装程序分发。

3
package-lock.json generated
View File

@ -74,6 +74,9 @@
},
"optionalDependencies": {
"canvas": "^2.11.2"
},
"engines": {
"node": ">=22.23.4 <23 || >=24.22.1 <25"
}
},
"node_modules/@adobe/css-tools": {

View File

@ -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"
}
}

View File

@ -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/)

View File

@ -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/

View File

@ -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"
}
]

View File

@ -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'}));

View File

@ -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) }
}