Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 70 additions & 24 deletions crates/codeguard-cli/src/git_index_safety.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! 真实 Git index 路径的有界只读观察;结果不认证完整质量门禁。

use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -44,10 +44,17 @@ pub struct IndexSafetyObservation {
pub objects_verified: bool,
pub object_evidence: Vec<IndexObjectEvidence>,
pub unresolved_object_paths: Vec<String>,
/// 对象读取或身份核验失败原因;路径违规仍保留。
/// 对象预算、读取或身份核验未完成原因;已核对的路径及内容发现仍保留。
pub object_verification_reason: Option<String>,
}

struct VerifiedIndexObjects {
evidence: Vec<IndexObjectEvidence>,
unresolved_paths: Vec<String>,
content_violations: Vec<RepositoryPathViolation>,
incomplete_reason: Option<String>,
}

/// 独立计算 Git blob 对象身份:`blob <字节长度>\0<内容>`。
#[must_use]
pub fn verify_git_blob_oid(content: &[u8], object_format: &str, expected_oid: &str) -> bool {
Expand Down Expand Up @@ -200,9 +207,13 @@ pub(crate) fn observe_index_safety_with_deadline(
}
let (object_evidence, unresolved_object_paths, object_verification_reason) = match object_result
{
Ok((evidence, unresolved, content_violations)) => {
violations.extend(content_violations);
(evidence, unresolved, None)
Ok(result) => {
violations.extend(result.content_violations);
(
result.evidence,
result.unresolved_paths,
result.incomplete_reason,
)
}
Err(reason) => (Vec::new(), paths.clone(), Some(reason)),
};
Expand All @@ -228,14 +239,7 @@ fn verify_index_objects(
entries: &[GitIndexEntry],
format: &str,
deadline: Instant,
) -> Result<
(
Vec<IndexObjectEvidence>,
Vec<String>,
Vec<RepositoryPathViolation>,
),
String,
> {
) -> Result<VerifiedIndexObjects, String> {
let mut requested = BTreeMap::<&str, usize>::new();
let mut unresolved = Vec::new();
for entry in entries {
Expand All @@ -246,7 +250,12 @@ fn verify_index_objects(
}
}
if requested.is_empty() {
return Ok((Vec::new(), unresolved, Vec::new()));
return Ok(VerifiedIndexObjects {
evidence: Vec::new(),
unresolved_paths: unresolved,
content_violations: Vec::new(),
incomplete_reason: None,
});
}
let oid_request = requested
.keys()
Expand All @@ -268,6 +277,8 @@ fn verify_index_objects(
return Err("Git 对象大小响应数量或结束符错误".into());
}
let mut total = 0_usize;
let mut skipped_oids = BTreeSet::new();
let mut budget_reason = None;
for ((oid, size), line) in requested.iter_mut().zip(lines) {
let line = std::str::from_utf8(line).map_err(|_| "Git 对象大小响应非 UTF-8")?;
let expected_prefix = format!("{oid} blob ");
Expand All @@ -277,37 +288,61 @@ fn verify_index_objects(
.parse::<usize>()
.map_err(|_| "Git 对象长度无效")?;
if count > 8 * 1024 * 1024 {
return Err("Git 单个对象超出本次验证预算".into());
skipped_oids.insert(*oid);
budget_reason.get_or_insert_with(|| "Git 单个对象超出本次验证预算".to_owned());
continue;
}
total = total.checked_add(count).ok_or("Git 对象总长度溢出")?;
if total > 128 * 1024 * 1024 {
return Err("Git 对象总长度超出本次验证预算".into());
let Some(next_total) = total.checked_add(count) else {
skipped_oids.insert(*oid);
budget_reason.get_or_insert_with(|| "Git 对象总长度溢出".to_owned());
continue;
};
if next_total > 128 * 1024 * 1024 {
skipped_oids.insert(*oid);
budget_reason.get_or_insert_with(|| "Git 对象总长度超出本次验证预算".to_owned());
continue;
}
total = next_total;
*size = count;
}
let mut verified = BTreeMap::<String, (usize, String, bool, bool)>::new();
let mut batch = Vec::<(&str, usize)>::new();
let mut batch_size = 0_usize;
let mut read_failure = None;
for (oid, size) in requested {
if skipped_oids.contains(oid) {
continue;
}
if !batch.is_empty() && (batch.len() >= 64 || batch_size + size > 8 * 1024 * 1024) {
read_blob_batch(root, tool, index, &batch, format, deadline, &mut verified)?;
if let Err(reason) =
read_blob_batch(root, tool, index, &batch, format, deadline, &mut verified)
{
read_failure = Some(reason);
break;
}
batch.clear();
batch_size = 0;
}
batch.push((oid, size));
batch_size += size;
}
if !batch.is_empty() {
read_blob_batch(root, tool, index, &batch, format, deadline, &mut verified)?;
if read_failure.is_none() && !batch.is_empty() {
if let Err(reason) =
read_blob_batch(root, tool, index, &batch, format, deadline, &mut verified)
{
read_failure = Some(reason);
}
}
let mut evidence = Vec::new();
let mut content_violations = Vec::new();
for entry in entries {
if entry.mode == "160000" {
continue;
}
let (size, digest, lfs_pointer, private_key) =
verified.get(&entry.oid).ok_or("Git 对象验证结果缺失")?;
let Some((size, digest, lfs_pointer, private_key)) = verified.get(&entry.oid) else {
unresolved.push(entry.path.clone());
continue;
};
let kind = if entry.mode == "120000" {
"symlink_unresolved"
} else if *lfs_pointer {
Expand All @@ -334,7 +369,18 @@ fn verify_index_objects(
});
}
unresolved.sort();
Ok((evidence, unresolved, content_violations))
unresolved.dedup();
let reason = match (budget_reason, read_failure) {
(Some(_), Some(_)) => Some("Git 对象预算与读取均未完成".into()),
(Some(reason), None) | (None, Some(reason)) => Some(reason),
(None, None) => None,
};
Ok(VerifiedIndexObjects {
evidence,
unresolved_paths: unresolved,
content_violations,
incomplete_reason: reason,
})
}

fn read_blob_batch(
Expand Down
74 changes: 74 additions & 0 deletions crates/codeguard-cli/tests/git_index_safety_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,80 @@ fn object_size_limit_does_not_hide_a_staged_path_violation() {
assert!(observed.object_verification_reason.is_some());
}

#[test]
fn oversized_staged_blob_does_not_erase_a_verified_private_key_finding() {
let repo = Repo::new();
fs::create_dir_all(repo.0.join(".codeguard/findings")).unwrap();
fs::write(
repo.0.join(".codeguard/findings/CG-demo.md"),
synthetic_ed25519_pem(),
)
.unwrap();
fs::write(repo.0.join("large.bin"), vec![b'x'; 8 * 1024 * 1024 + 1]).unwrap();
repo.git(&["add", ".codeguard/findings/CG-demo.md", "large.bin"]);
let observed = observe_index_safety(&repo.0, &git_binary(), None).unwrap();
assert!(!observed.objects_verified);
assert_eq!(observed.unresolved_object_paths, ["large.bin"]);
assert_eq!(observed.object_evidence.len(), 1);
assert!(observed.object_verification_reason.is_some());
assert!(observed.violations.iter().any(|item| {
item.path == ".codeguard/findings/CG-demo.md"
&& item.rule_id == "repository_policy.unencrypted_openssh_ed25519_private_key"
}));
let output = Command::new(env!("CARGO_BIN_EXE_codeguard"))
.args([
"gate",
"pre-commit",
repo.0.to_str().unwrap(),
"--format=json",
])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3));
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["object_status"], "failed");
assert_eq!(report["verified_object_count"], 1);
assert_eq!(report["unresolved_object_count"], 1);
assert_eq!(report["delivery_decision"], "not_evaluated");
assert!(report["violations"].as_array().unwrap().iter().any(|item| {
item["rule_id"] == "repository_policy.unencrypted_openssh_ed25519_private_key"
}));
assert!(!String::from_utf8_lossy(&output.stdout).contains("BEGIN OPENSSH"));
}

#[test]
fn later_corrupt_git_batch_keeps_earlier_oid_verified_objects_incomplete() {
use std::os::unix::fs::PermissionsExt;

let repo = Repo::new();
for index in 0..65 {
fs::write(
repo.0.join(format!("file-{index}.txt")),
format!("value {index}\n"),
)
.unwrap();
}
repo.git(&["add", "."]);
let marker = repo.0.join("first-batch-read");
let wrapper = repo.0.join("git-wrapper");
let script = format!(
"#!/bin/sh\nif [ \"$1\" = cat-file ] && [ \"$2\" = --batch ]; then\n if [ -e '{}' ]; then printf 'corrupt\\n'; exit 0; fi\n : > '{}'\nfi\nexec '{}' \"$@\"\n",
marker.display(),
marker.display(),
git_binary().display()
);
fs::write(&wrapper, script).unwrap();
fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o700)).unwrap();

let observed = observe_index_safety(&repo.0, &wrapper, None).unwrap();
assert_eq!(observed.entries.len(), 65);
assert_eq!(observed.object_evidence.len(), 64);
assert_eq!(observed.unresolved_object_paths.len(), 1);
assert!(observed.object_verification_reason.is_some());
assert!(!observed.objects_verified);
assert!(observed.violations.is_empty());
}

#[test]
fn public_gate_preview_reports_staged_violation_without_a_false_allow() {
let repo = Repo::new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Rust 交付普通 allow MUST 同时满足全部适用必需义务完成、覆盖
- **WHEN** 必需规则检出历史文件中的违规
- **THEN** 保留 finding 并按同一策略阻断,基线分类不能改变结果

#### Scenario: One staged blob exceeds the scan budget after another yields a finding
- **WHEN** 同一 Git index 中的普通 blob 已经核对身份并检出私钥,而另一个暂存对象超过读取预算
- **THEN** 保留已核对内容的 finding、路径和规则 ID;超预算对象单独列为 unresolved,整体结论仍为 incomplete,不能清空全部发现或签发交付通过

#### Scenario: Approved false positive is the only blocker
- **WHEN** 全部适用义务完整,唯一原始阻断 finding 有当前输入匹配的可信误报白名单
- **THEN** 报告保留原始 finding 和处置引用,交付决策为 allow_with_exceptions
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/introduce-rust-codeguard-cli/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- [x] 2.2 定义请求/计划/报告/工具锁 JSON schemas 与兼容版本策略,提供完整正反例;验收:未知 major/非法枚举不按 PASS 消费,运行报告区分项目检查器的 `configured/missing/invalid/unknown` 与本次原生结果。证据:相邻 `codeguard-cli/schemas/run-report.schema.json` 的 1.2 协议、`crates/codeguard-cli/src/run_report.rs` 与 `tests/run_report_contract.rs` 的正反例;1.0 历史报告仍可读取,1.1 起区分配置和运行,1.2 起 finding 要求定位且检查器状态按构建根区分。此项只完成协议,不代表项目配置探测或宿主对话接线已完成。
公共协议输入加固:Rust 检查请求和 RunReport 现从原始字节递归拒绝重复 JSON 键,并复用 16 MiB 输入上限;`delivery_gate`/`decision` 和嵌套 `jobs` 重复键反例先失败后通过。此校验只保证结构消费,不验证报告来源或正式门禁。
- [ ] 2.3 实现 findings 与 completion 双维聚合及新退出码;验收:混合违规/缺工具返回 3 并保留全部发现,取消/内部异常优先级正确。
Git 暂存增量:`gate pre-commit` 的一个对象超出 8 MiB 预算时,已核对普通 blob 的私钥 finding 不再被整体丢弃;公开报告同时保留 1 项发现、1 项 unresolved,仍固定退出 3/交付未评估。真实 Git 混合样本见 `tests/acceptance/git-index-safety-preview.md`。这不是跨命令完整双维聚合,2.3 不勾选。
Python CVE 部分结果增量:`check all` 对完整但原生异常退出或输出超限的 pip-audit 报告,保留已归属 advisory;执行节点与类别候选均为 `native_incomplete`,未解决条件保留 `python_cve_task_incomplete`。旧汇总层仅按 `native_report_valid=true` 将部分报告误记成功,反例先 RED 后修正;两份实际总报告通过 schema。完整 findings/completion 跨语言、取消与内部异常优先级仍缺,2.3 不勾选,见相邻 `codeguard-cli/tests/acceptance/python-cve-partial-native.md`。
内部故障反馈进展:`check all/java` 的任务图若返回内部故障,现以退出 4 输出结构化 `check_aborted`,保留兄弟任务已取得的原生局部结果、发现及每项任务状态;交付为 incomplete/not_evaluated。同轮同时有取消时优先退出 130,仍保留故障任务 ID 与原生发现。它不是完整 RunReport,调度器自身失败和跨入口聚合尚未完成,2.3 仍不勾选。
取消优先级进展:`check all/java` 的 `check_feedback` 升至 0.16,实际 SIGINT 并行回归证明一项 Ruff 原生任务取消后退出 130,同时保留另一项已完成的 Rust Clippy finding;状态明确为 cancelled,交付仍 incomplete/not_evaluated。`lint python` 对话反馈也升至 0.11,真实 SIGINT 原生探测退出 130,保留局部反馈与子孙清理证据。其它入口、调度器故障与完整义务聚合仍缺,2.3 不勾选。
Expand Down Expand Up @@ -74,6 +75,7 @@
Java 扩展:`check_feedback` 0.7 增加 `java.p3c` 独立节点,复用相同截止时间和取消标志,缺项目配置或工具前提仍保留文件级原因。原生 Maven 探针使用私有临时工作区;完整规则集、跨模块构建资源锁及工作区级义务仍缺,3.4 不勾选。
并发私有工作区补强:共享 `DoctorScratch` 在内部标签后追加进程 ID 与进程内单调序号,防止同一时钟刻度的多个任务争抢同名 0700 目录;目录仍独占创建、不复用残留。并发同标签 RED→GREEN、Python CVE 多根 20 次及整组 15 次重复运行见[局部验收](../../../tests/acceptance/private-scratch-concurrency.md)。跨进程资源锁与完整 CheckPlan 仍缺,3.4 不勾选。
- [ ] 3.5 实现工作树/index/ref 快照与原始字节校验;验收:SHA-1/SHA-256、坏批响应、特殊文件、symlink/gitlink/LFS 均明确处理。进行中:相邻 Rust CLI 已按 NUL 协议读取真实 index、前后复核列表,并对有界普通 blob 独立核验 SHA-1/SHA-256 Git OID、记录脱敏字节摘要;symlink/gitlink/LFS、超预算和对象读取失败明确 unresolved,见 `codeguard-cli/tests/acceptance/git-index-safety-preview.md`。工作树/ref 快照、坏批响应注入测试、特殊类型完整语义及 Git 工具身份未完成。
局部保留进展:单对象/总预算超限不再清空其它已核对 blob 的证据;局部原生 batch 故障也保留先前逐对象核对的结果,并将余项列为 unresolved。真实超预算混合样本通过;受控 Git 第二批损坏响应反例证明前 64 个 OID 证据保留、第 65 个未完成。其它坏批响应变体、工作树/ref 快照及跨平台实测仍缺,3.5 不勾选。
- [ ] 3.6 实现 GIT_INDEX_FILE、初始提交、worktree、多 ref/non-HEAD/删除 ref push 输入;验收:真实临时 Git 仓检查准确且 index 不变。进行中:相邻 Rust 的真实 index 路径安全预览覆盖初始仓库、替代 `GIT_INDEX_FILE` 与默认 index 不变;worktree 和 pre-push 多 ref/删除 ref 尚未实现,不勾选。
- [ ] 3.7 实现执行前后内容身份复核和源码副作用检测;验收:并发编辑或检查器改源码导致 incomplete。
进行中:相邻 Rust runtime 新增显式相对路径的有界 `SourceSnapshot`,拒绝静态可见的路径越界、重复、符号链接及预算超限;私有副本须写入新目录,运行后复核原件与副本字节。JDK Javadoc 单文件入口已使用此契约,真实 JDK 21 与链接/修改反例通过,见 `codeguard-cli/tests/acceptance/source-snapshot-boundary.md`。调用方源集完整性、并发目录替换的内核级隔离、其它 adapter 接线和项目级 Maven 副作用仍缺,3.7 不勾选。
Expand Down
4 changes: 4 additions & 0 deletions tests/acceptance/git-index-safety-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ Rust CLI 的 `observe_index_safety` 通过受控原生 Git 进程读取仓库根
目标测试先因 Rust 模块缺失失败;随后验证暂存 `.env`、未暂存 `.pem`、替代 index、默认 index 不变、真实 SHA-1/SHA-256 仓库、暂存后工作树改动、symlink/LFS、超预算对象仍保留路径违规、坏记录/冲突、非 Git 目录、公开 CLI JSON/退出码及坏参数,并校验公开预览 schema 不含 allow 值。`git_index_safety_contract` 共 13 项通过,`repository_path_safety_contract` 共 3 项通过。真实 Git Hook、pre-push 多 ref、CI 与三宿主接线不在此切片内,OpenSpec 3.5/3.6/4.6/11.5/12.12 保持未完成。

2026-09-29 内容增量采用红→绿:先增加在 `.codeguard/findings/CG-demo.md` 暂存结构化私钥、随后把工作树同一路径改为安全文本的反例,测试因无内容违规失败;接入 Rust 纯结构解析和已核对 Git blob 的路径映射后通过。Core 目标测试 2 项、真实 Git/CLI 目标测试 15 项通过;CRLF、空壳 PEM、无效 base64、截断、损坏填充和未暂存密钥均有反例。本机 `ssh-keygen` 生成真实 Ed25519 私钥的首次验收暴露合法“零填充”格式漏报;修正后同一真实暂存样本在普通 `notes.md` 路径命中稳定规则,CLI 仍为 `not_evaluated`。`cargo fmt --all --check` 与 `git diff --check` 通过;全工作区回归在磁盘可用空间降至 801 MiB 时主动中断,未声称通过,随后仅清理本仓可重建的 Cargo dev 构建产物,磁盘恢复至约 20 GiB。首轮 PR CI 因 crate 依赖白名单缺少 base64 失败;修正并增加限定依赖的反例后,[第二轮 CI](https://github.com/full-stack-plugins/codeguard/actions/runs/36517569232) 通过完整 workspace 构建、Linux WASM worker 测试与全量测试。它仍不证明正式门禁已完成。

后续混合证据反例:同一真实 Git index 暂存 `.codeguard/findings/CG-demo.md` 中的结构化私钥和超过 8 MiB 的 `large.bin`。改动前对象大小预检整体报错,两个路径都列为 unresolved,私钥 finding 消失;改动后单独跳过超预算 OID,其余对象继续核对,保留一条私钥 finding、一条超预算 unresolved,CLI JSON 的 `verified_object_count=1`、`unresolved_object_count=1`、`delivery_decision=not_evaluated` 且退出 3。目标 Git/CLI 契约 16 项通过;完整工作区与 Linux CI 仍待本次提交验证。此修正仍不是完整内容扫描、原子 Git 快照或正式提交门禁。

补充坏批响应反例:受控 Git 替身将 65 个不同 OID 的首批 64 个返回真实原生命令输出,第二批返回损坏对象头。观察保留前 64 个逐对象核对证据,仅把第 65 个路径列为 unresolved,标记对象读取未完成且 `objects_verified=false`;没有从此前局部成功推导完整结果。目标 Git/CLI 契约增至 17 项通过,其余异常响应和实际平台边界仍由后续验收覆盖。
Loading