OXYGENHUB
◆ main oxygenhub / crates / hub / src / history_scan.rs
18.2 KiB · Rust · last changed Drops: per-user gists, each a small hidden Oxygen repository (#279) 16 days ago source blame history raw
cb7b3cd4 Andy McCall ↶ 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 {
53
    fn default() -> Self {
54
        Budget { bytes: 64 << 20, time: Duration::from_secs(5), file: pp::Budget::default().file }
55
    }
56
}
57
58
impl Budget {
59
    /// The hub's budget from `[limits]`; `None` when the scan is off.
60
    pub fn of(config: &Config) -> Option<Budget> {
61
        let l = &config.limits;
62
        (l.history_scan_limit.0 > 0).then(|| Budget {
63
            bytes: l.history_scan_limit.0,
64
            time: Duration::from_millis(l.history_scan_time_ms),
65
            file: l.scan_file_limit.0,
66
        })
67
    }
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.
102
pub async fn worker(app: Shared) {
103
    let Some(budget) = Budget::of(&app.config) else {
104
        // Off in `[limits]`: the task holds its lock and does nothing.
105
        return std::future::pending().await;
106
    };
107
    let mut events = app.bus.subscribe(Topic::Live);
108
    tokio::time::sleep(START_DELAY).await;
109
    let mut sweep = true;
110
    let mut dirty: BTreeSet<i64> = BTreeSet::new();
111
    loop {
112
        let mut order = pdb::requested(&app.db).await.unwrap_or_default();
113
        if sweep {
166f6e9b Andy McCall 114
            order.extend(db::repos::list_with_drops(&app.db).await.unwrap_or_default().into_iter().map(|r| r.id));
cb7b3cd4 Andy McCall ↶ 115
            sweep = false;
116
        }
117
        order.extend(std::mem::take(&mut dirty));
118
        let mut seen = HashSet::new();
119
        order.retain(|id| seen.insert(*id));
120
        for id in order {
121
            sweep |= hear(&mut events, &mut dirty);
122
            let Ok(Some(row)) = db::repos::get_by_id(&app.db, id).await else { continue };
123
            if !row.push_protection {
124
                continue;
125
            }
126
            match pass(&app, &row, budget).await {
127
                Ok(p) => {
128
                    if p.left > 0 {
129
                        dirty.insert(id);
130
                    }
131
                    if p.found > 0 {
132
                        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
                        );
138
                    }
139
                }
140
                Err(e) => tracing::warn!("history scan: {}/{}: {e:#}", row.owner, row.name),
141
            }
142
            tokio::time::sleep(PAUSE).await;
143
        }
144
        sweep |= hear(&mut events, &mut dirty);
145
        if sweep || !dirty.is_empty() {
146
            continue;
147
        }
148
        // Nothing left: wait for a push, a request or the next sweep.
149
        let wake = tokio::time::sleep(SWEEP_EVERY);
150
        tokio::pin!(wake);
151
        loop {
152
            tokio::select! {
153
                ev = events.recv() => match ev {
154
                    Ok(ev) => if let Some(id) = wakes(&ev) { dirty.insert(id); break; },
155
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { sweep = true; break; }
156
                    Err(tokio::sync::broadcast::error::RecvError::Closed) => { (&mut wake).await; sweep = true; break; }
157
                },
158
                _ = &mut wake => { sweep = true; break; }
159
            }
160
        }
161
        if !sweep {
162
            // Let a burst of pushes settle into one pass each.
163
            tokio::time::sleep(Duration::from_secs(1)).await;
164
            hear(&mut events, &mut dirty);
165
        }
166
    }
167
}
168
169
/// Take the events waiting: the repositories they name go in `dirty`.
170
/// Whether events were lost, so everything should be looked at.
171
fn hear(events: &mut tokio::sync::broadcast::Receiver<Event>, dirty: &mut BTreeSet<i64>) -> bool {
172
    use tokio::sync::broadcast::error::TryRecvError;
173
    loop {
174
        match events.try_recv() {
175
            Ok(ev) => {
176
                if let Some(id) = wakes(&ev) {
177
                    dirty.insert(id);
178
                }
179
            }
180
            Err(TryRecvError::Lagged(_)) => return true,
181
            Err(_) => return false,
182
        }
183
    }
184
}
185
186
/// The repository an event says has something to scan: a push to it
187
/// (`repo:N`, `push`), or a maintainer's request.
188
fn wakes(ev: &Event) -> Option<i64> {
189
    let Event::Live { topic, kind } = ev else { return None };
190
    if topic == REQUEST_TOPIC {
191
        return kind.parse().ok();
192
    }
193
    if kind != "push" {
194
        return None;
195
    }
196
    topic.strip_prefix("repo:")?.parse().ok()
197
}
198
199
/// Ask for repository `repo_id`'s whole history to be scanned again now
200
/// ("Scan history now", maintain role).
201
pub async fn request(app: &Shared, repo_id: i64) -> sqlx::Result<()> {
202
    pdb::request_scan(&app.db, repo_id).await?;
203
    wake(app, repo_id);
204
    Ok(())
205
}
206
207
/// Tell the worker that repository `repo_id` may have history to scan (its
208
/// push protection was turned back on).
209
pub fn wake(app: &Shared, repo_id: i64) {
210
    app.bus.live(REQUEST_TOPIC, &repo_id.to_string());
211
}
212
213
fn heads_text(heads: &[ChangeHash]) -> String {
214
    heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",")
215
}
216
217
fn heads_of(text: &str) -> Vec<ChangeHash> {
218
    text.split(',').filter_map(ChangeHash::from_hex).collect()
219
}
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.
224
pub async fn pass(app: &Shared, row: &RepoRow, budget: Budget) -> anyhow::Result<Pass> {
225
    let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| anyhow::anyhow!("not a repository name"))?;
