OXYGENHUB

Coverage / crates/hub/src/history_scan.rs 91.2%

279 of 306 lines run, 48 of 85 functions · run #503 on main · run missed

1
//! The history scan (hub spec §15.4, oxygenhub#277): push protection's
2
//! rules run over what a repository already holds, so a secret committed
3
//! before push protection existed, or while it was off, is found too.
4
//!
5
//! What is scanned is every change reachable from the repository's
6
//! bookmarks: the lines it inserts and the text blobs it puts in place of
7
//! a file's content, exactly what push protection reads in a push. A
8
//! repository's progress is a row in `secret_scans`: the heads whose
9
//! history is scanned (`done_heads`), and while a scan is under way the
10
//! heads it works toward (`target_heads`) and how far it got (`position`
11
//! in its list of changes: those reachable from the target and not from
12
//! the done heads, in clock order then by hash, so the same list every
13
//! pass). A pass scans within a [`Budget`] and saves where it stopped; the
14
//! next one resumes there. Once the list is done the target becomes the
15
//! done heads, so after a push only the new changes are scanned. A change
16
//! of rules (`version` in `push_protection.toml`) or a maintainer's "Scan
17
//! history now" scans the whole history again; findings are kept one per
18
//! value and file, so a rescan adds nothing already known and keeps what
19
//! was resolved.
20
//!
21
//! The scan is the worker role's `secrets.history_scan` task (docs/roles.md),
22
//! one repository at a time, one pass each in turn, on one blocking thread
23
//! that rests as long as it works: at most half a core, so it never starves
24
//! requests. It reads repositories with the shared lock only, as pages do.
25
//! Repositories with push protection off are not scanned.
26
27
use crate::bus::{Event, Topic};
28
use crate::config::Config;
29
use crate::db::{self, push_protection as pdb, repos::RepoRow};
30
use crate::push_protection::{self as pp, Finding};
31
use crate::repos::{RepoError, RepoName};
32
use crate::Shared;
33
use oxy_core::hash::{ChangeHash, ObjectType};
34
use oxy_core::objects::{Blob, Edit};
35
use oxy_core::protocol::Bundle;
36
use oxy_core::repo::Repo;
37
use std::collections::{BTreeSet, HashMap, HashSet};
38
use std::time::{Duration, Instant};
39
40
/// How much one pass over one repository may scan: `bytes` of text (a
41
/// change counts at least one, however little it adds) and `time`
42
/// wall-clock (resting included), each file or inserted text at most
43
/// `file` bytes. At least one change is scanned per pass, so a pass always
44
/// gets somewhere.
45
#[derive(Clone, Copy, Debug)]
46
pub struct Budget {
47
    pub bytes: u64,
48
    pub time: Duration,
49
    pub file: u64,
50
}
51
52
impl Default for Budget {
53202×
    fn default() -> Self {
54202×
        Budget { bytes: 64 << 20, time: Duration::from_secs(5), file: pp::Budget::default().file }
55202×
    }
56
}
57
58
impl Budget {
59
    /// The hub's budget from `[limits]`; `None` when the scan is off.
60325×
    pub fn of(config: &Config) -> Option<Budget> {
61325×
        let l = &config.limits;
62325×
        (l.history_scan_limit.0 > 0).then(|| Budget {
63314×
            bytes: l.history_scan_limit.0,
64314×
            time: Duration::from_millis(l.history_scan_time_ms),
65314×
            file: l.scan_file_limit.0,
66314×
        })
67325×
    }
68
}
69
70
/// What one pass did.
71
#[derive(Clone, Debug, Default, PartialEq, Eq)]
72
pub struct Pass {
73
    /// Changes and bytes scanned in this pass.
74
    pub changes: usize,
75
    pub bytes: u64,
76
    /// Findings new to this repository.
77
    pub found: usize,
78
    /// Changes still to scan toward the target.
79
    pub left: usize,
80
    /// Nothing to scan: the history up to the bookmarks is scanned.
81
    pub idle: bool,
82
}
83
84
/// How long the worker waits after starting before its first sweep, so a
85
/// starting hub serves first.
86
const START_DELAY: Duration = Duration::from_secs(10);
87
88
/// How long it rests between repositories.
89
const PAUSE: Duration = Duration::from_millis(200);
90
91
/// Every repository is looked at again this often, for the pushes the bus
92
/// did not tell of (it delivers at most once) and bookmarks moved without
93
/// a push (landings).
94
const SWEEP_EVERY: Duration = Duration::from_secs(15 * 60);
95
96
/// The bus topic a "Scan history now" is announced on, its kind the
97
/// repository's id.
98
pub const REQUEST_TOPIC: &str = "secrets:scan";
99
100
/// The worker role's task: every repository first, then those a push, a
101
/// request or a sweep says have something new, each a pass at a time.
102192×
pub async fn worker(app: Shared) {
103192×
    let Some(budget) = Budget::of(&app.config) else {
104
        // Off in `[limits]`: the task holds its lock and does nothing.
1052×
        return std::future::pending().await;
106
    };
107190×
    let mut events = app.bus.subscribe(Topic::Live);
108190×
    tokio::time::sleep(START_DELAY).await;
1096×
    let mut sweep = true;
1106×
    let mut dirty: BTreeSet<i64> = BTreeSet::new();
111
    loop {
11214×
        let mut order = pdb::requested(&app.db).await.unwrap_or_default();
11314×
        if sweep {
1146×
            order.extend(db::repos::list_with_drops(&app.db).await.unwrap_or_default().into_iter().map(|r| r.id));
1156×
            sweep = false;
1168×
        }
11714×
        order.extend(std::mem::take(&mut dirty));
11814×
        let mut seen = HashSet::new();
11914×
        order.retain(|id| seen.insert(*id));
12014×
        for id in order {
12112×
            sweep |= hear(&mut events, &mut dirty);
12212×
            let Ok(Some(row)) = db::repos::get_by_id(&app.db, id).await else { continue };
12312×
            if !row.push_protection {
1241×
                continue;
12511×
            }
12611×
            match pass(&app, &row, budget).await {
12711×
                Ok(p) => {
12811×
                    if p.left > 0 {
1290×
                        dirty.insert(id);
13011×
                    }
13111×
                    if p.found > 0 {
1322×
                        tracing::warn!(
133
                            "history scan: {}/{}: {} possible secret(s) found in history; see Settings › Push protection",
134
                            row.owner,
135
                            row.name,
136
                            p.found
137
                        );
1389×
                    }
139
                }
1400×
                Err(e) => tracing::warn!("history scan: {}/{}: {e:#}", row.owner, row.name),
141
            }
14211×
            tokio::time::sleep(PAUSE).await;
143
        }
14413×
        sweep |= hear(&mut events, &mut dirty);
14513×
        if sweep || !dirty.is_empty() {
1465×
            continue;
1478×
        }
148
        // Nothing left: wait for a push, a request or the next sweep.
1498×
        let wake = tokio::time::sleep(SWEEP_EVERY);
1508×
        tokio::pin!(wake);
151
        loop {
15216×
            tokio::select! {
15316×
                ev = events.recv() => match ev {
15412×
                    Ok(ev) => if let Some(id) = wakes(&ev) { dirty.insert(id); break; },
1550×
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { sweep = true; break; }
1560×
                    Err(tokio::sync::broadcast::error::RecvError::Closed) => { (&mut wake).await; sweep = true; break; }
157
                },
15816×
                _ = &mut wake => { sweep = true; break; }
159
            }
160
        }
1614×
        if !sweep {
162
            // Let a burst of pushes settle into one pass each.
1634×
            tokio::time::sleep(Duration::from_secs(1)).await;
1643×
            hear(&mut events, &mut dirty);
1650×
        }
166
    }
1670×
}
168
169
/// Take the events waiting: the repositories they name go in `dirty`.
170
/// Whether events were lost, so everything should be looked at.
17128×
fn hear(events: &mut tokio::sync::broadcast::Receiver<Event>, dirty: &mut BTreeSet<i64>) -> bool {
172
    use tokio::sync::broadcast::error::TryRecvError;
173
    loop {
17484×
        match events.try_recv() {
17556×
            Ok(ev) => {
17656×
                if let Some(id) = wakes(&ev) {
17724×
                    dirty.insert(id);
17832×
                }
179
            }
1800×
            Err(TryRecvError::Lagged(_)) => return true,
18128×
            Err(_) => return false,
182
        }
183
    }
18428×
}
185
186
/// The repository an event says has something to scan: a push to it
187
/// (`repo:N`, `push`), or a maintainer's request.
18873×
fn wakes(ev: &Event) -> Option<i64> {
18973×
    let Event::Live { topic, kind } = ev else { return None };
19072×
    if topic == REQUEST_TOPIC {
1911×
        return kind.parse().ok();
19271×
    }
19371×
    if kind != "push" {
19441×
        return None;
19530×
    }
19630×
    topic.strip_prefix("repo:")?.parse().ok()
19773×
}
198
199
/// Ask for repository `repo_id`'s whole history to be scanned again now
200
/// ("Scan history now", maintain role).
2011×
pub async fn request(app: &Shared, repo_id: i64) -> sqlx::Result<()> {
2021×
    pdb::request_scan(&app.db, repo_id).await?;
2031×
    wake(app, repo_id);
2041×
    Ok(())
2051×
}
206
207
/// Tell the worker that repository `repo_id` may have history to scan (its
208
/// push protection was turned back on).
2093×
pub fn wake(app: &Shared, repo_id: i64) {
2103×
    app.bus.live(REQUEST_TOPIC, &repo_id.to_string());
2113×
}
212
21320×
fn heads_text(heads: &[ChangeHash]) -> String {
214117×
    heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",")
