OXYGENHUB
# 166f6e9bf4f1 oxygenhub / crates / hub / tests / history_scan.rs
20.0 KiB · Rust · last changed Secret scanning: scan existing history, remove an allowed fingerprint 16 days ago source blame history raw
1
//! The history scan (oxygenhub#277, hub spec §15.4): a secret that reached
2
//! a repository before push protection existed, or while it was off, is
3
//! found in its history with the change, file and line; a maintainer
4
//! allows it or marks it resolved; an allowed fingerprint can be removed;
5
//! after a push only the new changes are scanned; a pass stops at its
6
//! budget and the next resumes there; the worker scans every repository
7
//! when it starts and each after a push.
8
//!
9
//! Every credential here is made up and put together at run time, so this
10
//! file is not itself a finding.
11
12
use axum::body::Body;
13
use axum::http::{header, Request, StatusCode};
14
use http_body_util::BodyExt;
15
use oxy_core::hash::ChangeHash;
16
use oxy_core::repo::Repo;
17
use oxygenhub::auth::random_hex;
18
use oxygenhub::config::Config;
19
use oxygenhub::db;
20
use oxygenhub::history_scan::{pass, Budget};
21
use oxygenhub::push_protection::fingerprint;
22
use oxygenhub::repos::RepoName;
23
use oxygenhub::Shared;
24
use std::path::Path;
25
use std::time::Duration;
26
use tower::ServiceExt;
27
28
fn hermetic() {
29
    static ONCE: std::sync::Once = std::sync::Once::new();
30
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
31
    ONCE.call_once(|| {
32
        let d = tempfile::tempdir().unwrap();
33
        std::env::set_var("OXY_HOME", d.path());
34
        DIR.set(d).ok();
35
    });
36
}
37
38
fn write(root: &Path, rel: &str, content: &[u8]) {
39
    let p = root.join(rel);
40
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
41
    std::fs::write(p, content).unwrap();
42
}
43
44
struct Hub {
45
    app: Shared,
46
    base: String,
47
    _dir: tempfile::TempDir,
48
}
49
50
/// A hub; with `worker` false the background scan is off and the tests
51
/// run its passes themselves.
52
async fn hub(worker: bool) -> Hub {
53
    hermetic();
54
    let dir = tempfile::tempdir().unwrap();
55
    let mut config = Config::load(dir.path()).unwrap();
56
    config.registration.setup_code = "test-setup".into();
57
    config.registration.mode = "open".into();
58
    if !worker {
59
        config.limits.history_scan_limit = oxygenhub::config::ByteSize(0);
60
    }
61
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
62
    let port = listener.local_addr().unwrap().port();
63
    config.server.base_url = format!("http://127.0.0.1:{port}");
64
    let app = oxygenhub::build(config).await.unwrap();
65
    let router = oxygenhub::router(app.clone());
66
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
67
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
68
}
69
70
struct Res {
71
    status: StatusCode,
72
    body: String,
73
    cookie: Option<String>,
74
}
75
76
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
77
    let mut req = Request::builder().method(method).uri(path);
78
    if let Some(c) = cookie {
79
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
80
    }
81
    let body = match form {
82
        Some(f) => {
83
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
84
            Body::from(f.to_string())
85
        }
86
        None => Body::empty(),
87
    };
88
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
89
    let status = res.status();
90
    let cookie = res
91
        .headers()
92
        .get(header::SET_COOKIE)
93
        .and_then(|v| v.to_str().ok())
94
        .and_then(|v| v.split(';').next())
95
        .and_then(|kv| kv.split_once('='))
96
        .map(|(_, v)| v.to_string());
97
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
98
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
99
}
100
101
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
102
    send(app, "GET", path, cookie, None).await
103
}
104
105
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
106
    send(app, "POST", path, cookie, Some(form)).await
