OXYGENHUB
# cb7b3cd45dc0 oxygenhub / crates / hub / tests / push_protection.rs
16.6 KiB · Rust · last changed Push protection: refuse a push that adds what looks like a secret 17 days ago source blame history raw
1
//! Push protection (oxygenhub#268, hub spec §15.4): a push adding what
2
//! looks like a secret is refused with the file, line and fingerprint, and
3
//! nothing of it is stored; a maintainer's bypass, or the setting off, lets
4
//! it through; a large push is scanned only up to the budget.
5
//!
6
//! Every credential here is made up, in its documented example format, and
7
//! put together at run time so this file is not itself a finding.
8
9
use axum::body::Body;
10
use axum::http::{header, Request, StatusCode};
11
use http_body_util::BodyExt;
12
use oxy_core::hash::ChangeHash;
13
use oxy_core::repo::Repo;
14
use oxygenhub::auth::random_hex;
15
use oxygenhub::config::Config;
16
use oxygenhub::db;
17
use oxygenhub::push_protection::fingerprint;
18
use oxygenhub::repos::RepoName;
19
use oxygenhub::Shared;
20
use std::path::Path;
21
use tower::ServiceExt;
22
23
fn hermetic() {
24
    static ONCE: std::sync::Once = std::sync::Once::new();
25
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
26
    ONCE.call_once(|| {
27
        let d = tempfile::tempdir().unwrap();
28
        std::env::set_var("OXY_HOME", d.path());
29
        DIR.set(d).ok();
30
    });
31
}
32
33
fn write(root: &Path, rel: &str, content: &[u8]) {
34
    let p = root.join(rel);
35
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
36
    std::fs::write(p, content).unwrap();
37
}
38
39
struct Hub {
40
    app: Shared,
41
    base: String,
42
    _dir: tempfile::TempDir,
43
}
44
45
async fn hub(scan_limit: Option<u64>) -> Hub {
46
    hermetic();
47
    let dir = tempfile::tempdir().unwrap();
48
    let mut config = Config::load(dir.path()).unwrap();
49
    config.registration.setup_code = "test-setup".into();
50
    config.registration.mode = "open".into();
51
    if let Some(n) = scan_limit {
52
        config.limits.scan_limit = oxygenhub::config::ByteSize(n);
53
    }
54
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
55
    let port = listener.local_addr().unwrap().port();
56
    config.server.base_url = format!("http://127.0.0.1:{port}");
57
    let app = oxygenhub::build(config).await.unwrap();
58
    let router = oxygenhub::router(app.clone());
59
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
60
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
61
}
62
63
struct Res {
64
    status: StatusCode,
65
    body: String,
66
    cookie: Option<String>,
67
}
68
69
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
70
    let mut req = Request::builder().method(method).uri(path);
71
    if let Some(c) = cookie {
72
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
73
    }
74
    let body = match form {
75
        Some(f) => {
76
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
77
            Body::from(f.to_string())
78
        }
79
        None => Body::empty(),
80
    };
81
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
82
    let status = res.status();
83
    let cookie = res
84
        .headers()
85
        .get(header::SET_COOKIE)
86
        .and_then(|v| v.to_str().ok())
87
        .and_then(|v| v.split(';').next())
88
        .and_then(|kv| kv.split_once('='))
89
        .map(|(_, v)| v.to_string());
90
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
91
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
92
}
93
94
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
95
    send(app, "GET", path, cookie, None).await
96
}
97
98
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
99
    send(app, "POST", path, cookie, Some(form)).await
