From 5fed98229c0e818ac6a7f361feb88761a841279e Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:27:20 +0800 Subject: [PATCH 1/3] feat: inspect staged OpenSSH key content in Rust preview --- Cargo.lock | 2 + Cargo.toml | 1 + README.md | 2 +- README.zh-CN.md | 2 +- .../src/eslint_finding_identity.rs | 7 +- crates/codeguard-cli/Cargo.toml | 1 + crates/codeguard-cli/src/eslint_workbench.rs | 2 +- crates/codeguard-cli/src/git_index_safety.rs | 40 ++++-- .../src/git_index_safety_command.rs | 4 +- crates/codeguard-cli/src/go_lint_command.rs | 2 +- .../codeguard-cli/src/rust_build_command.rs | 2 +- .../src/rust_comments_command.rs | 2 +- .../src/work_sync/eslint_report.rs | 7 +- .../src/work_sync/rust_build_report.rs | 2 +- .../src/work_sync/rustdoc_report.rs | 2 +- .../tests/git_index_safety_contract.rs | 86 ++++++++++++- crates/codeguard-core/Cargo.toml | 1 + crates/codeguard-core/src/lib.rs | 2 + .../src/repository_content_safety.rs | 120 ++++++++++++++++++ .../tests/repository_content_safety.rs | 82 ++++++++++++ .../specs/scan-scope-policy/spec.md | 4 + .../introduce-rust-codeguard-cli/tasks.md | 2 + .../verification.md | 6 + .../git-index-safety-preview-0.2.schema.json | 39 ++++++ schemas/git-index-safety-preview.schema.json | 6 +- tests/acceptance/git-index-safety-preview.md | 6 +- 26 files changed, 395 insertions(+), 37 deletions(-) create mode 100644 crates/codeguard-core/src/repository_content_safety.rs create mode 100644 crates/codeguard-core/tests/repository_content_safety.rs create mode 100644 schemas/git-index-safety-preview-0.2.schema.json diff --git a/Cargo.lock b/Cargo.lock index cb4ae0a5..40566c5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,7 @@ dependencies = [ name = "codeguard-cli" version = "0.1.2" dependencies = [ + "base64", "codeguard-adapters", "codeguard-core", "codeguard-runtime", @@ -176,6 +177,7 @@ dependencies = [ name = "codeguard-core" version = "0.1.2" dependencies = [ + "base64", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index a6dc4a75..f46a464a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,4 @@ serde_json = "1" sha2 = "0.10.9" sha1 = "=0.10.7" ring = "=0.17.14" +base64 = "=0.22.1" diff --git a/README.md b/README.md index c5bc156d..ec895ce8 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Supply the original checker's required tool/configuration options to `task verif | `task claim / heartbeat / release`, `task attempt start / finish` | Local ownership and attempt history | Unix local coordination, not distributed locking | | `task verify` | Repeat selected original checkers | Formal closure/reopening pending | | `rules whitelist list / explain / propose` | Inspect/propose false-positive dispositions and corrections | No public approval or activation | -| `gate pre-commit` | Git-index path-safety observation | Not a full content/security gate | +| `gate pre-commit` | Git-index path/object observation and narrow OpenSSH Ed25519 key detection | Incomplete preview; not a full content/security gate | For Python edit feedback, run `codeguard lint python . --file src/changed.py --format json`. `--file` is repeatable with a limit of eight distinct paths, 512 bytes per path and 2 KiB combined. The response declares `scan_scope=selected_files` and observes only those files; it is not imported as a full workspace scan and cannot approve delivery. Without `--file`, the existing command still scans discovered Python files and synchronizes its local report. diff --git a/README.zh-CN.md b/README.zh-CN.md index 23ae0ad9..cbed779f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -168,7 +168,7 @@ codeguard task verify "$TASK_ID" . --format json | `task claim / heartbeat / release`、`task attempt start / finish` | 本地占用与尝试历史 | Unix 本地协作,不保证分布式锁 | | `task verify` | 重跑部分原检查器 | 正式关闭与重开待完成 | | `rules whitelist list / explain / propose` | 查询或提出误报处置与纠错 | 无公开批准或生效入口 | -| `gate pre-commit` | Git index 路径安全观察 | 尚非完整内容或安全门禁 | +| `gate pre-commit` | Git index 路径、对象及未加密 OpenSSH Ed25519 私钥局部观察 | 仍是不完整预览,尚非完整内容或安全门禁 | Python 编辑快反馈可执行 `codeguard lint python . --file src/changed.py --format json`;`--file` 可重复,最多 8 个不同路径,单路径 512 字节、总计 2 KiB。报告标明 `scan_scope=selected_files`,只观察选中文件;局部反馈不导入完整工作台,也不代表全项目或交付通过。原有不带 `--file` 的命令仍扫描发现到的 Python 文件并同步局部报告。 diff --git a/crates/codeguard-adapters/src/eslint_finding_identity.rs b/crates/codeguard-adapters/src/eslint_finding_identity.rs index 67d5a9ae..d9fcfb99 100644 --- a/crates/codeguard-adapters/src/eslint_finding_identity.rs +++ b/crates/codeguard-adapters/src/eslint_finding_identity.rs @@ -103,12 +103,7 @@ pub fn project_eslint_findings( } /// 复核脱敏任务输入的稳定投影;消息摘要只验证形状,不自证原生来源。 -pub fn validate_records( - relative: &str, - absolute: &str, - source: &[u8], - records: &[Value], -) -> bool { +pub fn validate_records(relative: &str, absolute: &str, source: &[u8], records: &[Value]) -> bool { let mut diagnostics = Vec::new(); for record in records { let native = &record["native"]; diff --git a/crates/codeguard-cli/Cargo.toml b/crates/codeguard-cli/Cargo.toml index a6df72f8..bf8c34b4 100644 --- a/crates/codeguard-cli/Cargo.toml +++ b/crates/codeguard-cli/Cargo.toml @@ -23,5 +23,6 @@ sha1.workspace = true http = "=1.5.0" [dev-dependencies] +base64.workspace = true ring.workspace = true tokio = { version="=1.53.1", features=["rt","time","net"] } diff --git a/crates/codeguard-cli/src/eslint_workbench.rs b/crates/codeguard-cli/src/eslint_workbench.rs index 91803764..aa2c8e57 100644 --- a/crates/codeguard-cli/src/eslint_workbench.rs +++ b/crates/codeguard-cli/src/eslint_workbench.rs @@ -1,5 +1,4 @@ //! ESLint 局部原生观察到既有修复队列的连接;没有审批或关闭权威。 -use codeguard_adapters::project_eslint_findings; use crate::{ eslint_lint_arguments::EslintLintArguments, eslint_probe_request::EslintProbeRequest, @@ -7,6 +6,7 @@ use crate::{ work_sync::{save_local_report, sync_local_workspace}, workspace_refresh::read_workspace_baseline, }; +use codeguard_adapters::project_eslint_findings; use codeguard_runtime::read_bounded_regular_file; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; diff --git a/crates/codeguard-cli/src/git_index_safety.rs b/crates/codeguard-cli/src/git_index_safety.rs index 3ab4a199..df69cdb1 100644 --- a/crates/codeguard-cli/src/git_index_safety.rs +++ b/crates/codeguard-cli/src/git_index_safety.rs @@ -7,7 +7,9 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::time::{Duration, Instant}; -use codeguard_core::{RepositoryPathViolation, check_repository_paths}; +use codeguard_core::{ + RepositoryPathViolation, check_repository_paths, has_unencrypted_openssh_ed25519_private_key, +}; use codeguard_runtime::{ProcessSpec, Termination, run_process}; use sha1::Sha1; use sha2::{Digest, Sha256}; @@ -183,7 +185,7 @@ pub(crate) fn observe_index_safety_with_deadline( let first = git(&root, git_tool, index.as_deref(), &args, deadline, None)?; let entries = parse_index_listing(&first, format)?; let paths: Vec = entries.iter().map(|entry| entry.path.clone()).collect(); - let violations = check_repository_paths(&paths)?; + let mut violations = check_repository_paths(&paths)?; let object_result = verify_index_objects( &root, git_tool, @@ -198,7 +200,10 @@ pub(crate) fn observe_index_safety_with_deadline( } let (object_evidence, unresolved_object_paths, object_verification_reason) = match object_result { - Ok((evidence, unresolved)) => (evidence, unresolved, None), + Ok((evidence, unresolved, content_violations)) => { + violations.extend(content_violations); + (evidence, unresolved, None) + } Err(reason) => (Vec::new(), paths.clone(), Some(reason)), }; let objects_verified = object_verification_reason.is_none() @@ -223,7 +228,14 @@ fn verify_index_objects( entries: &[GitIndexEntry], format: &str, deadline: Instant, -) -> Result<(Vec, Vec), String> { +) -> Result< + ( + Vec, + Vec, + Vec, + ), + String, +> { let mut requested = BTreeMap::<&str, usize>::new(); let mut unresolved = Vec::new(); for entry in entries { @@ -234,7 +246,7 @@ fn verify_index_objects( } } if requested.is_empty() { - return Ok((Vec::new(), unresolved)); + return Ok((Vec::new(), unresolved, Vec::new())); } let oid_request = requested .keys() @@ -273,7 +285,7 @@ fn verify_index_objects( } *size = count; } - let mut verified = BTreeMap::::new(); + let mut verified = BTreeMap::::new(); let mut batch = Vec::<(&str, usize)>::new(); let mut batch_size = 0_usize; for (oid, size) in requested { @@ -289,11 +301,13 @@ fn verify_index_objects( read_blob_batch(root, tool, index, &batch, format, deadline, &mut verified)?; } 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) = verified.get(&entry.oid).ok_or("Git 对象验证结果缺失")?; + let (size, digest, lfs_pointer, private_key) = + verified.get(&entry.oid).ok_or("Git 对象验证结果缺失")?; let kind = if entry.mode == "120000" { "symlink_unresolved" } else if *lfs_pointer { @@ -304,6 +318,13 @@ fn verify_index_objects( if kind != "regular" { unresolved.push(entry.path.clone()); } + // 仅对已核对 OID 的普通 blob 记录精确结构命中;不回显私钥字节。 + if kind == "regular" && *private_key { + content_violations.push(RepositoryPathViolation { + path: entry.path.clone(), + rule_id: "repository_policy.unencrypted_openssh_ed25519_private_key".into(), + }); + } evidence.push(IndexObjectEvidence { path: entry.path.clone(), oid: entry.oid.clone(), @@ -313,7 +334,7 @@ fn verify_index_objects( }); } unresolved.sort(); - Ok((evidence, unresolved)) + Ok((evidence, unresolved, content_violations)) } fn read_blob_batch( @@ -323,7 +344,7 @@ fn read_blob_batch( batch: &[(&str, usize)], format: &str, deadline: Instant, - verified: &mut BTreeMap, + verified: &mut BTreeMap, ) -> Result<(), String> { let input = batch .iter() @@ -364,6 +385,7 @@ fn read_blob_batch( *size, format!("{:x}", Sha256::digest(content)), content.starts_with(b"version https://git-lfs.github.com/spec/v1"), + has_unencrypted_openssh_ed25519_private_key(content), ), ); cursor = content_end + 1; diff --git a/crates/codeguard-cli/src/git_index_safety_command.rs b/crates/codeguard-cli/src/git_index_safety_command.rs index 6cd55ff6..032491d7 100644 --- a/crates/codeguard-cli/src/git_index_safety_command.rs +++ b/crates/codeguard-cli/src/git_index_safety_command.rs @@ -56,7 +56,7 @@ pub fn run(args: &[String]) -> ExitCode { incomplete_reasons.push("index_objects_unresolved"); } json!({ - "schema_version":"0.2.0", + "schema_version":"0.3.0", "report_type":"git_index_safety_preview", "operation":"gate_pre_commit", "command_status":"incomplete", @@ -77,7 +77,7 @@ pub fn run(args: &[String]) -> ExitCode { }) } Err(_) => json!({ - "schema_version":"0.2.0", + "schema_version":"0.3.0", "report_type":"git_index_safety_preview", "operation":"gate_pre_commit", "command_status":"incomplete", diff --git a/crates/codeguard-cli/src/go_lint_command.rs b/crates/codeguard-cli/src/go_lint_command.rs index 6a6d8316..cd7887cc 100644 --- a/crates/codeguard-cli/src/go_lint_command.rs +++ b/crates/codeguard-cli/src/go_lint_command.rs @@ -2,9 +2,9 @@ use crate::check_budget::parse_check_timeout; use crate::discovery::discover; -use codeguard_adapters::go_finding_record; use crate::work_sync::{save_local_report, sync_local_workspace}; use crate::workspace_refresh::read_workspace_baseline; +use codeguard_adapters::go_finding_record; use codeguard_adapters::{GoVetParseState, legacy_registry, parse_go_vet_json}; use codeguard_runtime::{ NativeObservation, ProcessSpec, Termination, read_bounded_regular_file, run_process, diff --git a/crates/codeguard-cli/src/rust_build_command.rs b/crates/codeguard-cli/src/rust_build_command.rs index b58552d5..fb28e4d4 100644 --- a/crates/codeguard-cli/src/rust_build_command.rs +++ b/crates/codeguard-cli/src/rust_build_command.rs @@ -1,6 +1,5 @@ //! 原生 Cargo/check 类型检查观察;完整项目策略与修复任务仍独立核验。 -use codeguard_adapters::cargo_build_finding_record; use crate::cargo_build_repair_brief::cargo_build_repair_brief; use crate::check_budget::{ budget_record, parse_check_timeout, resolve_project_default, select_check_timeout, @@ -9,6 +8,7 @@ use crate::discovery::discover; use crate::doctor_scratch::DoctorScratch; use crate::work_sync::{save_local_report, sync_local_workspace}; use crate::workspace_refresh::read_workspace_baseline; +use codeguard_adapters::cargo_build_finding_record; use codeguard_adapters::{legacy_registry, parse_cargo_build_json}; use codeguard_runtime::{ NativeObservation, ProcessSpec, SourceSnapshot, Termination, read_bounded_regular_file, diff --git a/crates/codeguard-cli/src/rust_comments_command.rs b/crates/codeguard-cli/src/rust_comments_command.rs index c03c7086..d0aea58d 100644 --- a/crates/codeguard-cli/src/rust_comments_command.rs +++ b/crates/codeguard-cli/src/rust_comments_command.rs @@ -5,10 +5,10 @@ use crate::check_budget::{ }; use crate::discovery::discover; use crate::doctor_scratch::DoctorScratch; -use codeguard_adapters::rustdoc_finding_record; use crate::rustdoc_repair_brief::rustdoc_repair_brief; use crate::work_sync::{save_local_report, sync_local_workspace}; use crate::workspace_refresh::read_workspace_baseline; +use codeguard_adapters::rustdoc_finding_record; use codeguard_adapters::{legacy_registry, parse_cargo_rustdoc_json}; use codeguard_runtime::{ NativeObservation, ProcessSpec, SourceSnapshot, Termination, read_bounded_regular_file, diff --git a/crates/codeguard-cli/src/work_sync/eslint_report.rs b/crates/codeguard-cli/src/work_sync/eslint_report.rs index e363099d..a5550696 100644 --- a/crates/codeguard-cli/src/work_sync/eslint_report.rs +++ b/crates/codeguard-cli/src/work_sync/eslint_report.rs @@ -115,12 +115,7 @@ pub(super) fn parse( { return Err("eslint_completion_invalid"); } - if !codeguard_adapters::validate_records( - relative, - absolute, - &bytes["source"], - records, - ) { + if !codeguard_adapters::validate_records(relative, absolute, &bytes["source"], records) { return Err("eslint_finding_identity_invalid"); } let findings = records diff --git a/crates/codeguard-cli/src/work_sync/rust_build_report.rs b/crates/codeguard-cli/src/work_sync/rust_build_report.rs index 5ed40299..8111b30d 100644 --- a/crates/codeguard-cli/src/work_sync/rust_build_report.rs +++ b/crates/codeguard-cli/src/work_sync/rust_build_report.rs @@ -3,9 +3,9 @@ use super::{ BlockerInput, FindingInput, MAX_REPORT_BYTES, ReportInput, read_bounded_file, safe_reason, safe_relative_path, safe_run_id, valid_sha256, }; -use codeguard_adapters::cargo_build_finding_record; use crate::cargo_build_repair_brief::cargo_build_repair_brief; use codeguard_adapters::CargoBuildDiagnostic; +use codeguard_adapters::cargo_build_finding_record; use codeguard_runtime::SourceSnapshot; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; diff --git a/crates/codeguard-cli/src/work_sync/rustdoc_report.rs b/crates/codeguard-cli/src/work_sync/rustdoc_report.rs index 28e2c1fc..83d9f7ff 100644 --- a/crates/codeguard-cli/src/work_sync/rustdoc_report.rs +++ b/crates/codeguard-cli/src/work_sync/rustdoc_report.rs @@ -3,9 +3,9 @@ use super::{ BlockerInput, FindingInput, MAX_REPORT_BYTES, ReportInput, read_bounded_file, safe_reason, safe_relative_path, safe_run_id, valid_sha256, }; -use codeguard_adapters::rustdoc_finding_record; use crate::rustdoc_repair_brief::rustdoc_repair_brief; use codeguard_adapters::RustdocFinding; +use codeguard_adapters::rustdoc_finding_record; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; diff --git a/crates/codeguard-cli/tests/git_index_safety_contract.rs b/crates/codeguard-cli/tests/git_index_safety_contract.rs index bcf28055..39fece76 100644 --- a/crates/codeguard-cli/tests/git_index_safety_contract.rs +++ b/crates/codeguard-cli/tests/git_index_safety_contract.rs @@ -1,5 +1,7 @@ #![cfg(unix)] +use base64::Engine; +use base64::engine::general_purpose::STANDARD; use codeguard_cli::git_index_safety::{ observe_index_safety, parse_index_listing, verify_git_blob_oid, }; @@ -59,6 +61,83 @@ fn git_binary() -> PathBuf { .unwrap() } +fn synthetic_ed25519_pem() -> String { + fn field(out: &mut Vec, value: &[u8]) { + out.extend_from_slice(&(value.len() as u32).to_be_bytes()); + out.extend_from_slice(value); + } + let public_key = [0xa5_u8; 32]; + let mut public = Vec::new(); + field(&mut public, b"ssh-ed25519"); + field(&mut public, &public_key); + let mut private = Vec::new(); + private.extend_from_slice(&0x1234_5678_u32.to_be_bytes()); + private.extend_from_slice(&0x1234_5678_u32.to_be_bytes()); + field(&mut private, b"ssh-ed25519"); + field(&mut private, &public_key); + let mut key_material = [0x5a_u8; 64]; + key_material[32..].copy_from_slice(&public_key); + field(&mut private, &key_material); + field(&mut private, b""); + private.extend((1..=(8 - private.len() % 8)).map(|value| value as u8)); + let mut envelope = b"openssh-key-v1\0".to_vec(); + field(&mut envelope, b"none"); + field(&mut envelope, b"none"); + field(&mut envelope, b""); + envelope.extend_from_slice(&1_u32.to_be_bytes()); + field(&mut envelope, &public); + field(&mut envelope, &private); + format!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n{}\n-----END OPENSSH PRIVATE KEY-----\n", + STANDARD.encode(envelope) + ) +} + +#[test] +fn staged_private_key_material_is_found_in_managed_records_without_reading_worktree_bytes() { + let repo = Repo::new(); + fs::create_dir_all(repo.0.join(".codeguard/findings")).unwrap(); + let record = repo.0.join(".codeguard/findings/CG-demo.md"); + fs::write(&record, synthetic_ed25519_pem()).unwrap(); + repo.git(&["add", ".codeguard/findings/CG-demo.md"]); + fs::write(&record, "safe current worktree text\n").unwrap(); + let observed = observe_index_safety(&repo.0, &git_binary(), None).unwrap(); + assert!(observed.objects_verified); + 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["schema_version"], "0.3.0"); + assert_eq!(report["delivery_decision"], "not_evaluated"); + assert_eq!( + report["violations"][0]["rule_id"], + "repository_policy.unencrypted_openssh_ed25519_private_key" + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("BEGIN OPENSSH")); +} + +#[test] +fn unstaged_private_key_material_does_not_change_the_staged_content_result() { + let repo = Repo::new(); + fs::write(repo.0.join("notes.md"), "safe staged bytes\n").unwrap(); + repo.git(&["add", "notes.md"]); + fs::write(repo.0.join("notes.md"), synthetic_ed25519_pem()).unwrap(); + let observed = observe_index_safety(&repo.0, &git_binary(), None).unwrap(); + assert!(observed.objects_verified); + assert!(observed.violations.is_empty()); +} + #[test] fn staged_hidden_secret_is_seen_but_unstaged_worktree_secret_is_not() { let repo = Repo::new(); @@ -282,10 +361,15 @@ fn published_preview_schema_never_contains_an_allow_decision() { "../../../schemas/git-index-safety-preview.schema.json" )) .unwrap(); + let previous: Value = serde_json::from_str(include_str!( + "../../../schemas/git-index-safety-preview-0.2.schema.json" + )) + .unwrap(); assert_eq!( schema["$id"], - "urn:codeguard:schema:git-index-safety-preview:0.2.0" + "urn:codeguard:schema:git-index-safety-preview:0.3.0" ); + assert_eq!(previous["properties"]["schema_version"]["const"], "0.2.0"); assert_eq!(schema["additionalProperties"], false); assert_eq!( schema["properties"]["delivery_decision"]["const"], diff --git a/crates/codeguard-core/Cargo.toml b/crates/codeguard-core/Cargo.toml index c407d005..015e756b 100644 --- a/crates/codeguard-core/Cargo.toml +++ b/crates/codeguard-core/Cargo.toml @@ -6,4 +6,5 @@ rust-version.workspace = true license.workspace = true [dependencies] +base64.workspace = true serde.workspace = true diff --git a/crates/codeguard-core/src/lib.rs b/crates/codeguard-core/src/lib.rs index 0b36c245..af1c43a0 100644 --- a/crates/codeguard-core/src/lib.rs +++ b/crates/codeguard-core/src/lib.rs @@ -80,7 +80,9 @@ pub use task_node::TaskNode; pub use verdict::Verdict; mod readiness_state; +mod repository_content_safety; pub use readiness_state::ReadinessState; +pub use repository_content_safety::has_unencrypted_openssh_ed25519_private_key; mod preparation_evidence_state; pub use preparation_evidence_state::PreparationEvidenceState; diff --git a/crates/codeguard-core/src/repository_content_safety.rs b/crates/codeguard-core/src/repository_content_safety.rs new file mode 100644 index 00000000..a7f53bd5 --- /dev/null +++ b/crates/codeguard-core/src/repository_content_safety.rs @@ -0,0 +1,120 @@ +//! 拟入库内容的低误报结构检查;只识别完整的未加密 OpenSSH Ed25519 私钥。 + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; + +const BEGIN: &[u8] = b"-----BEGIN OPENSSH PRIVATE KEY-----"; +const END: &[u8] = b"-----END OPENSSH PRIVATE KEY-----"; +const MAX_ENCODED_BYTES: usize = 16 * 1024; + +/// 在已核对身份的 Git blob 字节中识别完整的未加密 OpenSSH Ed25519 私钥。 +/// 参数为原始 blob 字节,返回值只表示该精确格式命中;其他密钥格式仍须独立检查。 +#[must_use] +pub fn has_unencrypted_openssh_ed25519_private_key(content: &[u8]) -> bool { + let mut encoded: Option> = None; + for line in content.split(|byte| *byte == b'\n') { + let line = line.strip_suffix(b"\r").unwrap_or(line); + if line == BEGIN { + encoded = Some(Vec::new()); + } else if line == END { + if let Some(encoded) = encoded.take() { + if let Ok(decoded) = STANDARD.decode(&encoded) { + if valid_ed25519_envelope(&decoded) { + return true; + } + } + } + } else if let Some(buffer) = encoded.as_mut() { + if line.is_empty() + || buffer.len().saturating_add(line.len()) > MAX_ENCODED_BYTES + || !line + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')) + { + encoded = None; + } else { + buffer.extend_from_slice(line); + } + } + } + false +} + +fn valid_ed25519_envelope(bytes: &[u8]) -> bool { + let Some(mut cursor) = bytes.strip_prefix(b"openssh-key-v1\0") else { + return false; + }; + let Some(cipher) = take_string(&mut cursor) else { + return false; + }; + let Some(kdf) = take_string(&mut cursor) else { + return false; + }; + let Some(kdf_options) = take_string(&mut cursor) else { + return false; + }; + if cipher != b"none" || kdf != b"none" || !kdf_options.is_empty() { + return false; + } + let Some(key_count) = take_u32(&mut cursor) else { + return false; + }; + if key_count != 1 { + return false; + } + let Some(public_blob) = take_string(&mut cursor) else { + return false; + }; + let Some(private_blob) = take_string(&mut cursor) else { + return false; + }; + if !cursor.is_empty() || private_blob.len() % 8 != 0 { + return false; + } + let mut public = public_blob; + if take_string(&mut public) != Some(b"ssh-ed25519".as_slice()) { + return false; + } + let Some(public_key) = take_string(&mut public) else { + return false; + }; + if public_key.len() != 32 || !public.is_empty() { + return false; + } + let mut private = private_blob; + let Some(first_check) = take_u32(&mut private) else { + return false; + }; + if take_u32(&mut private) != Some(first_check) + || take_string(&mut private) != Some(b"ssh-ed25519".as_slice()) + || take_string(&mut private) != Some(public_key) + { + return false; + } + let Some(key_material) = take_string(&mut private) else { + return false; + }; + if key_material.len() != 64 || key_material[32..] != *public_key { + return false; + } + if take_string(&mut private).is_none() || private.len() > 8 { + return false; + } + private + .iter() + .enumerate() + .all(|(index, byte)| *byte == (index + 1) as u8) +} + +fn take_u32(input: &mut &[u8]) -> Option { + let (head, tail) = input.split_at_checked(4)?; + *input = tail; + Some(u32::from_be_bytes(head.try_into().ok()?)) +} + +fn take_string<'a>(input: &mut &'a [u8]) -> Option<&'a [u8]> { + let size = usize::try_from(take_u32(input)?).ok()?; + let (head, tail) = input.split_at_checked(size)?; + *input = tail; + Some(head) +} diff --git a/crates/codeguard-core/tests/repository_content_safety.rs b/crates/codeguard-core/tests/repository_content_safety.rs new file mode 100644 index 00000000..6f0a932c --- /dev/null +++ b/crates/codeguard-core/tests/repository_content_safety.rs @@ -0,0 +1,82 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codeguard_core::has_unencrypted_openssh_ed25519_private_key; + +fn ssh_string(bytes: &[u8], output: &mut Vec) { + output.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + output.extend_from_slice(bytes); +} + +fn fixture_with_padding(add_padding: bool) -> Vec { + let public_bytes = [0xa5_u8; 32]; + let mut public = Vec::new(); + ssh_string(b"ssh-ed25519", &mut public); + ssh_string(&public_bytes, &mut public); + let mut private = Vec::new(); + private.extend_from_slice(&0x1234_5678_u32.to_be_bytes()); + private.extend_from_slice(&0x1234_5678_u32.to_be_bytes()); + ssh_string(b"ssh-ed25519", &mut private); + ssh_string(&public_bytes, &mut private); + let mut key_material = [0x5a_u8; 64]; + key_material[32..].copy_from_slice(&public_bytes); + ssh_string(&key_material, &mut private); + let comment_length = (8 - (private.len() + 4) % 8) % 8; + ssh_string(&vec![b'x'; comment_length], &mut private); + if add_padding { + private.extend((1..=8).map(|value| value as u8)); + } + let mut envelope = b"openssh-key-v1\0".to_vec(); + ssh_string(b"none", &mut envelope); + ssh_string(b"none", &mut envelope); + ssh_string(b"", &mut envelope); + envelope.extend_from_slice(&1_u32.to_be_bytes()); + ssh_string(&public, &mut envelope); + ssh_string(&private, &mut envelope); + format!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n{}\n-----END OPENSSH PRIVATE KEY-----\n", + STANDARD.encode(envelope) + ) + .into_bytes() +} + +fn fixture() -> Vec { + fixture_with_padding(true) +} + +#[test] +fn recognizes_structurally_complete_openssh_private_key_without_needing_a_sensitive_name() { + assert!(has_unencrypted_openssh_ed25519_private_key(&fixture())); + assert!(has_unencrypted_openssh_ed25519_private_key( + &fixture_with_padding(false) + )); + assert!(has_unencrypted_openssh_ed25519_private_key( + &[b"# documentation\n".as_slice(), &fixture()].concat() + )); + let crlf = String::from_utf8(fixture()).unwrap().replace('\n', "\r\n"); + assert!(has_unencrypted_openssh_ed25519_private_key(crlf.as_bytes())); +} + +#[test] +fn rejects_header_only_invalid_base64_and_truncated_envelopes() { + assert!(!has_unencrypted_openssh_ed25519_private_key( + b"-----BEGIN OPENSSH PRIVATE KEY-----\nexample\n-----END OPENSSH PRIVATE KEY-----\n" + )); + assert!(!has_unencrypted_openssh_ed25519_private_key( + b"-----BEGIN OPENSSH PRIVATE KEY-----\n!!!!\n-----END OPENSSH PRIVATE KEY-----\n" + )); + let mut truncated = fixture(); + truncated.truncate(truncated.len() - 39); + assert!(!has_unencrypted_openssh_ed25519_private_key(&truncated)); + + let text = String::from_utf8(fixture()).unwrap(); + let body = text.lines().nth(1).unwrap(); + let mut decoded = STANDARD.decode(body).unwrap(); + *decoded.last_mut().unwrap() = 0; + let damaged = format!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n{}\n-----END OPENSSH PRIVATE KEY-----\n", + STANDARD.encode(decoded) + ); + assert!(!has_unencrypted_openssh_ed25519_private_key( + damaged.as_bytes() + )); +} diff --git a/openspec/changes/introduce-rust-codeguard-cli/specs/scan-scope-policy/spec.md b/openspec/changes/introduce-rust-codeguard-cli/specs/scan-scope-policy/spec.md index fea34911..51972e1d 100644 --- a/openspec/changes/introduce-rust-codeguard-cli/specs/scan-scope-policy/spec.md +++ b/openspec/changes/introduce-rust-codeguard-cli/specs/scan-scope-policy/spec.md @@ -12,6 +12,10 @@ - **WHEN** 拟提交的持久记录意外包含密钥 - **THEN** 入库安全仍检查并阻断,受管产物不能豁免敏感内容 +#### Scenario: A staged managed record contains structurally valid key material +- **WHEN** `.codeguard/` 记录的暂存 blob 包含完整的未加密 OpenSSH Ed25519 私钥,而同一路径的工作树字节已被替换 +- **THEN** 安全检查仍以核对 Git OID 后的暂存字节报告稳定规则 ID;不得因 `.codeguard/` 名称或工作树当前内容掩盖发现,也不得泄露私钥字节。单项内容预览不能签发完整交付通过 + #### Scenario: Agent expands the ownership manifest - **WHEN** agent 将普通源码路径添加进受管排除清单 - **THEN** 生成器所有权校验不接受扩张,不能据可写 manifest 关闭扫描 diff --git a/openspec/changes/introduce-rust-codeguard-cli/tasks.md b/openspec/changes/introduce-rust-codeguard-cli/tasks.md index 61decd30..ac9e4457 100644 --- a/openspec/changes/introduce-rust-codeguard-cli/tasks.md +++ b/openspec/changes/introduce-rust-codeguard-cli/tasks.md @@ -100,6 +100,7 @@ - [ ] 4.5 实现可信政策修订和例外输入校验;验收:agent 自写批准、无到期、过期、错内容或错范围凭据不生效,批准例外不显示普通 PASS。 签名进展:Rust 已增加域分隔 Ed25519 原始载荷核验,绑定工作区、策略修订、受保护 Git 基线、序号、期限与快照字节;普通候选桥接还核对签名与快照内修订,再走精确字节/身份绑定。可信公钥、撤销、时间和最低序号仍须由宿主提供;未提供项目自批入口。签名四项及原快照十五项通过,见相邻 codeguard-cli/tests/acceptance/signed-approval-binding.md。宿主信任来源、完整签名修订链及真实门禁尚缺,4.5/4.8 不勾选。 - [ ] 4.6 保留点前缀默认策略及两项例外,生成汇总覆盖;验收:F18 全部成立,无未授权的新排除。进行中:相邻 Rust `detect` 0.3.0 已汇总普通发现的点前缀排除根、配置例外文件及未评估入库安全状态;`gate pre-commit` 路径安全预览能在真实 index 看到点前缀 `.env`,见 `codeguard-cli/tests/acceptance/{dot-prefix-scope-baseline,git-index-safety-preview}.md`。完整安全内容扫描与正式 Git 门禁未接线,不能勾选。 + 暂存内容增量:`gate pre-commit` 0.3.0 对核对 OID 的普通 blob 识别结构完整的未加密 OpenSSH Ed25519 私钥,`.codeguard/` 受管记录也不豁免;真实 `ssh-keygen` 样本及暂存/工作树分离反例见同一验收记录。只覆盖一个高置信度格式,其他密钥、完整安全门禁和 F18 全矩阵仍缺,4.6 不勾选。 - [ ] 4.7 接入 rules list/config validate/explain 命令;验收:有效规则、原生suppression与批准来源可解释,静态验证不执行项目脚本,配置错误为未完成而非源码违规。 追加进展:`config validate/explain` 0.1 只返回旧配置与工具锁的静态状态、未绑定的可信质量策略和无效白名单权威;不产生有效策略或质量门禁。`rules list`、规则/原生配置差异和批准来源解释仍缺,任务不勾选。 2026-09-27入口进展:Rust rules list现复用静态发现及内置版本化Ruff候选映射,逐语言显示配置声明、来源/许可/摘要/兼容版本和六类目录缺口;Node生态显式映射到TypeScript,不靠语言前缀丢失ESLint配置。动态配置不执行、本地同名映射/自批不授权,报告0.1固定未完成且无门禁效果。新增schema及验收用例,结果见verification.md。完整有效规则、可信来源、suppression和批准例外仍缺,4.7不勾选。 @@ -556,6 +557,7 @@ - [ ] 11.3 实现 MCP 相同核心 API 与版本化兼容工具;验收:F22、凭据脱敏与超时/取消正确。 - [ ] 11.4 更新五类宿主入口及 hooks/__protocol__.md 的旧新模式表;验收:保存反馈与严格交付区分,skipGate/env 不能降级新模式。 - [ ] 11.5 实现真实 Git pre-commit/pre-push 与 CI 入口;验收:alternate index、多 ref、非 HEAD、未知 Shell 边界正确,remote参数/stdin及ci input schema显式验证,不默认HEAD或由input自授策略权威。进行中:Rust CLI 暴露 `gate pre-commit` 的真实 index 路径安全预览,固定 3/incomplete,不是可放行的完整 Git Hook;见 `codeguard-cli/tests/acceptance/git-index-safety-preview.md`。 + 内容增量:同一预览已报告暂存普通 blob 中结构完整的未加密 OpenSSH Ed25519 私钥,按暂存对象身份而非工作树读取;CLI 仍固定退出 3、交付未评估,不具备完整内容规则、可信 Git 工具或正式 Git Hook,11.5 不勾选。 - [ ] 11.6 在独立 codeguard-skills 源仓更新调用与修复指引,再按 vendor 流程同步;验收:不直接编辑受管副本,不建议规避门禁。 - [ ] 11.7 明确 legacy 弃用与安全回滚路径;验收:旧数字保持,但旧通过不能被新 CI 认证。 - [ ] 11.8 完成macOS arm64候选制品和平台适配验收;依赖11.1/11.2;证据:真实启动、进程树/取消、路径/私有权限、原子写入、离线能力和代表性工具,未支持项明确gap。 diff --git a/openspec/changes/introduce-rust-codeguard-cli/verification.md b/openspec/changes/introduce-rust-codeguard-cli/verification.md index 409d1beb..a66f1129 100644 --- a/openspec/changes/introduce-rust-codeguard-cli/verification.md +++ b/openspec/changes/introduce-rust-codeguard-cli/verification.md @@ -2066,3 +2066,9 @@ TDD 先复现报告漏洞:把源码 finding 的主定位、原生身份及白 源码提交 `d2ae54355ea0e8b67b7fa42fc66829656db9de01` 的 GitHub [CI 36512426737](https://github.com/full-stack-plugins/codeguard/actions/runs/36512426737) 成功;干净 checkout 的 release 二进制自报 `0.1.2`、`macos_arm64` 与同一候选构建身份。`npm publish` 成功,注册表 `latest` 指向 0.1.2;注册表下载包与本地 tarball SHA-256 均为 `4411136f2cdf678615335a6a9fe4f3435fe87f82e789b98a1e07be4518587401`,包内和本机程序 SHA-256 均为 `785d8b9abb685ce5a3f4c1de0697d67a669394160bd7fb9a3554019d1d223bca`。新缓存 npx 返回同一版本、平台和构建身份;完整性字段、调用命令及边界见 [npm 0.1.2 验收](../../../tests/acceptance/npm-0.1.2-candidate.md)。插件 0.1.2 锁和真实候选 `UserPromptSubmit` 调用局部成立;默认宿主 Hook、严格 Git/CI 门禁、多平台和可信签名仍缺,S11.2、S13.4 与整体 S13 保持未完成。 插件 [PR #86](https://github.com/full-stack-plugins/codeguard-plugin/pull/86) 的四项 CI 全部通过后合并为 `461f1529f92135c51c2bf569a864f78e459e439c`;远端 `v0.18.0` 标签指向该提交,[GitHub Release](https://github.com/full-stack-plugins/codeguard-plugin/releases/tag/v0.18.0) 已建立。市场仓 `e085469` 将 CodeGuard 元数据更新到 0.18.0,并通过定向远端 tag/Release 校验;用户已有 CodeReview 差异仍未暂存。独立缓存中,注册表安装的二进制经插件 `verify` 返回 `status=verified`;直接向候选 dispatcher 送入 Claude `UserPromptSubmit` 形状 JSON 返回固定提示,明确“源码检查未运行,交付未评估”。这不证明已安装 Claude Code 自动触发,也不等于默认 Hook 或交付门禁已切换。插件 OpenSpec 候选变更完成任务核对并归档;完整 Rust S11/S13 仍不勾选。 + +## 2026-09-29 暂存内容安全局部规则 + +`repository_content_safety` 新增纯 Rust 的未加密 OpenSSH Ed25519 私钥结构判定:要求成对 PEM 标记、合法 base64、完整 OpenSSH 封装、公私钥字段一致、checkints 和填充格式。`git_index_safety` 只在受控 `git cat-file` 取回并独立核对 OID 的普通暂存 blob 上调用;命中只投影路径与稳定规则 ID,不公开密钥字节。`.codeguard/findings` 与普通文件同样受此入库观察。预览协议升至 0.3.0,0.2.0 schema 留存;命令仍固定退出 3/incomplete/not_evaluated。 + +目标集成测试先因无内容违规失败,接线后 Core 2/2、真实 Git/CLI 15/15 通过。额外使用本机 `ssh-keygen` 生成的真实 Ed25519 私钥暂存到普通 `notes.md`:首次执行发现合法零填充格式漏报,修正后同一暂存对象命中 `repository_policy.unencrypted_openssh_ed25519_private_key`,且不回显内容。`cargo fmt --all --check`、`git diff --check` 通过。全工作区测试在可用空间降至 801 MiB 时被中断,不作为通过证据;清理仅限本仓 Cargo dev 构建产物后可用空间约 20 GiB。完整 CI 仍待本次变更远端验证。其他私钥格式、签发工具身份、完整交付义务、Git Hook/pre-push/CI 与三宿主真实接入仍缺,3.5/4.6/11.5/12.12 等总体任务不勾选。 diff --git a/schemas/git-index-safety-preview-0.2.schema.json b/schemas/git-index-safety-preview-0.2.schema.json new file mode 100644 index 00000000..2b283545 --- /dev/null +++ b/schemas/git-index-safety-preview-0.2.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:codeguard:schema:git-index-safety-preview:0.2.0", + "title": "CodeGuard staged path safety preview", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "report_type", "operation", "command_status", "exit_code", "delivery_decision", "index_observation", "index_object_format", "index_listing_sha256", "staged_entry_count", "object_status", "verified_object_count", "unresolved_object_count", "object_verification_reason", "violations", "tool_approval", "incomplete_reasons", "next_actions"], + "properties": { + "schema_version": {"const": "0.2.0"}, + "report_type": {"const": "git_index_safety_preview"}, + "operation": {"const": "gate_pre_commit"}, + "command_status": {"const": "incomplete"}, + "exit_code": {"const": 3}, + "delivery_decision": {"const": "not_evaluated"}, + "index_observation": {"enum": ["complete", "incomplete"]}, + "index_object_format": {"type": ["string", "null"], "enum": ["sha1", "sha256", null]}, + "index_listing_sha256": {"anyOf": [{"type": "string", "pattern": "^[0-9a-f]{64}$"}, {"type": "null"}]}, + "staged_entry_count": {"type": ["integer", "null"], "minimum": 0}, + "object_status": {"enum": ["verified", "unresolved", "failed", "not_observed"]}, + "verified_object_count": {"type": ["integer", "null"], "minimum": 0}, + "unresolved_object_count": {"type": ["integer", "null"], "minimum": 0}, + "object_verification_reason": {"type": ["string", "null"]}, + "violations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "rule_id"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "rule_id": {"enum": ["repository_policy.generated_or_dependency_directory", "repository_policy.sensitive_filename", "repository_policy.root_dump"]} + } + } + }, + "tool_approval": {"const": "unverified"}, + "incomplete_reasons": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, + "next_actions": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}} + } +} diff --git a/schemas/git-index-safety-preview.schema.json b/schemas/git-index-safety-preview.schema.json index 2b283545..9cc84d9b 100644 --- a/schemas/git-index-safety-preview.schema.json +++ b/schemas/git-index-safety-preview.schema.json @@ -1,12 +1,12 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:codeguard:schema:git-index-safety-preview:0.2.0", + "$id": "urn:codeguard:schema:git-index-safety-preview:0.3.0", "title": "CodeGuard staged path safety preview", "type": "object", "additionalProperties": false, "required": ["schema_version", "report_type", "operation", "command_status", "exit_code", "delivery_decision", "index_observation", "index_object_format", "index_listing_sha256", "staged_entry_count", "object_status", "verified_object_count", "unresolved_object_count", "object_verification_reason", "violations", "tool_approval", "incomplete_reasons", "next_actions"], "properties": { - "schema_version": {"const": "0.2.0"}, + "schema_version": {"const": "0.3.0"}, "report_type": {"const": "git_index_safety_preview"}, "operation": {"const": "gate_pre_commit"}, "command_status": {"const": "incomplete"}, @@ -28,7 +28,7 @@ "required": ["path", "rule_id"], "properties": { "path": {"type": "string", "minLength": 1}, - "rule_id": {"enum": ["repository_policy.generated_or_dependency_directory", "repository_policy.sensitive_filename", "repository_policy.root_dump"]} + "rule_id": {"enum": ["repository_policy.generated_or_dependency_directory", "repository_policy.sensitive_filename", "repository_policy.root_dump", "repository_policy.unencrypted_openssh_ed25519_private_key"]} } } }, diff --git a/tests/acceptance/git-index-safety-preview.md b/tests/acceptance/git-index-safety-preview.md index 9edf04ac..c9b4aae4 100644 --- a/tests/acceptance/git-index-safety-preview.md +++ b/tests/acceptance/git-index-safety-preview.md @@ -1,9 +1,11 @@ -# Git index 路径安全预览切片 +# Git index 路径与内容安全预览切片 Rust Core 的 `check_repository_paths` 复现旧插件的敏感文件名、构建产物目录和仓根转储规则,同时保留 `.github/workflows`、嵌套 `scripts/vendor` 和测试 fixture 中 `.db` 的合法情形。它只判断路径政策,文件名命中不等于检测到真实密钥。非规范路径返回错误,不被默认为安全。 Rust CLI 的 `observe_index_safety` 通过受控原生 Git 进程读取仓库根的真实 `ls-files --stage -z --cached`;支持 SHA-1/SHA-256 OID 格式、替代 `GIT_INDEX_FILE`、未合并阶段拒绝和前后两次 index 列表一致性检查。它通过 `git cat-file --batch-check/--batch` 读取暂存 blob,独立重算 Git OID,并记录脱敏的内容 SHA-256、长度和类型;每个对象上限 8 MiB,总长度上限 128 MiB。symlink、gitlink、LFS 指针及超出读取预算的对象保持 unresolved;读取错误时仍保留独立取得的路径违规。暂存后工作树变化不会替代 index 中的字节。 -`codeguard gate pre-commit` 目前只提供此路径与对象预览:JSON/human 报告实际暂存违规和对象核验状态,始终返回 3/incomplete/not_evaluated。公开 schema 为 0.2.0。Git 工具身份、完整质量义务及受保护政策尚未验证;前后列表相同也不是原子 index 快照,不能把没有路径违规或普通 blob 已核验解释为交付通过。 +`codeguard gate pre-commit` 现在提供路径、对象和一项局部内容规则预览:对已核对 OID 的普通 blob 解析完整的未加密 OpenSSH Ed25519 私钥封装,命中时仅输出路径与 `repository_policy.unencrypted_openssh_ed25519_private_key`,不回显原始密钥。它不会把未暂存的工作树字节代入;`.codeguard/` 记录仍在入库安全范围。JSON/human 始终返回 3/incomplete/not_evaluated。公开 schema 升至 0.3.0,0.2.0 版本单独留存。其它密钥类型、Git 工具身份、完整质量义务及受保护政策尚未验证;前后列表相同也不是原子 index 快照,不能把没有违规或普通 blob 已核验解释为交付通过。 目标测试先因 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。完整工作区/CI 结果须以本次 PR 的远端检查为准。 From 862d9bbc1cc08583decf975a7eb7040994cf10d1 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:32:05 +0800 Subject: [PATCH 2/3] test: scope base64 dependency in crate contract --- crates/codeguard-cli/tests/crate_boundaries.rs | 15 +++++++++++++-- .../introduce-rust-codeguard-cli/verification.md | 2 ++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/codeguard-cli/tests/crate_boundaries.rs b/crates/codeguard-cli/tests/crate_boundaries.rs index af292bf5..2bceddff 100644 --- a/crates/codeguard-cli/tests/crate_boundaries.rs +++ b/crates/codeguard-cli/tests/crate_boundaries.rs @@ -4,7 +4,7 @@ use std::process::Command; fn allowed(package: &str, dependency: &str, kind: &str) -> bool { match package { - "codeguard-core" => dependency == "serde", + "codeguard-core" => dependency == "serde" || (dependency == "base64" && kind == "normal"), "codeguard-runtime" => { matches!(dependency, "codeguard-core" | "libc" | "ring") || (kind == "dev" && dependency == "rustls") @@ -30,7 +30,7 @@ fn allowed(package: &str, dependency: &str, kind: &str) -> bool { ) } "codeguard-cli" => { - (matches!(dependency, "ring" | "tokio") && kind == "dev") + (matches!(dependency, "ring" | "tokio" | "base64") && kind == "dev") || (dependency == "http" && kind == "normal") || matches!( dependency, @@ -148,6 +148,9 @@ fn forbidden_edges_stay_forbidden_under_alias_build_and_target_variants() { ("codeguard-cli", "ring", "normal"), ("codeguard-cli", "ring", "build"), ("codeguard-core", "ring", "normal"), + ("codeguard-core", "base64", "dev"), + ("codeguard-core", "base64", "build"), + ("codeguard-cli", "base64", "normal"), ("codeguard-core", "semver", "normal"), ("codeguard-runtime", "semver", "normal"), ("codeguard-cli", "semver", "normal"), @@ -173,6 +176,14 @@ fn semver_parser_is_scoped_to_adapter_normal_dependencies() { assert!(violations(&one_dependency("codeguard-adapters", "semver", "normal")).is_empty()); } +#[test] +fn base64_decoder_is_scoped_to_pure_core_and_cli_test_fixtures() { + assert!(violations(&one_dependency("codeguard-core", "base64", "normal")).is_empty()); + assert!(violations(&one_dependency("codeguard-cli", "base64", "dev")).is_empty()); + assert!(!violations(&one_dependency("codeguard-runtime", "base64", "normal")).is_empty()); + assert!(!violations(&one_dependency("codeguard-adapters", "base64", "normal")).is_empty()); +} + #[test] fn dependency_checker_rejects_missing_metadata() { assert!(!violations(&json!({})).is_empty()); diff --git a/openspec/changes/introduce-rust-codeguard-cli/verification.md b/openspec/changes/introduce-rust-codeguard-cli/verification.md index a66f1129..010dc311 100644 --- a/openspec/changes/introduce-rust-codeguard-cli/verification.md +++ b/openspec/changes/introduce-rust-codeguard-cli/verification.md @@ -2072,3 +2072,5 @@ TDD 先复现报告漏洞:把源码 finding 的主定位、原生身份及白 `repository_content_safety` 新增纯 Rust 的未加密 OpenSSH Ed25519 私钥结构判定:要求成对 PEM 标记、合法 base64、完整 OpenSSH 封装、公私钥字段一致、checkints 和填充格式。`git_index_safety` 只在受控 `git cat-file` 取回并独立核对 OID 的普通暂存 blob 上调用;命中只投影路径与稳定规则 ID,不公开密钥字节。`.codeguard/findings` 与普通文件同样受此入库观察。预览协议升至 0.3.0,0.2.0 schema 留存;命令仍固定退出 3/incomplete/not_evaluated。 目标集成测试先因无内容违规失败,接线后 Core 2/2、真实 Git/CLI 15/15 通过。额外使用本机 `ssh-keygen` 生成的真实 Ed25519 私钥暂存到普通 `notes.md`:首次执行发现合法零填充格式漏报,修正后同一暂存对象命中 `repository_policy.unencrypted_openssh_ed25519_private_key`,且不回显内容。`cargo fmt --all --check`、`git diff --check` 通过。全工作区测试在可用空间降至 801 MiB 时被中断,不作为通过证据;清理仅限本仓 Cargo dev 构建产物后可用空间约 20 GiB。完整 CI 仍待本次变更远端验证。其他私钥格式、签发工具身份、完整交付义务、Git Hook/pre-push/CI 与三宿主真实接入仍缺,3.5/4.6/11.5/12.12 等总体任务不勾选。 + +首轮 [PR #5 CI](https://github.com/full-stack-plugins/codeguard/actions/runs/36517224229) 编译及 Linux WASM 资源边界通过,但全量测试在 crate 依赖契约处失败:白名单未记录新引入的 base64 纯解码依赖。已将其限定为 core 正式依赖及 CLI 测试夹具依赖,补充“其它 crate/依赖种类仍禁止”的反例;待后续 CI 证明整体回归。不能以首轮红灯宣称全工作区通过。 From 9ee236dc505ea441a7fbfc88d9205f23380a22b0 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:36:52 +0800 Subject: [PATCH 3/3] docs: record staged key preview CI acceptance --- openspec/changes/introduce-rust-codeguard-cli/verification.md | 2 +- tests/acceptance/git-index-safety-preview.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/introduce-rust-codeguard-cli/verification.md b/openspec/changes/introduce-rust-codeguard-cli/verification.md index 010dc311..632de952 100644 --- a/openspec/changes/introduce-rust-codeguard-cli/verification.md +++ b/openspec/changes/introduce-rust-codeguard-cli/verification.md @@ -2073,4 +2073,4 @@ TDD 先复现报告漏洞:把源码 finding 的主定位、原生身份及白 目标集成测试先因无内容违规失败,接线后 Core 2/2、真实 Git/CLI 15/15 通过。额外使用本机 `ssh-keygen` 生成的真实 Ed25519 私钥暂存到普通 `notes.md`:首次执行发现合法零填充格式漏报,修正后同一暂存对象命中 `repository_policy.unencrypted_openssh_ed25519_private_key`,且不回显内容。`cargo fmt --all --check`、`git diff --check` 通过。全工作区测试在可用空间降至 801 MiB 时被中断,不作为通过证据;清理仅限本仓 Cargo dev 构建产物后可用空间约 20 GiB。完整 CI 仍待本次变更远端验证。其他私钥格式、签发工具身份、完整交付义务、Git Hook/pre-push/CI 与三宿主真实接入仍缺,3.5/4.6/11.5/12.12 等总体任务不勾选。 -首轮 [PR #5 CI](https://github.com/full-stack-plugins/codeguard/actions/runs/36517224229) 编译及 Linux WASM 资源边界通过,但全量测试在 crate 依赖契约处失败:白名单未记录新引入的 base64 纯解码依赖。已将其限定为 core 正式依赖及 CLI 测试夹具依赖,补充“其它 crate/依赖种类仍禁止”的反例;待后续 CI 证明整体回归。不能以首轮红灯宣称全工作区通过。 +首轮 [PR #5 CI](https://github.com/full-stack-plugins/codeguard/actions/runs/36517224229) 编译及 Linux WASM 资源边界通过,但全量测试在 crate 依赖契约处失败:白名单未记录新引入的 base64 纯解码依赖。已将其限定为 core 正式依赖及 CLI 测试夹具依赖,补充“其它 crate/依赖种类仍禁止”的反例。第二轮 [CI 36517569232](https://github.com/full-stack-plugins/codeguard/actions/runs/36517569232) 的 workspace build、Linux WASM 资源边界与完整 `cargo test --workspace --all-targets` 全部通过;分层脚本也通过。它证明当前 PR 的全量回归,不扩展为多平台或真实宿主门禁验收。 diff --git a/tests/acceptance/git-index-safety-preview.md b/tests/acceptance/git-index-safety-preview.md index c9b4aae4..d84e8804 100644 --- a/tests/acceptance/git-index-safety-preview.md +++ b/tests/acceptance/git-index-safety-preview.md @@ -8,4 +8,4 @@ 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。完整工作区/CI 结果须以本次 PR 的远端检查为准。 +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 测试与全量测试。它仍不证明正式门禁已完成。