107
}
108
109
async fn register(app: &Shared, user: &str) -> String {
110
    let r = post(
111
        app,
112
        "/register",
113
        None,
114
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
115
    )
116
    .await;
117
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
118
    r.cookie.unwrap()
119
}
120
121
fn csrf_of(page: &str) -> String {
122
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
123
    page[i..].split('"').next().unwrap().to_string()
124
}
125
126
async fn token(app: &Shared, user: &str) -> String {
127
    let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap();
128
    let (secret, hash) = oxygenhub::auth::new_token();
129
    db::accounts::create_token(&app.db, me.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap();
130
    secret
131
}
132
133
/// andy/demo with a client checkout at `work` and its first change pushed.
134
async fn demo(h: &Hub, work: &Path, tok: &str) -> Repo {
135
    let repo = RepoName::parse("andy/demo").unwrap();
136
    h.app.repos.create(&repo, None).unwrap();
137
    db::repos::create(&h.app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
138
    let mut client = Repo::init(work).unwrap();
139
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
140
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
141
    client.config.set("remote.origin.token", tok).unwrap();
142
    client.save_config().unwrap();
143
    write(work, "README.md", b"one\ntwo\nthree\n");
144
    client.snapshot("t").unwrap();
145
    client.commit("base", &[]).unwrap();
146
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
147
    client
148
}
149
150
async fn row(app: &Shared) -> db::repos::RepoRow {
151
    db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap()
152
}
153
154
/// Commit `content` at `file` and push it with push protection off, as a
155
/// repository's history got its secrets before push protection existed.
156
async fn push_unprotected(app: &Shared, client: &Repo, work: &Path, file: &str, content: &str) -> ChangeHash {
157
    let id = row(app).await.id;
158
    db::push_protection::set_enabled(&app.db, id, false).await.unwrap();
159
    write(work, file, content.as_bytes());
160
    client.snapshot("t").unwrap();
161
    let h = client.commit(&format!("add {file}"), &[]).unwrap();
162
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
163
    db::push_protection::set_enabled(&app.db, id, true).await.unwrap();
164
    h
165
}
166
167
fn stripe() -> String {
168
    format!("{}_{}_{}", "sk", "live", random_hex(12))
169
}
170
171
fn github() -> String {
172
    format!("{}_{}", "ghp", random_hex(18))
173
}
174
175
/// The whole flow: found with change, file and line; allowed, resolved, an
176
/// allowed fingerprint removed; incremental after a push; scanned again
177
/// on request without duplicates.
178
#[tokio::test(flavor = "multi_thread")]
179
async fn a_secret_in_history_is_found_and_judged() {
180
    let h = hub(false).await;
181
    let app = &h.app;
182
    let andy = register(app, "andy").await;
183
    let tok = token(app, "andy").await;
184
    let work = tempfile::tempdir().unwrap();
185
    let client = demo(&h, work.path(), &tok).await;
186
    let key = stripe();
187
    let leak = push_unprotected(app, &client, work.path(), "deploy/pay.env", &format!("# payments\nSTRIPE={key}\n")).await;
188
    let budget = Budget::default();
189
190
    // The first pass scans the whole history and finds it, placed.
191
    let p = pass(app, &row(app).await, budget).await.unwrap();
192
    assert_eq!((p.found, p.left, p.idle), (1, 0, false), "{p:?}");
193
    assert!(p.changes >= 2, "{p:?}");
194
    let all = p.changes;
195
    let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
196
    assert_eq!(found.len(), 1, "{found:?}");
197
    let f = &found[0];
198
    let fp = fingerprint("stripe-live-key", key.as_bytes());
199
    assert_eq!((f.path.as_str(), f.line, f.fingerprint.as_str()), ("deploy/pay.env", 2, fp.as_str()));
200
    assert_eq!((f.change_hash.as_str(), f.bookmark.as_str()), (leak.to_hex().as_str(), "main"));
201
    assert!(!f.excerpt.contains(&key), "{}", f.excerpt);
202
    // Nothing new: the next pass has nothing to do.
203
    let p = pass(app, &row(app).await, budget).await.unwrap();
204
    assert!(p.idle && p.changes == 0, "{p:?}");
205
206
    // Maintainers see it under Found in history, never the secret.
207
    let page = get(app, "/andy/demo/settings", Some(&andy)).await;
208
    assert!(page.body.contains("Found in history") && page.body.contains(&fp), "{}", page.body);
209
    assert!(page.body.contains(&format!("/andy/demo/change/{}", leak.to_hex())) && page.body.contains("deploy/pay.env:2"));
210
    assert!(!page.body.contains(&key), "the settings page shows no secret");
211
    // This hub runs no scan of its own: the page says so.
212
    assert!(page.body.contains("The history scan is off on this hub"), "{}", page.body);
213
    let csrf = csrf_of(&page.body);
214
215
    // A push is scanned incrementally: only its change.
216
    write(work.path(), "README.md", b"one\ntwo\nthree\nfour\n");
217
    client.snapshot("t").unwrap();
218
    client.commit("four", &[]).unwrap();
219
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
220
    let p = pass(app, &row(app).await, budget).await.unwrap();
221
    assert_eq!((p.changes, p.found, p.left), (1, 0, 0), "{p:?}");
222
    let ghp = github();
223
    let second = push_unprotected(app, &client, work.path(), "ci.env", &format!("GH_TOKEN={ghp}\n")).await;
224
    let p = pass(app, &row(app).await, budget).await.unwrap();
225
    assert_eq!((p.changes, p.found), (1, 1), "{p:?}");
226
    let gfp = fingerprint("github-token", ghp.as_bytes());
227
    let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
228
    assert!(
229
        found.iter().any(|f| f.fingerprint == gfp && f.path == "ci.env" && f.line == 1 && f.change_hash == second.to_hex()),
230
        "{found:?}"
231
    );
232
233
    // A writer can neither resolve nor remove an allowed fingerprint.
234
    let bob = register(app, "bob").await;
235
    let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await;
236
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
237
    let bob_csrf = csrf_of(&get(app, "/andy/demo", Some(&bob)).await.body);
238
    let stripe_id = found.iter().find(|f| f.fingerprint == fp).unwrap().id;
239
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&bob), &format!("_csrf={bob_csrf}&finding={stripe_id}&reason=x")).await;
240
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
241
    let r = post(app, "/andy/demo/settings/secret-scan", Some(&bob), &format!("_csrf={bob_csrf}")).await;
242
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
243
244
    // Resolved: the key was revoked. A reason is needed; audited.
245
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=")).await;
246
    assert!(r.body.contains("Say how it was resolved"), "{}", r.body);
247
    let r = post(
248
        app,
249
        "/andy/demo/settings/secret-resolve",
250
        Some(&andy),
251
        &format!("_csrf={csrf}&finding={stripe_id}&reason=revoked+in+the+Stripe+dashboard"),
252
    )
253
    .await;
254
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
255
    assert!(r.body.contains("marked resolved") && r.body.contains("revoked in the Stripe dashboard"), "{}", r.body);
256
    let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
257
    assert!(open.iter().all(|f| f.id != stripe_id), "{open:?}");
258
    let resolved = db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap();
259
    assert!(resolved.iter().any(|f| f.id == stripe_id && f.resolved_by == "andy" && f.resolved_ms.is_some()), "{resolved:?}");
260
    let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=again")).await;
261
    assert!(r.body.contains("not open here"), "only once");
262
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
263
    assert!(
264
        audit.iter().any(|a| a.action == "secret.resolved" && a.detail.contains(&fp) && a.detail.contains("deploy/pay.env:2")),
265
        "{audit:?}"
266
    );
267
268
    // Allowed from its history row (a test token): no longer open.
269
    let r =
270
        post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}&reason=a+test+fixture")).await;