21520×
}
216
21750×
fn heads_of(text: &str) -> Vec<ChangeHash> {
21850×
    text.split(',').filter_map(ChangeHash::from_hex).collect()
21950×
}
220
221
/// One pass over `row`'s history within `budget`: resume the scan under
222
/// way, or start one toward the bookmarks if they moved past what was
223
/// scanned; record what it finds and where it stopped.
22424×
pub async fn pass(app: &Shared, row: &RepoRow, budget: Budget) -> anyhow::Result<Pass> {
22524×
    let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| anyhow::anyhow!("not a repository name"))?;
22624×
    let loaded = pdb::scan_of(&app.db, row.id).await?.unwrap_or(pdb::ScanRow { repo_id: row.id, ..Default::default() });
22724×
    let mut s = loaded.clone();
22824×
    let version = pp::rules().version as i64;
22924×
    if s.rules_version != version {
2306×
        // New rules: the whole history again.
2316×
        s.rules_version = version;
2326×
        s.done_heads.clear();
2336×
        s.target_heads.clear();
2346×
        s.position = 0;
23518×
    }
23624×
    let from = Progress { done: heads_of(&s.done_heads), target: heads_of(&s.target_heads), position: s.position.max(0) as usize };
23724×
    let default = row.default_bookmark.clone();
