//! Push protection (oxygenhub#268, hub spec ยง15.4): a push adding what //! looks like a secret is refused with the file, line and fingerprint, and //! nothing of it is stored; a maintainer's bypass, or the setting off, lets //! it through; a large push is scanned only up to the budget. //! //! Every credential here is made up, in its documented example format, and //! put together at run time so this file is not itself a finding. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use http_body_util::BodyExt; use oxy_core::hash::ChangeHash; use oxy_core::repo::Repo; use oxygenhub::auth::random_hex; use oxygenhub::config::Config; use oxygenhub::db; use oxygenhub::push_protection::fingerprint; use oxygenhub::repos::RepoName; use oxygenhub::Shared; use std::path::Path; use tower::ServiceExt; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } struct Hub { app: Shared, base: String, _dir: tempfile::TempDir, } async fn hub(scan_limit: Option) -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.registration.setup_code = "test-setup".into(); config.registration.mode = "open".into(); if let Some(n) = scan_limit { config.limits.scan_limit = oxygenhub::config::ByteSize(n); } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } } struct Res { status: StatusCode, body: String, cookie: Option, location: Option, } async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { let mut req = Request::builder().method(method).uri(path); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } let body = match form { Some(f) => { req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); Body::from(f.to_string()) } None => Body::empty(), }; let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); let status = res.status(); let cookie = res .headers() .get(header::SET_COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|v| v.split(';').next()) .and_then(|kv| kv.split_once('=')) .map(|(_, v)| v.to_string()); let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); let bytes = res.into_body().collect().await.unwrap().to_bytes(); Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } } /// A repository settings form's answer (#278): a redirect to the page that /// holds the form, followed. async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res { assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let to = r.location.expect("a redirect"); assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}"); send(app, "GET", &to, cookie, None).await } async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { send(app, "GET", path, cookie, None).await } async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { send(app, "POST", path, cookie, Some(form)).await } async fn register(app: &Shared, user: &str) -> String { let r = post( app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); r.cookie.unwrap() } fn csrf_of(page: &str) -> String { let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); page[i..].split('"').next().unwrap().to_string() } /// A personal access token for `user`, marked as an agent's when `agent` /// names one. async fn token(app: &Shared, user: &str, agent: Option<&str>) -> String { let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap(); let (secret, hash) = oxygenhub::auth::new_token(); let t = db::accounts::create_token(&app.db, me.id, agent.unwrap_or("laptop"), &hash, "repo:read,repo:write", "", None).await.unwrap(); if agent.is_some() { db::accounts::set_token_agent(&app.db, me.id, t.id, true).await.unwrap(); } secret } /// andy/demo, public, with a client checkout at `work` pushing as `tok` /// and its first change pushed. async fn demo(h: &Hub, work: &Path, tok: &str) -> (Repo, ChangeHash) { let repo = RepoName::parse("andy/demo").unwrap(); h.app.repos.create(&repo, None).unwrap(); db::repos::create(&h.app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let mut client = Repo::init(work).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", tok).unwrap(); client.save_config().unwrap(); write(work, "README.md", b"one\ntwo\nthree\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); // A push without secrets is unaffected. assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); (client, base) } fn hosted_heads(app: &Shared) -> Vec { app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().bookmark_heads().unwrap() } /// A PEM private key made for this test and thrown away. fn pem_key() -> String { rcgen::KeyPair::generate().unwrap().serialize_pem() } /// What a file should be refused for: (rule, secret, line). type Expected = Vec<(&'static str, String, usize)>; /// Each kind of secret: (file, content, what it is refused for). fn samples() -> Vec<(String, String, Expected)> { let aws_id = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE"); let aws_secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY"); let pem = pem_key(); let gcp_key = pem.replace('\n', "\\n"); let azure_key = format!("{}==", random_hex(43).chars().map(|c| if c == 'a' { 'Z' } else { c }).collect::()); let ghp = format!("{}_{}", "ghp", random_hex(18)); let pat = format!("{}_{}_{}{}", "github_pat", random_hex(11), random_hex(29), "x"); let glpat = format!("{}-{}", "glpat", random_hex(10)); let oxyhub = format!("{}_{}", "oxyhub", random_hex(32)); let grant = format!("{}_{}", "oxyagent", random_hex(32)); let job = format!("{}_{}", "flowjob", random_hex(32)); let slack = format!("{}-{}-{}-{}", "xoxb", "123456789012", "1234567890123", random_hex(12)); let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12)); let ssh_body = (0..4).map(|_| random_hex(30)).collect::>().join("\n"); let ssh = format!("-----{} OPENSSH PRIVATE KEY-----\n{ssh_body}\n-----{} OPENSSH PRIVATE KEY-----", "BEGIN", "END"); let head = "# settings\n\n"; vec![ ( "aws/credentials".into(), format!("[default]\naws_access_key_id = {aws_id}\naws_secret_access_key = {aws_secret}\n"), vec![("aws-access-key-id", aws_id, 2), ("aws-secret-access-key", aws_secret, 3)], ), ( "gcp/key.json".into(), format!( "{{\n \"type\": \"service_account\",\n \"project_id\": \"example\",\n \"private_key\": \"{gcp_key}\",\n \"client_email\": \"x@example.iam.gserviceaccount.com\"\n}}\n" ), vec![("gcp-service-account", gcp_key, 4)], ), ( "azure.env".into(), format!("{head}AZURE=DefaultEndpointsProtocol=https;AccountName=example;AccountKey={azure_key};EndpointSuffix=core.windows.net\n"), vec![("azure-storage-connection-string", azure_key, 3)], ), ("gh.env".into(), format!("{head}GH_TOKEN={ghp}\nGH_PAT={pat}\n"), vec![("github-token", ghp, 3), ("github-fine-grained-token", pat, 4)]), ("gl.env".into(), format!("{head}GITLAB_TOKEN={glpat}\n"), vec![("gitlab-token", glpat, 3)]), ( "hub.env".into(), format!("{head}OXY_TOKEN={oxyhub}\nGRANT={grant}\nJOB={job}\n"), vec![("oxygenhub-token", oxyhub, 3), ("oxygenhub-agent-grant", grant, 4), ("oxygenflow-job-token", job, 5)], ), ("slack.env".into(), format!("{head}SLACK={slack}\n"), vec![("slack-token", slack, 3)]), ("stripe.env".into(), format!("{head}STRIPE={stripe}\n"), vec![("stripe-live-key", stripe, 3)]), ("keys/tls.key".into(), format!("{head}{pem}"), vec![("private-key", pem.trim_end().to_string(), 3)]), ("keys/id_ed25519".into(), format!("{head}{ssh}\n"), vec![("openssh-private-key", ssh, 3)]), ] } #[tokio::test(flavor = "multi_thread")] async fn a_push_adding_a_secret_is_refused_until_allowed() { let h = hub(None).await; let app = &h.app; let andy = register(app, "andy").await; let tok = token(app, "andy", None).await; let work = tempfile::tempdir().unwrap(); let (mut client, base) = demo(&h, work.path(), &tok).await; assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection, "on by default"); // Each kind, alone, is refused with where it is and its fingerprint, // the secret itself never shown; nothing is stored or moved. for (file, content, expect) in samples() { write(work.path(), &file, content.as_bytes()); client.snapshot("t").unwrap(); let h1 = client.commit(&format!("add {file}"), &[]).unwrap(); let err = client.push("origin", false).err().unwrap_or_else(|| panic!("{file} was accepted")).to_string(); assert!(err.contains("HTTP 422") && err.contains("push protection refused this push"), "{err}"); assert!(err.contains("oxy amend") && err.contains(&format!("{}/andy/demo/settings/push-protection", h.base)), "{err}"); assert!(!err.contains("hint:"), "no credential hint for a refusal: {err}"); for (rule, secret, line) in &expect { let fp = fingerprint(rule, secret.as_bytes()); assert!(err.contains(&format!("{file}:{line}: ")) && err.contains(&format!("(fingerprint {fp})")), "{rule} in {file}: {err}"); assert!(!err.contains(secret.as_str()), "{rule}: the secret is shown: {err}"); } assert_eq!(err.matches("(fingerprint ").count(), expect.len(), "{err}"); assert_eq!(hosted_heads(app), vec![base], "nothing moved"); let hosted = app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap(); assert!(hosted.store().get_change(&h1).is_err(), "nothing stored"); client.abandon(&h1).unwrap(); let _ = std::fs::remove_file(work.path().join(&file)); client.snapshot("t").unwrap(); } // A secret inserted into an existing file is placed at its line there. let ghp = format!("{}_{}", "ghp", random_hex(18)); write(work.path(), "README.md", format!("one\ntwo\n{ghp}\nthree\n").as_bytes()); client.snapshot("t").unwrap(); let edit = client.commit("token in the readme", &[]).unwrap(); let err = client.push("origin", false).err().unwrap().to_string(); let fp = fingerprint("github-token", ghp.as_bytes()); assert!(err.contains(&format!("README.md:3: GitHub token (fingerprint {fp})")), "{err}"); // The refusal is in the push log and among the findings in Settings. let log = db::repos::pushes(&app.db, 1, 10).await.unwrap(); assert!( log.iter().any(|p| !p.ok && p.reference == "main" && p.reason.contains("push protection") && p.reason.contains("README.md:3")), "{log:?}" ); let settings = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await; assert!(settings.body.contains("id=\"push-protection\"") && settings.body.contains(&fp), "{}", settings.body); assert!(!settings.body.contains(&ghp), "the settings page shows no secret"); // A writer cannot allow it. let bob = register(app, "bob").await; let csrf = csrf_of(&settings.body); let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; let r = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await; assert!(r.body.contains("bob now has write access."), "{}", r.body); let bob_page = get(app, "/andy/demo", Some(&bob)).await; let bob_csrf = csrf_of(&bob_page.body); let r = post(app, "/andy/demo/settings/secret-bypass", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={fp}&reason=mine")).await; assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain"); assert!(db::push_protection::allowed(&app.db, 1).await.unwrap().is_empty()); // A maintainer allows it, with a reason, audited; the push then lands. let r = post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=")).await; let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; assert!(r.body.contains("Say why"), "a reason is needed"); let r = post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=a+revoked+test+token")) .await; let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; assert!(r.body.contains(&format!("Fingerprint {fp} is allowed")) && r.body.contains("a revoked test token"), "{}", r.body); let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&fp) && a.detail.contains("github-token")), "{audit:?}"); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); assert_eq!(hosted_heads(app), vec![edit]); // An agent's push is told what an agent should do. let agent_tok = token(app, "andy", Some("Claude")).await; client.config.set("remote.origin.token", &agent_tok).unwrap(); client.save_config().unwrap(); let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12)); write(work.path(), "pay.env", format!("STRIPE={stripe}\n").as_bytes()); client.snapshot("t").unwrap(); client.commit("pay", &[]).unwrap(); let err = client.push("origin", false).err().unwrap().to_string(); assert!(err.contains("an agent's token (Claude)") && err.contains("do not try to get around the refusal"), "{err}"); let settings = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await; assert!(settings.body.contains("via Claude"), "the finding names the agent"); // With push protection off, the same push lands; the change is audited. let csrf = csrf_of(&settings.body); let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}")).await; let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; assert!(r.body.contains("Push protection is off"), "{}", r.body); assert!(!db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection); let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); assert!(audit.iter().any(|a| a.action == "repo.push_protection" && a.detail == "off"), "{audit:?}"); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}&protect=on")).await; let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; assert!(r.body.contains("Push protection is on"), "{}", r.body); assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection); } /// Past the scan budget the rest of a push goes unscanned: it is accepted, /// and its push log entry says how much was not scanned. #[tokio::test(flavor = "multi_thread")] async fn a_push_past_the_scan_budget_is_accepted_and_says_so() { let h = hub(Some(1024)).await; let app = &h.app; register(app, "andy").await; let tok = token(app, "andy", None).await; let work = tempfile::tempdir().unwrap(); let (client, _) = demo(&h, work.path(), &tok).await; let filler: String = (0..64).map(|i| format!("line {i} of a long generated file\n")).collect(); let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12)); write(work.path(), "big.txt", format!("{filler}STRIPE={stripe}\n").as_bytes()); client.snapshot("t").unwrap(); let big = client.commit("big", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); assert!(hosted_heads(app).contains(&big)); let log = db::repos::pushes(&app.db, 1, 10).await.unwrap(); let mut said = false; for _ in 0..50 { said = db::repos::pushes(&app.db, 1, 10) .await .unwrap() .iter() .any(|p| p.ok && p.reason.contains("stopped at its size budget") && p.reason.contains("not scanned")); if said { break; } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } assert!(said, "{log:?}"); }