271
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
272
    assert!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().is_empty());
273
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
274
    assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&gfp) && a.detail.contains("github-token")), "{audit:?}");
275
276
    // The allowed fingerprint is removed again: a writer cannot; a
277
    // maintainer can, audited; the finding is open again and a push adding
278
    // that value is refused again.
279
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={gfp}")).await;
280
    assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain");
281
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await;
282
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
283
    assert!(r.body.contains(&format!("Fingerprint {gfp} is no longer allowed")), "{}", r.body);
284
    assert!(db::push_protection::allowed(&app.db, row(app).await.id).await.unwrap().is_empty());
285
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
286
    assert!(
287
        audit.iter().any(|a| a.action == "secret.bypass_removed" && a.detail.contains(&gfp) && a.detail.contains("a test fixture")),
288
        "{audit:?}"
289
    );
290
    let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
291
    assert!(open.iter().any(|f| f.fingerprint == gfp), "{open:?}");
292
    let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await;
293
    assert!(r.body.contains("is not allowed here"), "{}", r.body);
294
    write(work.path(), "ci2.env", format!("GH_TOKEN={ghp}\n").as_bytes());
295
    client.snapshot("t").unwrap();
296
    let again = client.commit("again", &[]).unwrap();
297
    let err = client.push("origin", false).expect_err("refused again").to_string();