23824×
    let started = db::now_ms();
23924×
    let work = app.repos.read(&name, move |repo, _| scan_in(repo, from, &default, budget)).await;
24024×
    let work = match work {
24124×
        Ok(w) => w,
2420×
        Err(e) => {
2430×
            s.error = e.to_string();
2440×
            s.pass_ms = Some(started);
2450×
            save(app, &loaded, &s).await?;
2460×
            return Err(e.into());
247
        }
248
    };
24924×
    if work.idle && s == loaded {
250
        // Up to date, as the row already says: nothing to write.
2515×
        return Ok(Pass { idle: true, ..Default::default() });
25219×
    }
25319×
    let records: Vec<pdb::NewHistoryFinding> = work
25419×
        .findings
25519×
        .iter()
25619×
        .map(|(f, bookmark)| pdb::NewHistoryFinding {
2577×
            finding: pdb::NewFinding {
2587×
                rule: &f.rule,
2597×
                description: &f.description,
2607×
                path: &f.path,
2617×
                line: f.line as i64,
2627×
                fingerprint: &f.fingerprint,
2637×
                excerpt: &f.excerpt,
2647×
            },
2657×
            change_hash: f.change_hash.map(|h| h.to_hex()).unwrap_or_default(),
2667×
            bookmark: bookmark.clone(),
2677×
        })
26819×
        .collect();
26919×
    let found = pdb::record_history(&app.db, row.id, &records).await?;
27019×
    let left = work.pending - work.position;
27119×
    if work.idle || left == 0 {
27214×
        s.done_heads = heads_text(&work.target);
27314×
        s.target_heads.clear();
27414×
        s.position = 0;
27514×
        s.pending = 0;
27614×
        s.requested_ms = None;
27714×
        if !work.idle {
27814×
            s.finished_ms = Some(db::now_ms());
27914×
        }
2805×
    } else {
2815×
        s.target_heads = heads_text(&work.target);
2825×
        s.position = work.position as i64;
2835×
        s.pending = work.pending as i64;
2845×
    }
28519×
    s.changes += work.changes as i64;
28619×
    s.bytes += work.bytes as i64;
28719×
    s.pass_ms = Some(started);
28819×
    s.error.clear();
28919×
    save(app, &loaded, &s).await?;
29019×
    Ok(Pass { changes: work.changes, bytes: work.bytes, found, left, idle: work.idle })