100
}
101
102
async fn register(app: &Shared, user: &str) -> String {
103
    let r = post(
104
        app,
105
        "/register",
106
        None,
107
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
108
    )
109
    .await;
110
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
111
    r.cookie.unwrap()
112
}
113
114
fn csrf_of(page: &str) -> String {
115
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
116
    page[i..].split('"').next().unwrap().to_string()
117
}
118
119
/// A personal access token for `user`, marked as an agent's when `agent`
120
/// names one.
121
async fn token(app: &Shared, user: &str, agent: Option<&str>) -> String {
122
    let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap();
123
    let (secret, hash) = oxygenhub::auth::new_token();
124
    let t = db::accounts::create_token(&app.db, me.id, agent.unwrap_or("laptop"), &hash, "repo:read,repo:write", "", None).await.unwrap();
125
    if agent.is_some() {
126
        db::accounts::set_token_agent(&app.db, me.id, t.id, true).await.unwrap();
127
    }
128
    secret
129
}
130
131
/// andy/demo, public, with a client checkout at `work` pushing as `tok`
132
/// and its first change pushed.
133
async fn demo(h: &Hub, work: &Path, tok: &str) -> (Repo, ChangeHash) {
134
    let repo = RepoName::parse("andy/demo").unwrap();
135
    h.app.repos.create(&repo, None).unwrap();
136
    db::repos::create(&h.app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
137
    let mut client = Repo::init(work).unwrap();
138
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
139
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
140
    client.config.set("remote.origin.token", tok).unwrap();
141
    client.save_config().unwrap();
142
    write(work, "README.md", b"one\ntwo\nthree\n");
143
    client.snapshot("t").unwrap();
144
    let base = client.commit("base", &[]).unwrap();
145
    // A push without secrets is unaffected.
146
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
147
    (client, base)
148
}
149
150
fn hosted_heads(app: &Shared) -> Vec<ChangeHash> {
151
    app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().bookmark_heads().unwrap()
152
}
153
154
/// A PEM private key made for this test and thrown away.
155
fn pem_key() -> String {
156
    rcgen::KeyPair::generate().unwrap().serialize_pem()
157
}
158
159
/// What a file should be refused for: (rule, secret, line).
160
type Expected = Vec<(&'static str, String, usize)>;
161
162
/// Each kind of secret: (file, content, what it is refused for).
163
fn samples() -> Vec<(String, String, Expected)> {
164
    let aws_id = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE");
165
    let aws_secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY");
166
    let pem = pem_key();
167
    let gcp_key = pem.replace('\n', "\\n");
168
    let azure_key = format!("{}==", random_hex(43).chars().map(|c| if c == 'a' { 'Z' } else { c }).collect::<String>());
169
    let ghp = format!("{}_{}", "ghp", random_hex(18));
170
    let pat = format!("{}_{}_{}{}", "github_pat", random_hex(11), random_hex(29), "x");
171
    let glpat = format!("{}-{}", "glpat", random_hex(10));
172
    let oxyhub = format!("{}_{}", "oxyhub", random_hex(32));
173
    let grant = format!("{}_{}", "oxyagent", random_hex(32));
174
    let job = format!("{}_{}", "flowjob", random_hex(32));
175
    let slack = format!("{}-{}-{}-{}", "xoxb", "123456789012", "1234567890123", random_hex(12));
176
    let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12));
177
    let ssh_body = (0..4).map(|_| random_hex(30)).collect::<Vec<_>>().join("\n");
178
    let ssh = format!("-----{} OPENSSH PRIVATE KEY-----\n{ssh_body}\n-----{} OPENSSH PRIVATE KEY-----", "BEGIN", "END");
179
    let head = "# settings\n\n";
180
    vec![
181
        (
182
            "aws/credentials".into(),
183
            format!("[default]\naws_access_key_id = {aws_id}\naws_secret_access_key = {aws_secret}\n"),
184
            vec![("aws-access-key-id", aws_id, 2), ("aws-secret-access-key", aws_secret, 3)],
185
        ),
186
        (
187
            "gcp/key.json".into(),
188
            format!(
189
                "{{\n  \"type\": \"service_account\",\n  \"project_id\": \"example\",\n  \"private_key\": \"{gcp_key}\",\n  \"client_email\": \"[email protected]\"\n}}\n"
190
            ),
191
            vec![("gcp-service-account", gcp_key, 4)],
192
        ),
193
        (
194
            "azure.env".into(),
195
            format!("{head}AZURE=DefaultEndpointsProtocol=https;AccountName=example;AccountKey={azure_key};EndpointSuffix=core.windows.net\n"),
196
            vec![("azure-storage-connection-string", azure_key, 3)],
197
        ),
198
        ("gh.env".into(), format!("{head}GH_TOKEN={ghp}\nGH_PAT={pat}\n"), vec![("github-token", ghp, 3), ("github-fine-grained-token", pat, 4)]),
199
        ("gl.env".into(), format!("{head}GITLAB_TOKEN={glpat}\n"), vec![("gitlab-token", glpat, 3)]),
200
        (
201
            "hub.env".into(),
202
            format!("{head}OXY_TOKEN={oxyhub}\nGRANT={grant}\nJOB={job}\n"),
203
            vec![("oxygenhub-token", oxyhub, 3), ("oxygenhub-agent-grant", grant, 4), ("oxygenflow-job-token", job, 5)],
204
        ),
205
        ("slack.env".into(), format!("{head}SLACK={slack}\n"), vec![("slack-token", slack, 3)]),
206
        ("stripe.env".into(), format!("{head}STRIPE={stripe}\n"), vec![("stripe-live-key", stripe, 3)]),
207
        ("keys/tls.key".into(), format!("{head}{pem}"), vec![("private-key", pem.trim_end().to_string(), 3)]),
208
        ("keys/id_ed25519".into(), format!("{head}{ssh}\n"), vec![("openssh-private-key", ssh, 3)]),
209
    ]
210
}
211
212
#[tokio::test(flavor = "multi_thread")]
213
async fn a_push_adding_a_secret_is_refused_until_allowed() {
214
    let h = hub(None).await;
215
    let app = &h.app;
216
    let andy = register(app, "andy").await;
217
    let tok = token(app, "andy", None).await;
218
    let work = tempfile::tempdir().unwrap();
219
    let (mut client, base) = demo(&h, work.path(), &tok).await;
220
    assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection, "on by default");