298
    assert!(err.contains(&format!("(fingerprint {gfp})")), "{err}");
299
    client.abandon(&again).unwrap();
300
    let _ = std::fs::remove_file(work.path().join("ci2.env"));
301
    client.snapshot("t").unwrap();
302
303
    // Scan history now: the whole history again, audited; nothing found
304
    // twice, and what was resolved stays resolved.
305
    let r = post(app, "/andy/demo/settings/secret-scan", Some(&andy), &format!("_csrf={csrf}")).await;
306
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
307
    assert!(r.body.contains("will be scanned again"), "{}", r.body);
308
    assert!(db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap().requested_ms.is_some());
309
    let p = pass(app, &row(app).await, budget).await.unwrap();
310
    assert_eq!((p.changes, p.found, p.left), (all + 2, 0, 0), "{p:?}");
311
    let s = db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap();
312
    assert!(s.requested_ms.is_none() && s.target_heads.is_empty() && s.finished_ms.is_some(), "{s:?}");
313
    assert_eq!(db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap().len(), 1);
314
    assert_eq!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().len(), 1);
315
    let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap();
316
    assert!(audit.iter().any(|a| a.action == "secret.history_scan"), "{audit:?}");
317
318
    // Findings of refused pushes stay apart from history's.
319
    let recent = db::push_protection::recent(&app.db, row(app).await.id, 50).await.unwrap();
320
    assert!(recent.iter().all(|f| f.fingerprint == gfp && f.path == "ci2.env"), "{recent:?}");
