diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 8f641dfe6..be20e97cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1767,6 +1767,7 @@ dependencies = [ "jsonschema", "libc", "maud", + "module-game-distribution", "nalgebra", "oxc_allocator", "oxc_ast", @@ -2890,6 +2891,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "module-game-distribution" +version = "0.1.0" +dependencies = [ + "hex", + "serde", + "sha2", + "shared-kernel", + "zip 2.4.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -5000,6 +5012,14 @@ dependencies = [ "ts-rs 12.0.1", ] +[[package]] +name = "shared-kernel" +version = "0.1.0" +dependencies = [ + "time", + "uuid", +] + [[package]] name = "shared_library" version = "0.1.9" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 5f3f50505..592d63419 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -82,6 +82,11 @@ zip = { version = "2", default-features = false, features = ["deflate"] } tauri-plugin-clipboard-manager = "2.3.2" maud = "0.27.0" +# P1-b 执行级交叉证据:客户端打包器产出的字节直接喂给服务端校验器 +# (`module-game-distribution::validate_project_bundle_zip`),替代只有文本级的一致性门禁。 +[dev-dependencies] +module-game-distribution = { path = "../../../server-rs/crates/module-game-distribution" } + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs index 1ef1f0543..6f55748e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs @@ -31,14 +31,21 @@ //! | --- | --- | --- | //! | 依赖与版本库 | 任意层级 `node_modules` / `.git` / `.svn` | 依赖可重装;版本库元数据泄漏提交历史与远端地址 | //! | AGC 平台保留名 | 任意层级 `.agent` | 项目身份 + Agent 运行数据 + 本机改编来源记录,见 `bundle_excludes_agc_owned_dir` | +//! | 凭据目录 | 任意层级 `.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets` | 长期凭据、集群与注册表配置、私钥环、Terraform 状态(可能含明文 secret) | //! | 构建产物 | 项目根首层 `dist` / `build` / `library` / `temp` / `local`(服务端权威);**客户端额外**:`game/` 首层 `dist` / `build` | 产物可重建;AGC 网页脚手架的 vite `outDir` 就落在 `game/dist`,源码包不该带 | //! | IDE / 编辑器 | 项目根首层 `.idea` / `.vscode`(服务端权威);**客户端额外** `.godot` | 编辑器缓存,模板包指南同样排除 | -//! | 凭据与隐私 | 任意层级 前缀 `.env` / `id_rsa` / `id_ed25519`、后缀 `.pem` / `.key` / `.p12` / `.pfx` / `.map` / `.zip`、全名 `.npmrc` / `.netrc` / `.git-credentials` | 凭据外发不可撤销;`.map` 可反推源码与内部路径;`.zip` 是嵌套压缩包,解包阶段不递归校验等于绕过整份清单 | +//! | 凭据与隐私 | 任意层级 前缀 `.env` / `credentials` / `id_rsa` / `id_ecdsa` / `id_dsa` / `id_ed25519` / `terraform.tfstate` / `service-account`、后缀 `.pem` / `.key` / `.p12` / `.pfx` / `.jks` / `.keystore` / `.ppk` / `.p8` / `.kdbx` / `.der` / `.map` / `.zip`、全名 `.npmrc` / `.netrc` / `.git-credentials` / `.htpasswd` / `.pgpass` | 凭据外发不可撤销;`.map` 可反推源码与内部路径;`.zip` 是嵌套压缩包,解包阶段不递归校验等于绕过整份清单 | +//! | 嵌套压缩包 | 任意层级后缀并集 `.zip` / `.tar` / `.gz` / `.tgz` / `.7z` / `.rar` / `.jar` / `.whl` / `.nupkg`,并在打包每个文件前**嗅探头部字节**(改名成 `.dat` 也拒) | 解包阶段不递归校验包里的包 | +//! | 凭据内容 | 文本型(白名单扩展名或无扩展名)且 ≤ 256 KiB 的条目做特征扫描:PEM 私钥块、AWS Access Key ID、GitHub / Slack token | 兜住「未知凭据文件名」;只做低误报强特征,命中即**报错**而不是静默跳过 | //! | AGC 生成物 | **客户端额外** 项目根首层 `exports` / `memory` | 见 `bundle_excludes_agc_owned_dir` | //! +//! 路径形状(含结尾点 / 空格、`<>"\|\?*`、空段、`.`/`..` 段)由 `bundle_entry_path_shape_error` +//! 按服务端 `normalize_archive_path` 的口径拒绝:**报错**而不是静默改名,否则作者会打出服务端 +//! 必然 422 的包。 +//! //! 规模上限**与服务端逐项相等**(服务端是安全边界):压缩包 ≤ 200 MiB、展开总量 ≤ 500 MiB、 -//! 单文件 ≤ 64 MiB、条目 ≤ 10 000、单文件 ≤ 包体 × 100。超限**失败关闭**并点名违规条目, -//! 不静默截断——客户端先拦,就不会出现「传到一半被服务端 422 拒掉」。 +//! 单文件 ≤ 64 MiB、条目 ≤ 10 000、单条目声明解压大小 ≤ 该条目压缩字节 × 100。超限**失败关闭** +//! 并点名违规条目,不静默截断——客户端先拦,就不会出现「传到一半被服务端 422 拒掉」。 //! //! 调用方:发布链路的 `game_distribution_publish::upload_project_bundle_for_version`(授权非 //! 「禁止共创」且作者未取消时,随版本上传工程源包);取件侧的摘要校验与它共用 `sha256_hex`。 @@ -54,11 +61,25 @@ pub(crate) const PROJECT_BUNDLE_MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; pub(crate) const PROJECT_BUNDLE_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; /// 条目数上限:与服务端 `MAX_PROJECT_FILE_COUNT` 相等。 pub(crate) const PROJECT_BUNDLE_MAX_FILES: usize = 10_000; -/// 单文件相对包体的倍数上限:与服务端 `MAX_PROJECT_COMPRESSION_RATIO` 相等(zip 炸弹防线)。 +/// 单条目压缩比上限:与服务端 `MAX_PROJECT_COMPRESSION_RATIO` 相等。分母是**该条目自身 +/// 的压缩字节**(不是整包体积):整包分母下,一个 10 MiB 的包单条可以声明 1 GiB 而不触发。 pub(crate) const PROJECT_BUNDLE_MAX_COMPRESSION_RATIO: u64 = 100; -/// 任意层级都排除的目录名(依赖、版本库元数据、平台保留名)。 -const BUNDLE_EXCLUDED_ANY_LEVEL_DIRS: &[&str] = &["node_modules", ".git", ".svn", ".agent"]; +/// 任意层级都排除的目录名(依赖、版本库元数据、平台保留名、凭据目录)。 +const BUNDLE_EXCLUDED_ANY_LEVEL_DIRS: &[&str] = &[ + "node_modules", + ".git", + ".svn", + ".agent", + // 凭据目录:家庭目录里那些装长期凭据 / 集群配置 / 私钥环 / Terraform 状态的目录。 + ".aws", + ".ssh", + ".kube", + ".docker", + ".gnupg", + ".terraform", + ".secrets", +]; /// 项目根首层排除的构建产物目录(服务端权威口径)。 const BUNDLE_EXCLUDED_ROOT_BUILD_DIRS: &[&str] = &["dist", "build", "library", "temp", "local"]; /// 项目根首层排除的 IDE / 编辑器目录(`.godot` 是客户端额外项)。 @@ -67,12 +88,81 @@ const BUNDLE_EXCLUDED_ROOT_IDE_DIRS: &[&str] = &[".idea", ".vscode", ".godot"]; const BUNDLE_EXCLUDED_GAME_BUILD_DIRS: &[&str] = &["dist", "build"]; /// **客户端额外项**:项目根首层排除的 AGC 生成目录(`exports` 试玩包、`memory` Agent 记忆)。 const BUNDLE_EXCLUDED_ROOT_AGC_DIRS: &[&str] = &["exports", "memory"]; -/// 任意层级按**前缀**排除的文件名(`.env*` / SSH 私钥名)。 -const BUNDLE_EXCLUDED_FILE_PREFIXES: &[&str] = &[".env", "id_rsa", "id_ed25519"]; -/// 任意层级按**后缀**排除的文件名:凭据、源码映射、嵌套压缩包。 -const BUNDLE_EXCLUDED_FILE_SUFFIXES: &[&str] = &[".pem", ".key", ".p12", ".pfx", ".map", ".zip"]; +/// 任意层级按**前缀**排除的文件名(`.env*`、SSH 私钥名、云凭据文件名)。 +const BUNDLE_EXCLUDED_FILE_PREFIXES: &[&str] = &[ + ".env", + "credentials", + "id_rsa", + "id_ecdsa", + "id_dsa", + "id_ed25519", + "terraform.tfstate", + "service-account", +]; +/// 任意层级按**后缀**排除的文件名:凭据、源码映射、嵌套压缩包(扩展名并集)。 +const BUNDLE_EXCLUDED_FILE_SUFFIXES: &[&str] = &[ + ".pem", + ".key", + ".p12", + ".pfx", + ".jks", + ".keystore", + ".ppk", + ".p8", + ".kdbx", + ".der", + ".map", + ".zip", + ".tar", + ".gz", + ".tgz", + ".7z", + ".rar", + ".jar", + ".whl", + ".nupkg", +]; /// 任意层级按**全名**排除的文件名。 -const BUNDLE_EXCLUDED_FILE_NAMES: &[&str] = &[".npmrc", ".netrc", ".git-credentials"]; +const BUNDLE_EXCLUDED_FILE_NAMES: &[&str] = &[ + ".npmrc", + ".netrc", + ".git-credentials", + ".htpasswd", + ".pgpass", +]; + +/// 与服务端 `normalize_archive_path` 逐字符对齐:路径段里出现任一字符即拒。 +/// +/// 含 `\`:服务端在路径层就拒反斜杠(`path.contains('\\')`),一致门禁把该字面量并入 +/// 「禁止字符」集比对,所以这里也列出来;函数里另有显式的反斜杠分支先拦(语义不变)。 +pub(crate) const BUNDLE_FORBIDDEN_PATH_CHARS: &[char] = &[':', '<', '>', '"', '|', '?', '*', '\\']; +/// 路径段以任一字符结尾即拒(服务端同口径)。 +pub(crate) const BUNDLE_FORBIDDEN_PATH_SUFFIX_CHARS: &[char] = &[' ', '.']; +/// 不允许出现的路径段(服务端同口径)。 +pub(crate) const BUNDLE_FORBIDDEN_PATH_SEGMENTS: &[&str] = &[".", ".."]; + +/// 嵌套压缩包头部嗅探读取的字节数(与服务端同名同值)。 +const NESTED_ARCHIVE_SNIFF_BYTES: usize = 512; + +/// 内容嗅探的文本白名单扩展名(不含点、小写比较):只对文本型条目做凭据特征扫描。 +/// +/// 刻意不做更宽的前缀启发(例如把 `sk-` 也当密钥):误报会直接阻断作者发布, +/// 而 `sk-` 同时也是无害字符串的常见前缀,收益与代价不成比例。宁可漏报未知命名, +/// 也不让作者为一个正常文件反复试错。 +const SECRET_CONTENT_TEXT_EXTENSIONS: &[&str] = &[ + "txt", "md", "json", "yml", "yaml", "toml", "ini", "cfg", "conf", "js", "ts", "tsx", "jsx", + "py", "rs", "go", "java", "sh", "ps1", "bat", "env", +]; +/// 内容嗅探的条目大小上限:与服务端同名同值。 +const SECRET_CONTENT_MAX_SNIFF_BYTES: u64 = 256 * 1024; +/// 简单子串特征:命中任一即判定疑似凭据(GitHub / Slack token)。 +const SECRET_CONTENT_SIGNATURES: &[&str] = &["ghp_", "github_pat_", "xoxa-", "xoxb-", "xoxp-"]; +/// AWS Access Key ID 的固定前缀:后面必须紧跟 16 位大写字母 / 数字才算命中。 +const SECRET_AWS_ACCESS_KEY_PREFIX: &str = "AKIA"; +/// PEM 私钥块的行首标记:必须与 `PEM_PRIVATE_KEY_MARKER` 出现在同一行才判定。 +const PEM_PRIVATE_KEY_LINE_PREFIX: &str = "-----BEGIN "; +/// PEM 私钥块的类型标记。 +const PEM_PRIVATE_KEY_MARKER: &str = "PRIVATE KEY-----"; /// ZIP 里给每个条目写的固定时间戳:ZIP 能表示的最早时刻(1980-01-01 00:00:00)。 /// 同内容必须得到同一份字节,所以绝不写「打包时刻」。 @@ -182,13 +272,27 @@ pub(crate) fn build_project_bundle_with_limits( limits.max_archive_bytes )); } - // 压缩比:与服务端同一判据(单文件声明大小 ≤ 包体 × 倍数),防 zip 炸弹。 - let archive_bytes = bytes.len() as u64; - for entry in &entries { - if entry.size > archive_bytes.saturating_mul(limits.max_compression_ratio) { + // 压缩比:与服务端同一判据——**单条目**声明解压大小 ≤ 该条目压缩字节 × 倍数。 + // 直接回读刚产出的 zip 中央目录,拿到的就是服务端会读到的同一组数值。 + // 分母不能用整包字节:那样一个几 MB 的包单条声明 1 GiB 也不触发,起不到防线作用; + // 整包维度由上面的包体积上限与收集阶段的单文件 / 累计展开量上限承担。 + let mut produced = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())) + .map_err(|error| format!("工程源包自检失败:{error}"))?; + for index in 0..entries.len() { + let file = produced + .by_index(index) + .map_err(|error| format!("工程源包自检失败:{error}"))?; + if file.size() + > file + .compressed_size() + .saturating_mul(limits.max_compression_ratio) + { return Err(format!( - "工程源包条目压缩比超过上限({} 字节的解压内容 > 包体 {} 字节 × {}):{}", - entry.size, archive_bytes, limits.max_compression_ratio, entry.entry_path + "工程源包条目压缩比超过上限({} 字节的解压内容 > 压缩后 {} 字节 × {}):{}", + file.size(), + file.compressed_size(), + limits.max_compression_ratio, + file.name() )); } } @@ -273,6 +377,11 @@ fn collect_project_bundle_entries( let entry_path = crate::template_library::safe_archive_relative_path(&project_relative) .map_err(|_| format!("工程源包条目路径无效:{project_relative}"))?; let entry_path = entry_path.to_string_lossy().replace('\\', "/"); + // 路径形状再按服务端 `normalize_archive_path` 逐字符核一遍:命中即**报错**, + // 不做 trim / 丢段那种静默改名(改名后作者不知道,服务端还会 422)。 + if let Some(error) = bundle_entry_path_shape_error(&entry_path) { + return Err(error); + } if !case_folded_entry_paths.insert(entry_path.to_ascii_lowercase()) { return Err(format!( "工程源包里有大小写折叠后重名的条目(服务端会拒收):{entry_path}" @@ -319,6 +428,23 @@ fn write_project_bundle_zip(entries: &[ProjectBundleEntry]) -> Result, S entry.project_relative )); } + // 嵌套压缩包:扩展名并集已经在收集阶段排掉了,这里按**头部字节**兜住改名 + // (`deps.dat` 里装的是 zip / 7z / rar / gzip / tar 一样拒),命中即报错点名路径。 + if let Some(format) = nested_archive_format(&content) { + return Err(format!( + "工程源包不允许嵌套压缩包:{} 是 {format} 格式,请先在本地解开或删掉后再打包", + entry.project_relative + )); + } + // 凭据内容嗅探:只扫文本型且不大的条目,命中即报错并点名命中的特征。 + if is_secret_content_candidate(&entry.entry_path, entry.size) { + if let Some(signature) = secret_content_signature(&content) { + return Err(format!( + "工程源包条目疑似包含凭据(命中 {signature}),已阻断打包:{}", + entry.project_relative + )); + } + } writer .start_file(entry.entry_path.as_str(), options) .map_err(|error| format!("写入工程源包条目失败:{}: {error}", entry.entry_path))?; @@ -389,6 +515,141 @@ fn bundle_excludes_sensitive_file_name(file_name: &str) -> bool { .any(|excluded| name == *excluded) } +/// 条目路径形状检查:与服务端 `normalize_archive_path` 逐字符对齐。 +/// +/// 返回 `Some(可读错误)` 表示该条目名会被服务端 422(`InvalidPath`),客户端必须提前 +/// **报错**——不做「整体 trim + 丢空段 / `.` 段」那种静默改名(改名后作者不知道, +/// 服务端照样拒)。规则全部来自 `BUNDLE_FORBIDDEN_PATH_*` 常量,便于一致性门禁比对。 +fn bundle_entry_path_shape_error(entry_path: &str) -> Option { + if entry_path.is_empty() { + return Some("工程源包条目路径为空".to_string()); + } + if entry_path.contains('\\') { + return Some(format!("工程源包条目路径不能包含反斜杠:{entry_path}")); + } + if entry_path.starts_with('/') { + return Some(format!("工程源包条目路径不能以 / 开头:{entry_path}")); + } + for segment in entry_path.split('/') { + if segment.is_empty() { + return Some(format!( + "工程源包条目路径不能包含空段(连续的 /):{entry_path}" + )); + } + if BUNDLE_FORBIDDEN_PATH_SEGMENTS.contains(&segment) { + return Some(format!( + "工程源包条目路径不能包含 {segment} 段:{entry_path}" + )); + } + if segment.ends_with(BUNDLE_FORBIDDEN_PATH_SUFFIX_CHARS) { + return Some(format!( + "工程源包条目路径段不能以空格或点结尾(服务端会拒收):{entry_path}" + )); + } + if let Some(character) = segment + .chars() + .find(|character| BUNDLE_FORBIDDEN_PATH_CHARS.contains(character)) + { + return Some(format!( + "工程源包条目路径不能包含字符 {character:?}(服务端会拒收):{entry_path}" + )); + } + } + None +} + +/// 嵌套压缩包头部嗅探:从头 512 字节判已知归档格式,返回格式名。 +/// +/// 判的是**内容**而不是文件名:改名成 `deps.dat` 的归档靠这里兜住。 +fn nested_archive_format(content: &[u8]) -> Option<&'static str> { + let head = &content[..content.len().min(NESTED_ARCHIVE_SNIFF_BYTES)]; + if head.starts_with(b"PK\x03\x04") { + return Some("zip"); + } + if head.starts_with(b"7z\xBC\xAF\x27\x1C") { + return Some("7z"); + } + if head.starts_with(b"Rar!\x1A\x07") { + return Some("rar"); + } + if head.starts_with(b"\x1F\x8B") { + return Some("gzip"); + } + if head.len() >= 262 && &head[257..262] == b"ustar" { + return Some("tar"); + } + None +} + +/// 是否值得做凭据内容嗅探:非空、≤ 256 KiB,且扩展名属于文本白名单 +/// (**无扩展名也算文本**——`credentials`、`config` 这类凭据文件常常没有扩展名)。 +fn is_secret_content_candidate(entry_path: &str, size: u64) -> bool { + if size == 0 || size > SECRET_CONTENT_MAX_SNIFF_BYTES { + return false; + } + let name = entry_path.rsplit('/').next().unwrap_or(entry_path); + let extension = match name.rfind('.') { + Some(index) if index > 0 && index + 1 < name.len() => &name[index + 1..], + _ => return true, + }; + SECRET_CONTENT_TEXT_EXTENSIONS + .iter() + .any(|allowed| extension.eq_ignore_ascii_case(allowed)) +} + +/// 凭据内容嗅探:命中任一已知强特征就返回特征说明(错误信息用它点名)。 +/// +/// 只做低误报的强特征;刻意不把 `sk-` 之类的前缀算进来(见 `SECRET_CONTENT_TEXT_EXTENSIONS` +/// 的口径说明)——误报会直接阻断作者发布。 +fn secret_content_signature(content: &[u8]) -> Option<&'static str> { + if pem_private_key_detected(content) { + return Some("PEM 私钥块"); + } + if aws_access_key_id_detected(content) { + return Some("AWS Access Key ID"); + } + if SECRET_CONTENT_SIGNATURES + .iter() + .any(|signature| contains_bytes(content, signature.as_bytes())) + { + return Some("GitHub / Slack token"); + } + None +} + +/// PEM 私钥块:行首标记与私钥类型标记出现在**同一行**(证书块不会带 `PRIVATE KEY-----`)。 +fn pem_private_key_detected(content: &[u8]) -> bool { + content.split(|byte| *byte == b'\n').any(|line| { + contains_bytes(line, PEM_PRIVATE_KEY_LINE_PREFIX.as_bytes()) + && contains_bytes(line, PEM_PRIVATE_KEY_MARKER.as_bytes()) + }) +} + +/// AWS Access Key ID:`AKIA` 后紧跟 16 位大写字母或数字。 +fn aws_access_key_id_detected(content: &[u8]) -> bool { + let prefix = SECRET_AWS_ACCESS_KEY_PREFIX.as_bytes(); + content + .windows(prefix.len()) + .enumerate() + .any(|(index, window)| { + window == prefix + && content + .get(index + prefix.len()..index + prefix.len() + 16) + .is_some_and(|tail| { + tail.iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + }) + }) +} + +/// `haystack` 是否包含 `needle`(不引入正则依赖的等价实现)。 +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + #[cfg(test)] mod tests { use super::*; @@ -415,6 +676,20 @@ mod tests { fs::write(&path, content).expect("write fixture file"); } + /// 变化内容的普通源码文本:压缩比远低于 100,用来证明默认倍数不会误拒正常工程。 + fn varied_source_text(len: usize) -> Vec { + let mut output = Vec::with_capacity(len); + let mut counter = 0_u64; + while output.len() < len { + output.extend_from_slice( + format!("const value_{counter} = compute({counter}, \"{counter}\");\n").as_bytes(), + ); + counter += 1; + } + output.truncate(len); + output + } + /// 一份典型 AGC 网页工程:`game/` 源码 + 根 `assets/` + 少量要排除的东西。 fn write_fixture_project(root: &Path) { write_file(root, "game/index.html", b""); @@ -522,6 +797,24 @@ mod tests { // 源码映射会暴露原始源码与内部路径,服务端按隐私类拒收。 write_file(&root, "game/dist/app.js.map", b"{\"version\":3}"); write_file(&root, "game/src/app.js.map", b"{\"version\":3}"); + // P0-a:更多 SSH 私钥名、云凭据文件名、密钥库后缀。 + write_file(&root, "deploy/id_ecdsa", b"private key"); + write_file(&root, "deploy/id_dsa", b"private key"); + write_file(&root, "deploy/credentials.json", b"{\"token\":\"x\"}"); + write_file( + &root, + "ci/service-account.json", + b"{\"type\":\"service_account\"}", + ); + write_file(&root, "infra/terraform.tfstate", b"{}"); + write_file(&root, "certs/truststore.jks", b"jks"); + write_file(&root, "certs/app.keystore", b"keystore"); + write_file(&root, "deploy/key.ppk", b"ppk"); + write_file(&root, "apple/AuthKey.p8", b"p8"); + write_file(&root, "db/passwords.kdbx", b"kdbx"); + write_file(&root, "certs/ca.der", b"der"); + write_file(&root, ".htpasswd", b"user:hash"); + write_file(&root, ".pgpass", b"host:5432:*:user:pass"); let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); let entries = entry_set(&bundle); @@ -540,6 +833,19 @@ mod tests { "game/ios/export.pfx", "game/dist/app.js.map", "game/src/app.js.map", + "deploy/id_ecdsa", + "deploy/id_dsa", + "deploy/credentials.json", + "ci/service-account.json", + "infra/terraform.tfstate", + "certs/truststore.jks", + "certs/app.keystore", + "deploy/key.ppk", + "apple/AuthKey.p8", + "db/passwords.kdbx", + "certs/ca.der", + ".htpasswd", + ".pgpass", ] { assert!(!entries.contains(excluded), "{excluded} 不应进包"); } @@ -567,6 +873,234 @@ mod tests { assert!(entries.contains("assets/hero.png")); } + /// P0-c:扩展名并集逐条排除(`.tar.gz` 由 `.gz` 覆盖)。 + #[test] + fn bundle_excludes_every_nested_archive_extension() { + let root = test_root("nested-extension-union"); + write_fixture_project(&root); + let excluded = [ + "assets/vendor.zip", + "assets/vendor.tar", + "assets/vendor.tar.gz", + "assets/vendor.gz", + "assets/vendor.tgz", + "assets/vendor.7z", + "assets/vendor.rar", + "assets/vendor.jar", + "assets/vendor.whl", + "assets/vendor.nupkg", + ]; + for path in excluded { + write_file(&root, path, b"payload"); + } + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let entries = entry_set(&bundle); + for path in excluded { + assert!(!entries.contains(path), "{path} 不应进包"); + } + assert!(entries.contains("assets/hero.png")); + } + + /// P0-c:改名成 `.dat` 装归档格式 → **报错**(不是静默跳过),错误要点名路径与格式。 + #[test] + fn bundle_rejects_renamed_nested_archives_by_magic_bytes() { + let root = test_root("nested-magic"); + write_fixture_project(&root); + write_file(&root, "game/vendor/deps.dat", b"PK\x03\x04payload"); + let error = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect_err("改名后的 zip 必须报错"); + assert!( + error.contains("game/vendor/deps.dat") && error.contains("zip"), + "错误必须点名路径与格式:{error}" + ); + + write_file(&root, "game/vendor/deps.dat", b"\x1F\x8B\x08\x00payload"); + let error = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect_err("改名后的 gzip 必须报错"); + assert!( + error.contains("game/vendor/deps.dat") && error.contains("gzip"), + "错误必须点名路径与格式:{error}" + ); + + // 正常二进制文件不误报。 + write_file(&root, "game/vendor/deps.dat", b"\x89PNG\r\n\x1a\nrest"); + assert!(build_project_bundle(&root, ProjectBundleScope::WholeProject).is_ok()); + } + + /// P0-a:凭据目录任意层级排除;名字只是前缀的普通目录 / 文件必须保留。 + #[test] + fn bundle_excludes_credential_directories_at_any_level() { + let root = test_root("credential-dirs"); + write_fixture_project(&root); + for excluded in [ + ".aws/config", + ".ssh/known_hosts", + ".kube/config", + ".docker/config.json", + ".gnupg/pubring.kbx", + ".terraform/plugins.json", + ".secrets/vault.json", + "game/nested/.SSH/known_hosts", + ] { + write_file(&root, excluded, b"credential"); + } + write_file(&root, "src/.sshrc", b"harmless"); + write_file(&root, "docker/Dockerfile", b"FROM scratch"); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let entries = entry_set(&bundle); + for excluded in [ + ".aws/config", + ".ssh/known_hosts", + ".kube/config", + ".docker/config.json", + ".gnupg/pubring.kbx", + ".terraform/plugins.json", + ".secrets/vault.json", + "game/nested/.SSH/known_hosts", + ] { + assert!(!entries.contains(excluded), "{excluded} 不应进包"); + } + assert!(entries.contains("src/.sshrc")); + assert!(entries.contains("docker/Dockerfile")); + } + + /// P0-a:文本型小文件命中凭据特征 → 报错点名特征;二进制与弱特征不误报。 + #[test] + fn bundle_rejects_secret_content_in_small_text_files() { + let root = test_root("secret-content"); + write_fixture_project(&root); + write_file( + &root, + "deploy/production-secret", + b"aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n", + ); + let error = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect_err("AWS Access Key ID 必须报错"); + assert!( + error.contains("deploy/production-secret") && error.contains("AWS Access Key ID"), + "错误必须点名路径与特征:{error}" + ); + + fs::remove_file(root.join("deploy/production-secret")).expect("remove fixture"); + write_file( + &root, + "config/pem.txt", + b"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk=\n", + ); + let error = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect_err("PEM 私钥块必须报错"); + assert!( + error.contains("config/pem.txt") && error.contains("PEM"), + "错误必须点名路径与特征:{error}" + ); + + // 误报防线:证书块不是私钥、`AKIA` 长度不足、二进制扩展名不扫。 + fs::remove_file(root.join("config/pem.txt")).expect("remove fixture"); + write_file( + &root, + "docs/cert.txt", + b"-----BEGIN CERTIFICATE-----\nMIIB\n", + ); + write_file( + &root, + "src/notes.md", + b"AKIA-SHORT\nBEGIN without the marker\n", + ); + write_file( + &root, + "assets/logo.png", + b"\x89PNG\r\n\x1a\naws_access_key_id = AKIAIOSFODNN7EXAMPLE", + ); + assert!(build_project_bundle(&root, ProjectBundleScope::WholeProject).is_ok()); + } + + /// P1-a:路径形状与服务端 `normalize_archive_path` 逐字符对齐,命中即报错。 + #[test] + fn bundle_entry_path_shape_error_matches_the_server_rule() { + let rejects: &[(&str, &str)] = &[ + ("src/a?.ts", "?"), + ("x.", "空格或点结尾"), + ("x ", "空格或点结尾"), + ("a//b", "空段"), + ("./x", "不能包含 . 段"), + ("foo/../bar", "不能包含 .. 段"), + ("C:/evil", "不能包含字符 ':'"), + ]; + for (path, expected) in rejects { + let error = + bundle_entry_path_shape_error(path).unwrap_or_else(|| panic!("{path} 必须被拒")); + assert!( + error.contains(expected), + "{path} 的错误应包含 {expected}:{error}" + ); + } + for path in ["game/src/main.ts", "assets/hero.png", ".gitignore"] { + assert_eq!(bundle_entry_path_shape_error(path), None, "{path} 应放行"); + } + } + + /// 用客户端依赖的 zip 直接造一个包:用来证明「服务端是独立把关的一侧」—— + /// 客户端打包器按设计不会把这些条目放进包,所以只能手工构造给服务端校验器。 + fn zip_with(entries: &[(&str, &[u8])]) -> Vec { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (path, content) in entries { + writer + .start_file(*path, zip::write::SimpleFileOptions::default()) + .expect("zip entry"); + writer.write_all(content).expect("zip content"); + } + writer.finish().expect("finish zip").into_inner() + } + + /// P1-b 执行级交叉证据:客户端打包器产出的**真实字节**必须被服务端校验器接受, + /// 且两份清单(路径 / 摘要 / 文件数)逐项一致——不是只靠文本级的一致性门禁。 + #[test] + fn bundle_bytes_are_accepted_by_the_server_validator() { + let root = test_root("cross-validator"); + write_fixture_project(&root); + write_file(&root, "game/src/main.js", b"console.log('hello');"); + write_file( + &root, + "game/src/deep/nested/mod.js", + b"export const ok = true;", + ); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let manifest = module_game_distribution::validate_project_bundle_zip(&bundle.bytes) + .expect("客户端产物必须被服务端校验器接受"); + assert_eq!(manifest.bundle_bytes, bundle.bytes.len() as u64); + assert_eq!(manifest.bundle_sha256, bundle.sha256); + assert_eq!(manifest.files.len(), bundle.file_count); + assert_eq!( + manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(), + bundle + .entry_paths + .iter() + .map(String::as_str) + .collect::>(), + ); + + // 反例:服务端是独立把关的一侧——含 `.env` / `node_modules` 的包必须被拒, + // 而不是「客户端拦了、服务端照收」。 + let with_env = zip_with(&[("package.json", b"{}"), (".env", b"TOKEN=1")]); + assert_eq!( + module_game_distribution::validate_project_bundle_zip(&with_env), + Err(module_game_distribution::ProjectBundleError::SensitiveFileNotAllowed) + ); + let with_dependencies = + zip_with(&[("package.json", b"{}"), ("node_modules/x/index.js", b"x")]); + assert_eq!( + module_game_distribution::validate_project_bundle_zip(&with_dependencies), + Err(module_game_distribution::ProjectBundleError::DependencyDirectoryNotAllowed) + ); + } + #[test] fn bundle_excludes_agent_state_playtest_packages_and_memory() { let root = test_root("agc-owned"); @@ -821,7 +1355,7 @@ mod tests { #[test] fn bundle_fails_closed_when_compression_ratio_exceeds_limit() { let root = test_root("limit-compression-ratio"); - // 4 KiB 的高度可压缩内容:压缩后约几百字节,倍数显然大于 1。 + // 4 KiB 的高度可压缩内容:压缩后几十字节,真实压缩比远大于 1。 write_file(&root, "game/levels/big.txt", &vec![b'a'; 4 * 1024]); let error = build_project_bundle_with_limits( &root, @@ -833,9 +1367,17 @@ mod tests { ) .expect_err("压缩比超过上限必须失败"); assert!(error.contains("压缩比超过上限"), "{error}"); - // 默认倍数(100)下同一份内容必须能正常打包:源码的压缩比远低于上限。 + + // 分母是**单条目的压缩字节**:4 KiB 全 'a' 的真实压缩比超过 100, + // 默认倍数下同样必须拒(旧实现用整包字节做分母,这里会误放行)。 + let error = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect_err("单条目压缩比超标必须失败"); + assert!(error.contains("压缩比超过上限"), "{error}"); + + // 正常源码内容压缩比远低于 100,默认倍数下必须能正常打包。 + write_file(&root, "game/levels/big.txt", &varied_source_text(4 * 1024)); let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject) - .expect("default ratio must accept a compressible source file"); + .expect("default ratio must accept ordinary source text"); assert_eq!(bundle.file_count, 1); } diff --git a/server-rs/crates/module-game-distribution/src/package.rs b/server-rs/crates/module-game-distribution/src/package.rs index 139ddc4b8..e3f0219b5 100644 --- a/server-rs/crates/module-game-distribution/src/package.rs +++ b/server-rs/crates/module-game-distribution/src/package.rs @@ -19,6 +19,11 @@ pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; pub const MAX_FILE_COUNT: usize = 10_000; +/// 单条目压缩比上限:**声明解压大小 / 该条目自身的压缩字节** 的倍数。 +/// +/// 分母是该条目自己的压缩字节,不是整包体积:整包分母下,一个 10 MiB 的包单条可以声明 +/// 1 GiB 而不触发,这条检查几乎不生效。整包维度的主约束由 `MAX_PACKAGE_BYTES` / +/// `MAX_FILE_BYTES` / `MAX_EXPANDED_BYTES` 承担。 pub const MAX_COMPRESSION_RATIO: u64 = 100; #[derive(Clone, Debug, PartialEq, Eq)] @@ -74,7 +79,7 @@ pub fn validate_release_zip(bytes: &[u8]) -> Result Result MAX_EXPANDED_BYTES { return Err(ReleasePackageError::ExpandedPackageTooLarge); } - if declared_size > package_bytes.saturating_mul(MAX_COMPRESSION_RATIO) { + if declared_size > file.compressed_size().saturating_mul(MAX_COMPRESSION_RATIO) { return Err(ReleasePackageError::CompressionRatioTooHigh); } - let mut content = Vec::with_capacity(usize::try_from(declared_size).unwrap_or(0)); - file.read_to_end(&mut content) + // 读取层按**声明大小**封顶:`take(declared + 1)` 保证这条条目最多产出 + // `declared + 1` 字节——读满声明值再多 1 字节即判「声明说谎(少报展开大小)」, + // 短读(实际不足声明值)同样失败。预分配收紧到单文件硬上限。 + // + // 不变式:单条实际解压内存 ≤ 其声明大小 + 1(声明大小已被 `MAX_FILE_BYTES` 与 + // `MAX_EXPANDED_BYTES` 夹住),因此整包实际内存被两道上限约束,不会出现 + // 「声明 1 KiB、实际解压数 GiB」的内存放大。 + let mut reader = file.take(declared_size.saturating_add(1)); + let mut content = Vec::with_capacity( + usize::try_from(declared_size.min(MAX_FILE_BYTES)).unwrap_or(usize::MAX), + ); + reader + .read_to_end(&mut content) .map_err(|_| ReleasePackageError::ReadFailed)?; if u64::try_from(content.len()).unwrap_or(u64::MAX) != declared_size { return Err(ReleasePackageError::ReadFailed); @@ -297,4 +313,54 @@ mod tests { assert_eq!(manifest.package_bytes, bytes.len() as u64); assert_eq!(manifest.files.len(), 3); } + + /// deflate 零内容条目,用于低成本构造规模类用例。 + fn zeros_archive(path: &str, size: u64) -> Vec { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + writer + .start_file(path, SimpleFileOptions::default()) + .expect("zip entry"); + let chunk = vec![0_u8; 1024 * 1024]; + let mut remaining = size; + while remaining > 0 { + let take = usize::try_from(remaining.min(chunk.len() as u64)).unwrap_or(chunk.len()); + writer.write_all(&chunk[..take]).expect("zip content"); + remaining -= take as u64; + } + writer.finish().expect("finish zip"); + output.into_inner() + } + + /// 把单条目 zip 声明的解压大小改成 `declared_size`,但不动实际 deflate 数据: + /// 构造「声明说谎」的发行包(本地文件头与中央目录项一起改)。 + fn declared_size_lie_archive(path: &str, actual_size: u64, declared_size: u32) -> Vec { + let mut bytes = zeros_archive(path, actual_size); + assert_eq!(&bytes[0..4], b"PK\x03\x04", "local file header"); + // 本地文件头:… crc(14) + compressed(18) → 解压大小在偏移 22。 + bytes[22..26].copy_from_slice(&declared_size.to_le_bytes()); + let central = bytes + .windows(4) + .position(|window| window == b"PK\x01\x02") + .expect("central directory header"); + // 中央目录项:… crc(16) + compressed(20) → 解压大小在偏移 24。 + bytes[central + 24..central + 28].copy_from_slice(&declared_size.to_le_bytes()); + bytes + } + + /// P0-b(发行包路径的回归证据):声明说谎必须在读取层失败关闭,不能先把 deflate + /// 流整段解进内存再比对长度。 + #[test] + fn rejects_entries_whose_declared_size_is_a_lie() { + let under_report = declared_size_lie_archive("assets/liar.bin", 8 * 1024 * 1024, 1024); + assert_eq!( + validate_release_zip(&under_report), + Err(ReleasePackageError::ReadFailed) + ); + let over_report = declared_size_lie_archive("assets/liar.bin", 4096, 1500); + assert_eq!( + validate_release_zip(&over_report), + Err(ReleasePackageError::ReadFailed) + ); + } } diff --git a/server-rs/crates/module-game-distribution/src/project_bundle.rs b/server-rs/crates/module-game-distribution/src/project_bundle.rs index 435ca4c9c..decf5633d 100644 --- a/server-rs/crates/module-game-distribution/src/project_bundle.rs +++ b/server-rs/crates/module-game-distribution/src/project_bundle.rs @@ -19,10 +19,38 @@ pub const MAX_PROJECT_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; pub const MAX_PROJECT_FILE_BYTES: u64 = 64 * 1024 * 1024; /// 条目数上限与发行包一致;10 000 个文件对任何手写工程都绰绰有余。 pub const MAX_PROJECT_FILE_COUNT: usize = 10_000; -/// 单文件声明大小相对整包体积的倍数上限,用于拦截 zip 炸弹(一个几 KB 的包声明 -/// 解压出几 GB):与发行包一致,正常源码的压缩比远低于 100 倍。 +/// 单条目压缩比上限:**声明解压大小 / 该条目自身的压缩字节** 的倍数(与发行包同口径)。 +/// +/// 分母必须是该条目自己的压缩字节,不能用整包体积:整包分母下,一个 10 MiB 的包 +/// 单条可以声明 1 GiB 而不触发,这条检查几乎不生效。整包维度的主约束由 +/// `MAX_PROJECT_BUNDLE_BYTES` / `MAX_PROJECT_FILE_BYTES` / `MAX_PROJECT_EXPANDED_BYTES` +/// 承担,本条只负责拦截同一体积内的异常压缩比。正常源码的压缩比远低于 100 倍。 pub const MAX_PROJECT_COMPRESSION_RATIO: u64 = 100; +/// 嵌套压缩包头部嗅探读取的字节数:条目改名(`deps.dat` / `deps.bin`)后靠 magic bytes +/// 兜住,`nested_archive_format` 在这个窗口内判格式。 +const NESTED_ARCHIVE_SNIFF_BYTES: usize = 512; + +/// 内容嗅探的文本白名单扩展名(不含点、小写比较):只对文本型条目做凭据特征扫描。 +/// +/// 刻意不做更宽的前缀启发(例如把 `sk-` 也当密钥):误报会直接阻断作者发布, +/// 而 `sk-` 同时也是无害字符串的常见前缀,收益与代价不成比例。宁可漏报未知命名, +/// 也不让作者为一个正常文件反复试错。 +const SECRET_CONTENT_TEXT_EXTENSIONS: &[&str] = &[ + "txt", "md", "json", "yml", "yaml", "toml", "ini", "cfg", "conf", "js", "ts", "tsx", "jsx", + "py", "rs", "go", "java", "sh", "ps1", "bat", "env", +]; +/// 内容嗅探的条目大小上限:更大的条目大概率不是配置文件,扫描收益低、误报代价高。 +const SECRET_CONTENT_MAX_SNIFF_BYTES: u64 = 256 * 1024; +/// 简单子串特征:命中任一即判定疑似凭据(GitHub / Slack token)。 +const SECRET_CONTENT_SIGNATURES: &[&str] = &["ghp_", "github_pat_", "xoxa-", "xoxb-", "xoxp-"]; +/// AWS Access Key ID 的固定前缀:后面必须紧跟 16 位大写字母 / 数字才算命中。 +const SECRET_AWS_ACCESS_KEY_PREFIX: &str = "AKIA"; +/// PEM 私钥块的行首标记:必须与 `PEM_PRIVATE_KEY_MARKER` 出现在同一行才判定。 +const PEM_PRIVATE_KEY_LINE_PREFIX: &str = "-----BEGIN "; +/// PEM 私钥块的类型标记。 +const PEM_PRIVATE_KEY_MARKER: &str = "PRIVATE KEY-----"; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectBundleFileManifest { pub path: String, @@ -50,9 +78,11 @@ pub enum ProjectBundleError { DependencyDirectoryNotAllowed, VersionControlDirectoryNotAllowed, LocalStateDirectoryNotAllowed, + CredentialDirectoryNotAllowed, BuildArtifactNotAllowed, IdeDirectoryNotAllowed, SensitiveFileNotAllowed, + SecretContentDetected, FileTooLarge, ExpandedBundleTooLarge, CompressionRatioTooHigh, @@ -89,7 +119,7 @@ pub fn validate_project_bundle_zip( let mut files = Vec::with_capacity(archive.len()); let mut expanded_bytes = 0_u64; for index in 0..archive.len() { - let (path, declared_size, is_dir) = { + let (path, declared_size, compressed_size, is_dir) = { // `by_index` 对加密条目直接报「需要口令」,拿不到元数据;这里先用 // `by_index_raw` 只读头部判掉加密与符号链接,再决定是否解压取内容。 let file = archive @@ -106,7 +136,7 @@ pub fn validate_project_bundle_zip( .ok_or(ProjectBundleError::InvalidPath)?; let path = normalize_archive_path(&enclosed).map_err(|_| ProjectBundleError::InvalidPath)?; - (path, file.size(), file.is_dir()) + (path, file.size(), file.compressed_size(), file.is_dir()) }; // 完全重复或大小写折叠后重复的条目:解包结果取决于解压器的遍历顺序, // 在大小写不敏感的文件系统(Windows / macOS)上还会互相覆盖。 @@ -124,19 +154,42 @@ pub fn validate_project_bundle_zip( if expanded_bytes > MAX_PROJECT_EXPANDED_BYTES { return Err(ProjectBundleError::ExpandedBundleTooLarge); } - if declared_size > bundle_bytes.saturating_mul(MAX_PROJECT_COMPRESSION_RATIO) { + if declared_size > compressed_size.saturating_mul(MAX_PROJECT_COMPRESSION_RATIO) { return Err(ProjectBundleError::CompressionRatioTooHigh); } - let mut content = Vec::with_capacity(usize::try_from(declared_size).unwrap_or(0)); - archive + // 读取层按**声明大小**封顶:`take(declared + 1)` 保证这条条目最多产出 + // `declared + 1` 字节——读满声明值再多 1 字节即判「声明说谎(少报展开大小)」, + // 短读(实际不足声明值)同样失败。预分配也收紧到单文件硬上限。 + // + // 不变式:单条实际解压内存 ≤ 其声明大小 + 1(声明大小已被 `MAX_PROJECT_FILE_BYTES` + // 与 `MAX_PROJECT_EXPANDED_BYTES` 两道上限夹住),因此整包实际内存被两道上限约束, + // 不会出现「声明 1 KiB、实际解压数 GiB」的内存放大。 + let mut reader = archive .by_index(index) .map_err(|_| ProjectBundleError::InvalidArchive)? + .take(declared_size.saturating_add(1)); + let mut content = Vec::with_capacity( + usize::try_from(declared_size.min(MAX_PROJECT_FILE_BYTES)).unwrap_or(usize::MAX), + ); + reader .read_to_end(&mut content) .map_err(|_| ProjectBundleError::ReadFailed)?; if u64::try_from(content.len()).unwrap_or(u64::MAX) != declared_size { return Err(ProjectBundleError::ReadFailed); } + // 嵌套压缩包:扩展名并集已在上面的拒绝清单里拦过一轮,这里按**头部字节**兜住 + // 改名(`deps.dat` / `deps.bin` 里装的是 zip / 7z / rar / gzip / tar 一样要拒)。 + // 判的是**解压后**的内容(`by_index_raw` 返回的是压缩流,不能用来嗅探格式)。 + if declared_size > 0 && nested_archive_format(&content).is_some() { + return Err(ProjectBundleError::NestedArchiveNotAllowed); + } + // 凭据内容嗅探:只扫「文本型且不大」的条目,命中已知凭据特征即拒。 + if is_secret_content_candidate(&path, declared_size) + && secret_content_signature(&content).is_some() + { + return Err(ProjectBundleError::SecretContentDetected); + } files.push(ProjectBundleFileManifest { path, size_bytes: declared_size, @@ -159,6 +212,7 @@ fn reject_forbidden_path(path: &str) -> Result<(), ProjectBundleError> { let mut has_dependency_dir = false; let mut has_version_control_dir = false; let mut has_local_state_dir = false; + let mut has_credential_dir = false; for part in path.split('/') { if is_first { root = part; @@ -168,6 +222,13 @@ fn reject_forbidden_path(path: &str) -> Result<(), ProjectBundleError> { has_version_control_dir |= part.eq_ignore_ascii_case(".git") || part.eq_ignore_ascii_case(".svn"); has_local_state_dir |= part.eq_ignore_ascii_case(".agent"); + has_credential_dir |= part.eq_ignore_ascii_case(".aws") + || part.eq_ignore_ascii_case(".ssh") + || part.eq_ignore_ascii_case(".kube") + || part.eq_ignore_ascii_case(".docker") + || part.eq_ignore_ascii_case(".gnupg") + || part.eq_ignore_ascii_case(".terraform") + || part.eq_ignore_ascii_case(".secrets"); } // 依赖目录出现在任意层级都拒绝:它是安装产物,体积大且可由 package.json 还原, @@ -184,6 +245,12 @@ fn reject_forbidden_path(path: &str) -> Result<(), ProjectBundleError> { if has_local_state_dir { return Err(ProjectBundleError::LocalStateDirectoryNotAllowed); } + // 凭据目录(任意层级):家庭目录下的 `.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` + // / `.terraform` / `.secrets` 装的是长期凭据、集群配置、注册表凭证、私钥环与 + // Terraform 状态(可能含明文 secret),随包外发即不可撤销的泄漏。 + if has_credential_dir { + return Err(ProjectBundleError::CredentialDirectoryNotAllowed); + } // 构建产物与 IDE 只拦**根级**:根 dist / build / library / temp / local 是 // 构建输出(与模板包同规则),子目录里的 dist/ 是工程自己的目录布局,必须允许。 @@ -208,8 +275,20 @@ fn reject_forbidden_path(path: &str) -> Result<(), ProjectBundleError> { if is_sensitive_file_name(&file_name) { return Err(ProjectBundleError::SensitiveFileNotAllowed); } - // 嵌套压缩包:解包阶段不会递归校验包里的包,等于绕过整份拒绝清单。 - if file_name.ends_with(".zip") { + // 嵌套压缩包(扩展名并集):解包阶段不会递归校验包里的包,等于绕过整份拒绝清单。 + // `.tar.gz` 由 `.gz` 覆盖;改名成 `.dat` / `.bin` 之类的由头部字节嗅探兜住 + // (见 `nested_archive_format`)。这里刻意用逐条 `ends_with("…")`:一致性门禁 + // (`scripts/check-project-bundle-policy-parity.mjs`)就是按这个调用形状抓 token 的。 + if file_name.ends_with(".zip") + || file_name.ends_with(".tar") + || file_name.ends_with(".gz") + || file_name.ends_with(".tgz") + || file_name.ends_with(".7z") + || file_name.ends_with(".rar") + || file_name.ends_with(".jar") + || file_name.ends_with(".whl") + || file_name.ends_with(".nupkg") + { return Err(ProjectBundleError::NestedArchiveNotAllowed); } Ok(()) @@ -217,18 +296,125 @@ fn reject_forbidden_path(path: &str) -> Result<(), ProjectBundleError> { fn is_sensitive_file_name(name: &str) -> bool { name.starts_with(".env") // .env / .env.local / .env.production … + || name.starts_with("credentials") // ~/.aws/credentials、credentials.json … + || name.starts_with("id_rsa") + || name.starts_with("id_ecdsa") + || name.starts_with("id_dsa") + || name.starts_with("id_ed25519") + || name.starts_with("terraform.tfstate") // terraform.tfstate / .tfstate.backup + || name.starts_with("service-account") // service-account.json … || name.ends_with(".pem") || name.ends_with(".key") || name.ends_with(".p12") || name.ends_with(".pfx") + || name.ends_with(".jks") + || name.ends_with(".keystore") + || name.ends_with(".ppk") + || name.ends_with(".p8") + || name.ends_with(".kdbx") + || name.ends_with(".der") + || name == ".htpasswd" + || name == ".pgpass" || name == ".npmrc" || name == ".netrc" || name == ".git-credentials" - || name.starts_with("id_rsa") - || name.starts_with("id_ed25519") || name.ends_with(".map") // 源码映射会暴露原始源码与路径 } +/// 嵌套压缩包头部嗅探:从条目内容的头 512 字节判已知归档格式,返回格式名。 +/// +/// 只在按扩展名拦不住时才有意义(条目被改名成 `deps.dat` 之类),所以判的是**内容** +/// 而不是文件名。`PK\x03\x04` 是 zip 的本地文件头;7z / rar 用各自 magic; +/// gzip 是 `\x1f\x8b`;tar 在偏移 257 处写 `ustar`。 +fn nested_archive_format(content: &[u8]) -> Option<&'static str> { + let head = &content[..content.len().min(NESTED_ARCHIVE_SNIFF_BYTES)]; + if head.starts_with(b"PK\x03\x04") { + return Some("zip"); + } + if head.starts_with(b"7z\xBC\xAF\x27\x1C") { + return Some("7z"); + } + if head.starts_with(b"Rar!\x1A\x07") { + return Some("rar"); + } + if head.starts_with(b"\x1F\x8B") { + return Some("gzip"); + } + if head.len() >= 262 && &head[257..262] == b"ustar" { + return Some("tar"); + } + None +} + +/// 是否值得做凭据内容嗅探:非空、声明大小 ≤ 256 KiB,且扩展名属于文本白名单 +/// (**无扩展名也算文本**——`credentials`、`config` 这类凭据文件常常没有扩展名)。 +fn is_secret_content_candidate(path: &str, declared_size: u64) -> bool { + if declared_size == 0 || declared_size > SECRET_CONTENT_MAX_SNIFF_BYTES { + return false; + } + let name = path.rsplit('/').next().unwrap_or(path); + let extension = match name.rfind('.') { + Some(index) if index > 0 && index + 1 < name.len() => &name[index + 1..], + _ => return true, + }; + SECRET_CONTENT_TEXT_EXTENSIONS + .iter() + .any(|allowed| extension.eq_ignore_ascii_case(allowed)) +} + +/// 凭据内容嗅探:命中任一已知特征就返回该特征的说明。 +/// +/// 只做**低误报**的强特征;刻意不把 `sk-` 之类的前缀算进来(见 `SECRET_CONTENT_TEXT_EXTENSIONS` +/// 上的口径说明)。返回说明串是为了让客户端能点名命中了哪条特征。 +fn secret_content_signature(content: &[u8]) -> Option<&'static str> { + if pem_private_key_detected(content) { + return Some("PEM 私钥块"); + } + if aws_access_key_id_detected(content) { + return Some("AWS Access Key ID"); + } + if SECRET_CONTENT_SIGNATURES + .iter() + .any(|signature| contains_bytes(content, signature.as_bytes())) + { + return Some("GitHub / Slack token"); + } + None +} + +/// PEM 私钥块:行首标记与私钥类型标记出现在**同一行**(证书块不会带 `PRIVATE KEY-----`)。 +fn pem_private_key_detected(content: &[u8]) -> bool { + content.split(|byte| *byte == b'\n').any(|line| { + contains_bytes(line, PEM_PRIVATE_KEY_LINE_PREFIX.as_bytes()) + && contains_bytes(line, PEM_PRIVATE_KEY_MARKER.as_bytes()) + }) +} + +/// AWS Access Key ID:`AKIA` 后紧跟 16 位大写字母或数字。 +fn aws_access_key_id_detected(content: &[u8]) -> bool { + let prefix = SECRET_AWS_ACCESS_KEY_PREFIX.as_bytes(); + content + .windows(prefix.len()) + .enumerate() + .any(|(index, window)| { + window == prefix + && content + .get(index + prefix.len()..index + prefix.len() + 16) + .is_some_and(|tail| { + tail.iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + }) + }) +} + +/// `haystack` 是否包含 `needle`(不引入正则依赖的等价实现)。 +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + #[cfg(test)] mod tests { use std::io::Write; @@ -487,14 +673,58 @@ mod tests { } #[test] - fn rejects_nested_archives() { + fn rejects_nested_archives_by_extension_union_and_magic_bytes() { + // 扩展名并集:`.tar.gz` 由 `.gz` 覆盖。 + for path in [ + "artist/vendor.zip", + "artist/vendor.tar", + "artist/vendor.gz", + "artist/vendor.tgz", + "artist/vendor.7z", + "artist/vendor.rar", + "artist/vendor.jar", + "artist/vendor.whl", + "artist/vendor.nupkg", + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::NestedArchiveNotAllowed), + "path {path:?} must be rejected" + ); + } + + // 改名后靠头部字节命中:内层内容是真正的归档格式,扩展名却看不出来。 + let inner_zip = archive(&[("inner.txt", b"x")]); assert_eq!( validate_project_bundle_zip(&archive(&[ ("package.json", b"{}"), - ("src/vendor.zip", b"PK\x03\x04") + ("src/deps.dat", &inner_zip) ])), Err(ProjectBundleError::NestedArchiveNotAllowed) ); + let mut tar = vec![0_u8; 512]; + tar[257..262].copy_from_slice(b"ustar"); + for (name, content) in [ + ("src/deps.bin", b"\x1F\x8B\x08\x00payload".as_slice()), + ("src/deps.bin", b"7z\xBC\xAF\x27\x1Cpayload".as_slice()), + ("src/deps.bin", b"Rar!\x1A\x07\x00payload".as_slice()), + ("src/deps.bin", tar.as_slice()), + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (name, content)])), + Err(ProjectBundleError::NestedArchiveNotAllowed), + "magic bytes for {name:?} must be rejected" + ); + } + + // 正常文件不误报:png 头、文本、空文件都要放行。 + let ok = archive(&[ + ("package.json", b"{}"), + ("src/main.ts", b"export {};"), + ("assets/logo.png", b"\x89PNG\r\n\x1a\nrest"), + ("assets/empty.bin", b""), + ]); + assert!(validate_project_bundle_zip(&ok).is_ok()); } #[test] @@ -577,15 +807,140 @@ mod tests { "deploy/id_rsa", "deploy/id_ed25519.pub", "public/app.js.map", + // P0-a:更多 SSH / 云凭据文件名(不落在凭据目录里,走的是文件名判定)。 + "deploy/id_ecdsa", + "deploy/id_dsa", + "deploy/credentials", + "deploy/credentials.json", + "ci/service-account.json", + "infra/terraform.tfstate", + "infra/terraform.tfstate.backup", + "certs/truststore.jks", + "certs/app.keystore", + "deploy/key.ppk", + "apple/AuthKey.p8", + "db/passwords.kdbx", + "certs/ca.der", + ".htpasswd", + ".pgpass", ] { assert_eq!( validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), Err(ProjectBundleError::SensitiveFileNotAllowed), - "path {path:?}" + "path {path:?} must be rejected" ); } } + /// P0-a:凭据目录出现在任意层级都拒(家庭目录里那些装长期凭据的目录)。 + #[test] + fn rejects_credential_directories_at_any_level() { + for path in [ + ".aws/config", + ".ssh/known_hosts", + ".kube/config", + ".docker/config.json", + ".gnupg/pubring.kbx", + ".terraform/plugins.json", + ".secrets/vault.json", + // 任意层级 + 大小写折叠。 + "game/nested/.AWS/config", + ".SSH/known_hosts", + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::CredentialDirectoryNotAllowed), + "path {path:?} must be rejected" + ); + } + // 反例:名字只是前缀的普通目录 / 文件必须放行。 + let ok = archive(&[ + ("package.json", b"{}"), + ("src/.sshrc", b"x"), + ("docker/Dockerfile", b"FROM scratch"), + ]); + assert!(validate_project_bundle_zip(&ok).is_ok()); + } + + /// P0-a:文本型小文件里的已知凭据特征必须拒;同时验证误报防线 + /// (证书块、长度不足的 `AKIA`、二进制扩展名都不拦)。 + #[test] + fn rejects_secret_content_only_in_small_text_files() { + let private_key = b"-----BEGIN RSA PRIVATE KEY-----\nMIIB\n"; + let aws = b"aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n"; + let github = b"token: ghp_0123456789abcdefghijklmnopqrstuvwx\n"; + let slack = b"SLACK_TOKEN=xoxb-123456789012-abcdefghijklmnop\n"; + for (path, content) in [ + ("deploy/seed.txt", private_key.as_slice()), + ("config/aws.json", aws.as_slice()), + ("ci/upload.yml", github.as_slice()), + ("src/notify.ts", slack.as_slice()), + // 无扩展名的凭据文件(`~/.aws/credentials` 之外的手工命名)也要扫。 + ("deploy/production-secret", aws.as_slice()), + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, content)])), + Err(ProjectBundleError::SecretContentDetected), + "path {path:?} must be rejected" + ); + } + + // 误报防线:证书块不是私钥;`AKIA` 后不足 16 位;二进制扩展名不做内容扫描 + // (宁可漏报也不阻断作者发布,见 `SECRET_CONTENT_TEXT_EXTENSIONS` 的口径)。 + let ok = archive(&[ + ("package.json", b"{}"), + ("docs/cert.txt", b"-----BEGIN CERTIFICATE-----\nMIIB\n"), + ( + "src/notes.md", + b"AKIA-SHORT\nBEGIN without private key marker\n", + ), + ("assets/logo.png", aws.as_slice()), + ]); + assert!(validate_project_bundle_zip(&ok).is_ok()); + } + + /// 每条目 = 1 MiB 不可压缩压舱石 + 其余零内容:deflate 后压缩字节约 1 MiB, + /// 保证「声明大小 ≤ 该条目压缩字节 × 100」这条**按条目**判定时不会先触发 + /// (压缩比分母改成单条目压缩字节后,纯零内容的条目会先撞压缩比)。 + fn ballasted_zeros_archive(entries: &[(&str, u64)]) -> Vec { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + let zeros = vec![0_u8; 1024 * 1024]; + for (path, size) in entries { + writer + .start_file(*path, SimpleFileOptions::default()) + .expect("zip entry"); + let ballast = incompressible_bytes(1024 * 1024); + writer.write_all(&ballast).expect("zip content"); + let mut remaining = size - ballast.len() as u64; + while remaining > 0 { + let take = + usize::try_from(remaining.min(zeros.len() as u64)).unwrap_or(zeros.len()); + writer.write_all(&zeros[..take]).expect("zip content"); + remaining -= take as u64; + } + } + writer.finish().expect("finish zip"); + output.into_inner() + } + + /// 手工把单条目 zip 声明的解压大小改成 `declared_size`,但不动实际 deflate 数据: + /// 构造「声明说谎」的包(本地文件头与中央目录项一起改,模拟攻击者完全控制文件)。 + fn declared_size_lie_archive(path: &str, actual_size: u64, declared_size: u32) -> Vec { + let mut bytes = zeros_archive(&[(path, actual_size)]); + assert_eq!(&bytes[0..4], b"PK\x03\x04", "local file header"); + // 本地文件头:magic(4) + version(2) + flags(2) + method(2) + time(2) + date(2) + // + crc(4) + compressed(4) → 解压大小在偏移 22。 + bytes[22..26].copy_from_slice(&declared_size.to_le_bytes()); + let central = bytes + .windows(4) + .position(|window| window == b"PK\x01\x02") + .expect("central directory header"); + // 中央目录项:crc(16) + compressed(20) → 解压大小在偏移 24。 + bytes[central + 24..central + 28].copy_from_slice(&declared_size.to_le_bytes()); + bytes + } + #[test] fn rejects_oversized_single_file() { // 65 MiB 零内容 deflate 后包体很小,但中央目录声明了真实解压大小。 @@ -599,41 +954,27 @@ mod tests { #[test] fn rejects_excessive_expanded_size() { // 每文件声明 63 MiB(未越单文件上限),累计第 8 个越过 500 MiB 展开上限。 - // 零内容 deflate 后包体只有几百 KB,需要一块不可压缩的压舱石把包体抬到 - // 声明大小 / 100 之上,否则会先撞上压缩比检查而不是展开量检查。 - let ballast = incompressible_bytes(1024 * 1024); - let mut output = Cursor::new(Vec::new()); - let mut writer = ZipWriter::new(&mut output); - writer - .start_file( - "assets/ballast.bin", - SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), - ) - .expect("zip entry"); - writer.write_all(&ballast).expect("zip content"); - let chunk = vec![0_u8; 1024 * 1024]; - for index in 0..8 { - writer - .start_file(format!("src/blob_{index}.ts"), SimpleFileOptions::default()) - .expect("zip entry"); - let mut remaining = 63 * 1024 * 1024_u64; - while remaining > 0 { - let take = - usize::try_from(remaining.min(chunk.len() as u64)).unwrap_or(chunk.len()); - writer.write_all(&chunk[..take]).expect("zip content"); - remaining -= take as u64; - } - } - writer.finish().expect("finish zip"); + // 每条目自带压舱石,保证不会先撞上「按条目压缩字节」判定的压缩比检查。 + let bytes = ballasted_zeros_archive(&[ + ("src/blob_0.ts", 63 * 1024 * 1024), + ("src/blob_1.ts", 63 * 1024 * 1024), + ("src/blob_2.ts", 63 * 1024 * 1024), + ("src/blob_3.ts", 63 * 1024 * 1024), + ("src/blob_4.ts", 63 * 1024 * 1024), + ("src/blob_5.ts", 63 * 1024 * 1024), + ("src/blob_6.ts", 63 * 1024 * 1024), + ("src/blob_7.ts", 63 * 1024 * 1024), + ]); assert_eq!( - validate_project_bundle_zip(&output.into_inner()), + validate_project_bundle_zip(&bytes), Err(ProjectBundleError::ExpandedBundleTooLarge) ); } #[test] fn rejects_excessive_compression_ratio() { - // 1 MiB 零内容 deflate 成 1 KB 上下,声明大小远超包体的 100 倍。 + // 分母是**该条目的压缩字节**:1 MiB 零内容 deflate 成 1 KB 上下,声明大小远超 + // 自身压缩字节的 100 倍(整包分母下这条几乎不生效,见常量注释)。 let bytes = zeros_archive(&[("src/bomb.bin", 1024 * 1024)]); assert_eq!( validate_project_bundle_zip(&bytes), @@ -641,6 +982,26 @@ mod tests { ); } + /// P0-b:声明说谎(少报展开大小)必须在**读取这一层**失败关闭,而不是把 deflate + /// 流整段解进内存之后才比对长度——那正是服务端内存放大的来源。 + #[test] + fn rejects_entries_whose_declared_size_is_a_lie() { + // 实际 8 MiB 的零内容 deflate 成几 KB,中央目录却声明 1 KiB: + // 旧写法会先把 8 MiB 解出来(Vec 反复扩容)再比对;新写法 `take(declare + 1)` + // 读到 1025 字节立刻判「多读 1 字节即溢出」。 + let under_report = declared_size_lie_archive("src/liar.dat", 8 * 1024 * 1024, 1024); + assert_eq!( + validate_project_bundle_zip(&under_report), + Err(ProjectBundleError::ReadFailed) + ); + // 反向(多报):实际 4 KiB、声明 1500 字节,读取在声明值处截断后长度不符,同样失败。 + let over_report = declared_size_lie_archive("src/liar.dat", 4096, 1500); + assert_eq!( + validate_project_bundle_zip(&over_report), + Err(ProjectBundleError::ReadFailed) + ); + } + /// 数据区损坏(中央目录与声明大小都正常,只有 deflate 流本身是垃圾)时必须在 /// 解压阶段报 `ReadFailed`,而不是 panic 或静默产出错误清单。 #[test]