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