321
}
322
323
/// A pass stops at its budget and the next resumes where it stopped; a push
324
/// in the meantime waits for the scan under way, then is scanned alone.
325
#[tokio::test(flavor = "multi_thread")]
326
async fn the_scan_resumes_where_its_budget_stopped() {
327
    let h = hub(false).await;
328
    let app = &h.app;
329
    register(app, "andy").await;
330
    let tok = token(app, "andy").await;
331
    let work = tempfile::tempdir().unwrap();
332
    let client = demo(&h, work.path(), &tok).await;
333
    let key = stripe();
334
    for i in 0..5 {
335
        let text = if i == 3 { format!("n = {i}\nSTRIPE={key}\n") } else { format!("n = {i}\n") };
336
        push_unprotected(app, &client, work.path(), &format!("f{i}.txt"), &text).await;
337
    }
338
    // A byte: one change a pass.
339
    let budget = Budget { bytes: 1, ..Budget::default() };
340
    let first = pass(app, &row(app).await, budget).await.unwrap();
341
    assert_eq!(first.changes, 1, "{first:?}");
342
    let total = first.left + 1;
343
    assert!(total >= 6, "{first:?}");
344
    let s = db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap();
345
    assert_eq!((s.position, s.pending), (1, total as i64), "{s:?}");
346
    assert!(!s.target_heads.is_empty() && s.done_heads.is_empty(), "{s:?}");
347
    let second = pass(app, &row(app).await, budget).await.unwrap();
348
    assert_eq!((second.changes, second.left), (1, total - 2), "{second:?}");
349
350
    // A push now does not restart the scan under way.
351
    write(work.path(), "late.txt", b"late\n");
352
    client.snapshot("t").unwrap();
353
    client.commit("late", &[]).unwrap();
354
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
355
    let mut found = first.found + second.found;
356
    let mut passes = 2;
357
    loop {
358
        let p = pass(app, &row(app).await, budget).await.unwrap();
359
        assert_eq!(p.changes, 1, "{p:?}");
360
        found += p.found;
361
        passes += 1;
362
        if p.left == 0 {
363
            break;
364
        }
365
        assert!(passes < 50, "the scan never ends");
366
    }
367
    assert_eq!((passes, found), (total, 1), "each change once, the secret once");
368
    // Then the push, alone.
369
    let p = pass(app, &row(app).await, budget).await.unwrap();
370
    assert_eq!((p.changes, p.left, p.found), (1, 0, 0), "{p:?}");
371
    assert!(pass(app, &row(app).await, budget).await.unwrap().idle);
372
    let found = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap();
373
    assert_eq!(found.iter().map(|f| (f.path.as_str(), f.line)).collect::<Vec<_>>(), [("f3.txt", 2)]);
374
}
375
376
/// The worker scans every repository when it starts (as on the first
377
/// deploy) and a repository again after a push, by itself.
378
#[tokio::test(flavor = "multi_thread")]
379
async fn the_worker_scans_on_start_and_after_a_push() {
380
    let h = hub(true).await;
381
    let app = &h.app;
382
    let andy = register(app, "andy").await;
383
    let tok = token(app, "andy").await;
384
    let work = tempfile::tempdir().unwrap();
385
    let client = demo(&h, work.path(), &tok).await;
386
    let key = stripe();
387
    push_unprotected(app, &client, work.path(), "old.env", &format!("STRIPE={key}\n")).await;
388
    let id = row(app).await.id;
389
    let until = |n: usize| async move {
390
        for _ in 0..300 {
391
            if db::push_protection::history(&app.db, id, 50).await.unwrap().len() >= n {
392
                return true;
393
            }
394
            tokio::time::sleep(Duration::from_millis(100)).await;
395
        }
396
        false
397
    };
398
    assert!(until(1).await, "found on the first sweep");
399
    // A push with protection on that adds nothing is scanned by itself;
400
    // one let through (protection off for it) is found once it is on again.
401
    let ghp = github();
402
    push_unprotected(app, &client, work.path(), "new.env", &format!("GH={ghp}\n")).await;
403
    write(work.path(), "README.md", b"one\n");
404
    client.snapshot("t").unwrap();
405
    client.commit("touch", &[]).unwrap();
406
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
407
    assert!(until(2).await, "found after the push");
408
    let found = db::push_protection::history(&app.db, id, 50).await.unwrap();
409
    assert!(found.iter().any(|f| f.path == "new.env" && f.fingerprint == fingerprint("github-token", ghp.as_bytes())), "{found:?}");
410
    for _ in 0..100 {
411
        let s = db::push_protection::scan_of(&app.db, id).await.unwrap().unwrap();
412
        if s.target_heads.is_empty() && s.finished_ms.is_some() {
413
            break;
414
        }
415
        tokio::time::sleep(Duration::from_millis(100)).await;
416
    }
417
    let page = get(app, "/andy/demo/settings", Some(&andy)).await;
418
    assert!(page.body.contains("Scanned up to the bookmarks") && page.body.contains("SCAN HISTORY NOW"), "{}", page.body);
419
}