221
222
    // Each kind, alone, is refused with where it is and its fingerprint,
223
    // the secret itself never shown; nothing is stored or moved.
224
    for (file, content, expect) in samples() {
225
        write(work.path(), &file, content.as_bytes());
226
        client.snapshot("t").unwrap();
227
        let h1 = client.commit(&format!("add {file}"), &[]).unwrap();
228
        let err = client.push("origin", false).err().unwrap_or_else(|| panic!("{file} was accepted")).to_string();
229
        assert!(err.contains("HTTP 422") && err.contains("push protection refused this push"), "{err}");
230
        assert!(err.contains("oxy amend") && err.contains(&format!("{}/andy/demo/settings#push-protection", h.base)), "{err}");
231
        assert!(!err.contains("hint:"), "no credential hint for a refusal: {err}");
232
        for (rule, secret, line) in &expect {
233
            let fp = fingerprint(rule, secret.as_bytes());
234
            assert!(err.contains(&format!("{file}:{line}: ")) && err.contains(&format!("(fingerprint {fp})")), "{rule} in {file}: {err}");
235
            assert!(!err.contains(secret.as_str()), "{rule}: the secret is shown: {err}");
236
        }
237
        assert_eq!(err.matches("(fingerprint ").count(), expect.len(), "{err}");
238
        assert_eq!(hosted_heads(app), vec![base], "nothing moved");
239
        let hosted = app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap();
240
        assert!(hosted.store().get_change(&h1).is_err(), "nothing stored");
241
        client.abandon(&h1).unwrap();
242
        let _ = std::fs::remove_file(work.path().join(&file));
243
        client.snapshot("t").unwrap();
244
    }
245
246
    // A secret inserted into an existing file is placed at its line there.
247
    let ghp = format!("{}_{}", "ghp", random_hex(18));
248
    write(work.path(), "README.md", format!("one\ntwo\n{ghp}\nthree\n").as_bytes());
249
    client.snapshot("t").unwrap();
250
    let edit = client.commit("token in the readme", &[]).unwrap();
251
    let err = client.push("origin", false).err().unwrap().to_string();
252
    let fp = fingerprint("github-token", ghp.as_bytes());
253
    assert!(err.contains(&format!("README.md:3: GitHub token (fingerprint {fp})")), "{err}");
254
    // The refusal is in the push log and among the findings in Settings.
255
    let log = db::repos::pushes(&app.db, 1, 10).await.unwrap();