29124×
}
292
293
/// Save `s` unless a maintainer asked for a new scan while the pass ran:
294
/// that request's fresh start wins.
29519×
async fn save(app: &Shared, loaded: &pdb::ScanRow, s: &pdb::ScanRow) -> sqlx::Result<()> {
29619×
    let now = pdb::scan_of(&app.db, s.repo_id).await?;
29719×
    if now.as_ref().and_then(|n| n.requested_ms) != loaded.requested_ms {
2980×
        return Ok(());
29919×
    }
30019×
    pdb::save_scan(&app.db, s).await
30119×
}
302
303
/// Where a scan stands, as read from its row.
304
struct Progress {
305
    done: Vec<ChangeHash>,
306
    target: Vec<ChangeHash>,
307
    position: usize,
308
}
309
310
/// What [`scan_in`] did.
311
struct Work {
312
    /// The heads it scanned toward, and how far it got of how many.
313
    target: Vec<ChangeHash>,
314
    position: usize,
315
    pending: usize,
316
    idle: bool,
317
    changes: usize,
318
    bytes: u64,
319
    /// Each finding, placed, with the bookmark it was found on.
320
    findings: Vec<(Finding, String)>,
321
}
322
323
/// The pass itself, on a blocking thread with the repository open.
32424×
fn scan_in(repo: &Repo, from: Progress, default: &str, budget: Budget) -> Result<Work, RepoError> {
32524×
    let started = Instant::now();
32624×
    let view = repo.view()?;
32724×
    let mut bookmarks: Vec<(String, Vec<ChangeHash>)> = Vec::new();
32859×
    for name in view.bookmarks.keys() {
32959×
        bookmarks.push((name.clone(), repo.heads_of(&view, name)?));
330
    }
331
    // The default bookmark first: a finding is named by the first bookmark
332
    // that reaches it.
333100×
    bookmarks.sort_by_key(|(n, _)| (n != default, n.clone()));
33459×
    let mut current: Vec<ChangeHash> = bookmarks.iter().flat_map(|(_, h)| h.iter().copied()).collect();
33524×
    current.sort();
33624×
    current.dedup();
33724×
    let mut done = from.done;
33824×
    done.sort();
33924×
    let (mut target, mut position) = (from.target, from.position);
34024×
    if target.is_empty() {
34119×
        if current == done {
3425×
            return Ok(Work { target: done, position: 0, pending: 0, idle: true, changes: 0, bytes: 0, findings: vec![] });
34314×
        }
34414×
        target = current.clone();
34514×
        position = 0;
3465×
    }
34719×
    let state = match repo.load_state_history(&target) {
34819×
        Ok(s) => s,
349
        // What the scan worked toward is gone (a bookmark rewritten, then
350
        // collected): start again toward the bookmarks as they are.
3510×
        Err(_) if target != current => {
3520×
            target = current.clone();
3530×
            position = 0;
3540×
            repo.load_state_history(&target)?
355
        }
3560×
        Err(e) => return Err(e.into()),
357
    };
358
    // What is scanned already: the done heads' history.
35950×
    let scanned: HashSet<ChangeHash> = if done.iter().all(|h| state.contains(h)) {
36058×
        closure(&done, |h| state.get(h).map(|m| m.deps.clone()))
361
    } else {
3620×
        match repo.load_state_history(&done) {
3630×
            Ok(old) => old.changes().map(|(h, _)| *h).collect(),
3640×
            Err(_) => HashSet::new(),
365
        }
366
    };
36719×
    let mut list: Vec<(oxy_core::objects::Hlc, ChangeHash)> =
368129×
        state.changes().filter(|(h, _)| !scanned.contains(*h)).map(|(h, m)| (m.hlc, *h)).collect();
36919×
    list.sort();
37019×
    let pending = list.len();
37119×
    if position > pending {
3720×
        position = 0;
37319×
    }
37419×
    let file = pp::Budget { bytes: u64::MAX, file: budget.file, time: Duration::MAX };
37519×
    let mut work = Work { target, position, pending, idle: false, changes: 0, bytes: 0, findings: vec![] };
37619×
    let mut reach: HashMap<String, HashSet<ChangeHash>> = HashMap::new();
37719×
    let mut busy = Duration::ZERO;
37819×
    let mut spent: u64 = 0;
37960×
    while work.position < pending {
38046×
        if work.changes > 0 && (spent >= budget.bytes || started.elapsed() >= budget.time) {
3815×
            break;
38241×
        }
38341×
        let t = Instant::now();
38441×
        let h = list[work.position].1;
38541×
        let bundle = bundle_of(repo, &h, budget.file)?;
38641×
        let scan = pp::scan(&bundle, file);
38741×
        work.bytes += scan.scanned;
38841×
        spent += scan.scanned.max(1);
38941×
        if !scan.findings.is_empty() {
3907×
            let mut findings = scan.findings;
3917×
            pp::locate(repo, &bundle, &mut findings);
3927×
            let on = bookmarks
3937×
                .iter()
3947×
                .find(|(name, heads)| {
39523×
                    reach.entry(name.clone()).or_insert_with(|| closure(heads, |c| state.get(c).map(|m| m.deps.clone()))).contains(&h)
3967×
                })
3977×
                .map(|(name, _)| name.clone())
3987×
                .unwrap_or_default();
3997×
            work.findings.extend(findings.into_iter().map(|f| (f, on.clone())));
40034×
        }
40141×
        work.position += 1;
40241×
        work.changes += 1;
403
        // Rest as long as it worked, so the scan takes at most half a core.
40441×
        busy += t.elapsed();
40541×
        if busy >= Duration::from_millis(20) {
4060×
            std::thread::sleep(busy);
4070×
            busy = Duration::ZERO;
40841×
        }
409
    }
41019×
    Ok(work)
41124×
}
412
413
/// `heads` and every change they depend on, by `deps`.
41425×
fn closure(heads: &[ChangeHash], deps: impl Fn(&ChangeHash) -> Option<Vec<ChangeHash>>) -> HashSet<ChangeHash> {
41525×
    let mut seen: HashSet<ChangeHash> = HashSet::new();
41625×
    let mut stack: Vec<ChangeHash> = heads.to_vec();
417123×
    while let Some(h) = stack.pop() {
41898×
        if !seen.insert(h) {
41917×
            continue;
42081×
        }
42181×
        if let Some(d) = deps(&h) {
42280×
            stack.extend(d.into_iter().filter(|x| !seen.contains(x)));
4231×
        }
424
    }
42525×
    seen
42625×
}
427
428
/// One stored change as push protection reads a push: the change, and the
429
/// blobs (with their chunks, unless over the file budget) its edits put in
430
/// place of a file's content. A blob the store does not hold (lazy) is
431
/// left out, so it is not scanned.
43241×
fn bundle_of(repo: &Repo, h: &ChangeHash, file: u64) -> Result<Bundle, RepoError> {
43341×
    let store = repo.store();
43441×
    let change = store.get_change(h)?;
43541×
    let mut objects = Vec::new();
43646×
    for e in &change.edits {
43741×
        let Edit::Replace { new: Some(r), .. } = e else { continue };
4385×
        let Ok(body) = store.get_typed(r, ObjectType::Blob) else { continue };
4395×
        let Ok(blob) = Blob::decode(&body) else { continue };
4405×
        if blob.length <= file {
4415×
            for c in &blob.chunks {
4425×
                if let Ok(chunk) = store.get_typed(c, ObjectType::Chunk) {
4435×
                    objects.push((ObjectType::Chunk, chunk));
4445×
                }
445
            }
4460×
        }
4475×
        objects.push((ObjectType::Blob, body));
448
    }
44941×
    Ok(Bundle { changes: vec![(change, vec![])], hashes: vec![*h], objects, ..Default::default() })
45041×
}
451
452
#[cfg(test)]
453
mod tests {
454
    use super::*;
455
456
    #[test]
4571×
    fn pushes_and_requests_wake_the_scan() {
4584×
        let live = |topic: &str, kind: &str| Event::Live { topic: topic.into(), kind: kind.into() };
4591×
        assert_eq!(wakes(&live("repo:7", "push")), Some(7));
4601×
        assert_eq!(wakes(&live("repo:7", "issue")), None);
4611×
        assert_eq!(wakes(&live("repo:7/proposal:2", "push")), None);
4621×
        assert_eq!(wakes(&live(REQUEST_TOPIC, "9")), Some(9));
4631×
        assert_eq!(wakes(&Event::DeliveryWaiting), None);
4641×
    }
465
466
    #[test]
4671×
    fn heads_round_trip() {
4681×
        let a = ChangeHash::from_hex(&"ab".repeat(32)).unwrap();
4691×
        let b = ChangeHash::from_hex(&"cd".repeat(32)).unwrap();
4701×
        assert_eq!(heads_of(&heads_text(&[a, b])), [a, b]);
4711×
        assert!(heads_of("").is_empty());
4721×
    }
473
}