226
    let loaded = pdb::scan_of(&app.db, row.id).await?.unwrap_or(pdb::ScanRow { repo_id: row.id, ..Default::default() });
227
    let mut s = loaded.clone();
228
    let version = pp::rules().version as i64;
229
    if s.rules_version != version {
230
        // New rules: the whole history again.
231
        s.rules_version = version;
232
        s.done_heads.clear();
233
        s.target_heads.clear();
234
        s.position = 0;
235
    }
236
    let from = Progress { done: heads_of(&s.done_heads), target: heads_of(&s.target_heads), position: s.position.max(0) as usize };
237
    let default = row.default_bookmark.clone();
238
    let started = db::now_ms();
239
    let work = app.repos.read(&name, move |repo, _| scan_in(repo, from, &default, budget)).await;
240
    let work = match work {
241
        Ok(w) => w,
242
        Err(e) => {
243
            s.error = e.to_string();
244
            s.pass_ms = Some(started);
245
            save(app, &loaded, &s).await?;
246
            return Err(e.into());
247
        }
248
    };
249
    if work.idle && s == loaded {
250
        // Up to date, as the row already says: nothing to write.
251
        return Ok(Pass { idle: true, ..Default::default() });
252
    }
253
    let records: Vec<pdb::NewHistoryFinding> = work
254
        .findings
255
        .iter()
256
        .map(|(f, bookmark)| pdb::NewHistoryFinding {
257
            finding: pdb::NewFinding {
258
                rule: &f.rule,
259
                description: &f.description,
260
                path: &f.path,
261
                line: f.line as i64,
262
                fingerprint: &f.fingerprint,
263
                excerpt: &f.excerpt,
264
            },
265
            change_hash: f.change_hash.map(|h| h.to_hex()).unwrap_or_default(),
266
            bookmark: bookmark.clone(),
267
        })
268
        .collect();
269
    let found = pdb::record_history(&app.db, row.id, &records).await?;
270
    let left = work.pending - work.position;