256
    assert!(
257
        log.iter().any(|p| !p.ok && p.reference == "main" && p.reason.contains("push protection") && p.reason.contains("README.md:3")),
258
        "{log:?}"
259
    );
260
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
261
    assert!(settings.body.contains("id=\"push-protection\"") && settings.body.contains(&fp), "{}", settings.body);
262
    assert!(!settings.body.contains(&ghp), "the settings page shows no secret");
263
264
    // A writer cannot allow it.
265
    let bob = register(app, "bob").await;
266
    let csrf = csrf_of(&settings.body);
267
    let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await;
268
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
269
    let bob_page = get(app, "/andy/demo", Some(&bob)).await;
270
    let bob_csrf = csrf_of(&bob_page.body);
271
    let r = post(app, "/andy/demo/settings/secret-bypass", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={fp}&reason=mine")).await;
272
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
273
    assert!(db::push_protection::allowed(&app.db, 1).await.unwrap().is_empty());
274
275
    // A maintainer allows it, with a reason, audited; the push then lands.
276
    let r = post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=")).await;
277
    assert!(r.body.contains("Say why"), "a reason is needed");
278
    let r =
279
        post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=a+revoked+test+token"))
280
            .await;
281
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
282
    assert!(r.body.contains(&format!("Fingerprint {fp} is allowed")) && r.body.contains("a revoked test token"), "{}", r.body);
283
    let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap();
284
    assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&fp) && a.detail.contains("github-token")), "{audit:?}");
285
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
286
    assert_eq!(hosted_heads(app), vec![edit]);
287
288
    // An agent's push is told what an agent should do.
289
    let agent_tok = token(app, "andy", Some("Claude")).await;
290
    client.config.set("remote.origin.token", &agent_tok).unwrap();
291
    client.save_config().unwrap();
292
    let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12));
293
    write(work.path(), "pay.env", format!("STRIPE={stripe}\n").as_bytes());
294
    client.snapshot("t").unwrap();
295
    client.commit("pay", &[]).unwrap();
296
    let err = client.push("origin", false).err().unwrap().to_string();
297
    assert!(err.contains("an agent's token (Claude)") && err.contains("do not try to get around the refusal"), "{err}");
298
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
299
    assert!(settings.body.contains("via Claude"), "the finding names the agent");
300
301
    // With push protection off, the same push lands; the change is audited.
302
    let csrf = csrf_of(&settings.body);
303
    let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}")).await;
304
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
305
    assert!(!db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection);
306
    let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap();
307
    assert!(audit.iter().any(|a| a.action == "repo.push_protection" && a.detail == "off"), "{audit:?}");
308
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
309
    let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}&protect=on")).await;
310
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
311
    assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection);
312
}
313
314
/// Past the scan budget the rest of a push goes unscanned: it is accepted,
315
/// and its push log entry says how much was not scanned.
316
#[tokio::test(flavor = "multi_thread")]
317
async fn a_push_past_the_scan_budget_is_accepted_and_says_so() {
318
    let h = hub(Some(1024)).await;
319
    let app = &h.app;
320
    register(app, "andy").await;
321
    let tok = token(app, "andy", None).await;
322
    let work = tempfile::tempdir().unwrap();
323
    let (client, _) = demo(&h, work.path(), &tok).await;
324
    let filler: String = (0..64).map(|i| format!("line {i} of a long generated file\n")).collect();
325
    let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12));
326
    write(work.path(), "big.txt", format!("{filler}STRIPE={stripe}\n").as_bytes());
327
    client.snapshot("t").unwrap();
328
    let big = client.commit("big", &[]).unwrap();
329
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
330
    assert!(hosted_heads(app).contains(&big));
331
    let log = db::repos::pushes(&app.db, 1, 10).await.unwrap();
332
    let mut said = false;
333
    for _ in 0..50 {
334
        said = db::repos::pushes(&app.db, 1, 10)
335
            .await
336
            .unwrap()
337
            .iter()
338
            .any(|p| p.ok && p.reason.contains("stopped at its size budget") && p.reason.contains("not scanned"));
339
        if said {
340
            break;
341
        }
342
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
343
    }
344
    assert!(said, "{log:?}");
345
}