271
    if work.idle || left == 0 {
272
        s.done_heads = heads_text(&work.target);
273
        s.target_heads.clear();
274
        s.position = 0;
275
        s.pending = 0;
276
        s.requested_ms = None;
277
        if !work.idle {
278
            s.finished_ms = Some(db::now_ms());
279
        }
280
    } else {
281
        s.target_heads = heads_text(&work.target);
282
        s.position = work.position as i64;
283
        s.pending = work.pending as i64;
284
    }
285
    s.changes += work.changes as i64;
286
    s.bytes += work.bytes as i64;
287
    s.pass_ms = Some(started);
288
    s.error.clear();
289
    save(app, &loaded, &s).await?;
290
    Ok(Pass { changes: work.changes, bytes: work.bytes, found, left, idle: work.idle })
291
}
292
293
/// Save `s` unless a maintainer asked for a new scan while the pass ran:
294
/// that request's fresh start wins.
295
async fn save(app: &Shared, loaded: &pdb::ScanRow, s: &pdb::ScanRow) -> sqlx::Result<()> {
296
    let now = pdb::scan_of(&app.db, s.repo_id).await?;
297
    if now.as_ref().and_then(|n| n.requested_ms) != loaded.requested_ms {
298
        return Ok(());
299
    }
300
    pdb::save_scan(&app.db, s).await
301
}
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.
324
fn scan_in(repo: &Repo, from: Progress, default: &str, budget: Budget) -> Result<Work, RepoError> {
325
    let started = Instant::now();
326
    let view = repo.view()?;
327
    let mut bookmarks: Vec<(String, Vec<ChangeHash>)> = Vec::new();
328
    for name in view.bookmarks.keys() {
329
        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.
333
    bookmarks.sort_by_key(|(n, _)| (n != default, n.clone()));
334
    let mut current: Vec<ChangeHash> = bookmarks.iter().flat_map(|(_, h)| h.iter().copied()).collect();
335
    current.sort();
336
    current.dedup();
337
    let mut done = from.done;
338
    done.sort();
339
    let (mut target, mut position) = (from.target, from.position);
340
    if target.is_empty() {
341
        if current == done {
342
            return Ok(Work { target: done, position: 0, pending: 0, idle: true, changes: 0, bytes: 0, findings: vec![] });
343
        }
344
        target = current.clone();
345
        position = 0;
346
    }
347
    let state = match repo.load_state_history(&target) {
348
        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.
351
        Err(_) if target != current => {
352
            target = current.clone();
353
            position = 0;
354
            repo.load_state_history(&target)?
355
        }
356
        Err(e) => return Err(e.into()),
357
    };
358
    // What is scanned already: the done heads' history.
359
    let scanned: HashSet<ChangeHash> = if done.iter().all(|h| state.contains(h)) {
360
        closure(&done, |h| state.get(h).map(|m| m.deps.clone()))
361
    } else {
362
        match repo.load_state_history(&done) {
363
            Ok(old) => old.changes().map(|(h, _)| *h).collect(),
364
            Err(_) => HashSet::new(),
365
        }
366
    };
367
    let mut list: Vec<(oxy_core::objects::Hlc, ChangeHash)> =
368
        state.changes().filter(|(h, _)| !scanned.contains(*h)).map(|(h, m)| (m.hlc, *h)).collect();
369
    list.sort();
370
    let pending = list.len();
371
    if position > pending {
372
        position = 0;
373
    }
374
    let file = pp::Budget { bytes: u64::MAX, file: budget.file, time: Duration::MAX };
375
    let mut work = Work { target, position, pending, idle: false, changes: 0, bytes: 0, findings: vec![] };
376
    let mut reach: HashMap<String, HashSet<ChangeHash>> = HashMap::new();
377
    let mut busy = Duration::ZERO;
378
    let mut spent: u64 = 0;
379
    while work.position < pending {
380
        if work.changes > 0 && (spent >= budget.bytes || started.elapsed() >= budget.time) {
381
            break;
382
        }
383
        let t = Instant::now();
384
        let h = list[work.position].1;
385
        let bundle = bundle_of(repo, &h, budget.file)?;
386
        let scan = pp::scan(&bundle, file);
387
        work.bytes += scan.scanned;
388
        spent += scan.scanned.max(1);
389
        if !scan.findings.is_empty() {
390
            let mut findings = scan.findings;
391
            pp::locate(repo, &bundle, &mut findings);
392
            let on = bookmarks
393
                .iter()
394
                .find(|(name, heads)| {
395
                    reach.entry(name.clone()).or_insert_with(|| closure(heads, |c| state.get(c).map(|m| m.deps.clone()))).contains(&h)
396
                })
397
                .map(|(name, _)| name.clone())
398
                .unwrap_or_default();
399
            work.findings.extend(findings.into_iter().map(|f| (f, on.clone())));
400
        }
401
        work.position += 1;
402
        work.changes += 1;
403
        // Rest as long as it worked, so the scan takes at most half a core.
404
        busy += t.elapsed();
405
        if busy >= Duration::from_millis(20) {
406
            std::thread::sleep(busy);
407
            busy = Duration::ZERO;
408
        }
409
    }
410
    Ok(work)
411
}
412
413
/// `heads` and every change they depend on, by `deps`.
414
fn closure(heads: &[ChangeHash], deps: impl Fn(&ChangeHash) -> Option<Vec<ChangeHash>>) -> HashSet<ChangeHash> {
415
    let mut seen: HashSet<ChangeHash> = HashSet::new();
416
    let mut stack: Vec<ChangeHash> = heads.to_vec();
417
    while let Some(h) = stack.pop() {
418
        if !seen.insert(h) {
419
            continue;
420
        }
421
        if let Some(d) = deps(&h) {
422
            stack.extend(d.into_iter().filter(|x| !seen.contains(x)));
423
        }
424
    }
425
    seen
426
}
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.
432
fn bundle_of(repo: &Repo, h: &ChangeHash, file: u64) -> Result<Bundle, RepoError> {
433
    let store = repo.store();
434
    let change = store.get_change(h)?;
435
    let mut objects = Vec::new();
436
    for e in &change.edits {
437
        let Edit::Replace { new: Some(r), .. } = e else { continue };
438
        let Ok(body) = store.get_typed(r, ObjectType::Blob) else { continue };
439
        let Ok(blob) = Blob::decode(&body) else { continue };
440
        if blob.length <= file {
441
            for c in &blob.chunks {
442
                if let Ok(chunk) = store.get_typed(c, ObjectType::Chunk) {
443
                    objects.push((ObjectType::Chunk, chunk));
444
                }
445
            }
446
        }
447
        objects.push((ObjectType::Blob, body));
448
    }
449
    Ok(Bundle { changes: vec![(change, vec![])], hashes: vec![*h], objects, ..Default::default() })
450
}
451
452
#[cfg(test)]
453
mod tests {
454
    use super::*;
455
456
    #[test]
457
    fn pushes_and_requests_wake_the_scan() {
458
        let live = |topic: &str, kind: &str| Event::Live { topic: topic.into(), kind: kind.into() };
459
        assert_eq!(wakes(&live("repo:7", "push")), Some(7));
460
        assert_eq!(wakes(&live("repo:7", "issue")), None);
461
        assert_eq!(wakes(&live("repo:7/proposal:2", "push")), None);
462
        assert_eq!(wakes(&live(REQUEST_TOPIC, "9")), Some(9));
463
        assert_eq!(wakes(&Event::DeliveryWaiting), None);
464
    }
465
466
    #[test]
467
    fn heads_round_trip() {
468
        let a = ChangeHash::from_hex(&"ab".repeat(32)).unwrap();
469
        let b = ChangeHash::from_hex(&"cd".repeat(32)).unwrap();
470
        assert_eq!(heads_of(&heads_text(&[a, b])), [a, b]);
471
        assert!(heads_of("").is_empty());
472
    }
473
}