OXYGENHUB

Secret scanning: scan existing history, remove an allowed fingerprint cb7b3cd45dc0

The worker role's secrets.history_scan task (under its leader lock) runs
push protection's rules over every change reachable from each
repository's bookmarks: inserted lines and replaced text blobs. Progress
per repository in secret_scans (done heads, target heads, position), so a
pass stops at its budget ([limits] history_scan_limit / _time_ms, 0 turns
it off) and the next resumes; after a push only the new changes are
scanned; new rules or "Scan history now" rescan everything. Low priority:
one repository at a time, resting as long as it works, shared lock only.

Findings go into secret_findings with source history, the change and
bookmark (migration 0086, both engines), one per value and file. Settings
› Push protection shows "Found in history" with allow and "Mark resolved"
(reason, audited secret.resolved), "Scan history now" (audited
secret.history_scan), and a remove button per allowed fingerprint
(audited secret.bypass_removed).

backups.rs builds AWS's documented example keys at run time; the rules'
self-check covers it and the new files.

Closes #277
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5dd5040d43a5
When
· 2026-10-20 04:58 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
cb7b3cd45dc092cb7101d98f4cdb8d7b7f6f8b79e4044368dc952513ae14353b
Depends on
0ee5c3fb32eb 19bb31ff1f5c 3b1bc27fe805 6b195ed3b637 7b9f4570daf5 9008bf6c2b8c 93be9ce4653e 9c04c779f032 a7e6e8e6094c a9f1c0bf403a b5668108487f b64e2537d702 d23464c0ac60 d3b048b27277 d74ce22fa80e d7d182467382 e57765dee61a
Depended on by
40a43921ef42 439ecd8708a1 dd327d76062d 166f6e9bf4f1 c7067c7bda56 ea55fb62f919
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
48 atoms, 4 tree operations, 16 files

Browse the tree at this change

added crates/hub/migrations-pg/0086_secret_history_scan.sql +27 −0
1 +
-- Secret scanning, part 2 (oxygenhub#277): findings from the background
2 +
-- scan of each repository's history beside those of refused pushes, with
3 +
-- the change and bookmark they were found in and a maintainer's mark that
4 +
-- the secret was revoked; and each repository's scan progress, so a pass
5 +
-- resumes where the last one stopped and a push is scanned incrementally.
6 +
ALTER TABLE secret_findings ADD COLUMN source TEXT NOT NULL DEFAULT 'push';
7 +
ALTER TABLE secret_findings ADD COLUMN change_hash TEXT NOT NULL DEFAULT '';
8 +
ALTER TABLE secret_findings ADD COLUMN bookmark TEXT NOT NULL DEFAULT '';
9 +
ALTER TABLE secret_findings ADD COLUMN resolved_ms BIGINT;
10 +
ALTER TABLE secret_findings ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
11 +
ALTER TABLE secret_findings ADD COLUMN resolved_reason TEXT NOT NULL DEFAULT '';
12 +
-- One history finding per value and file, however many changes carry it.
13 +
CREATE UNIQUE INDEX secret_findings_history ON secret_findings(repo_id, fingerprint, path) WHERE source = 'history';
14 +
CREATE TABLE secret_scans (
15 +
    repo_id       BIGINT PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
16 +
    rules_version BIGINT NOT NULL DEFAULT 0,
17 +
    done_heads    TEXT NOT NULL DEFAULT '',
18 +
    target_heads  TEXT NOT NULL DEFAULT '',
19 +
    position      BIGINT NOT NULL DEFAULT 0,
20 +
    pending       BIGINT NOT NULL DEFAULT 0,
21 +
    changes       BIGINT NOT NULL DEFAULT 0,
22 +
    bytes         BIGINT NOT NULL DEFAULT 0,
23 +
    requested_ms  BIGINT,
24 +
    pass_ms       BIGINT,
25 +
    finished_ms   BIGINT,
26 +
    error         TEXT NOT NULL DEFAULT ''
27 +
);
added crates/hub/migrations/0086_secret_history_scan.sql +27 −0
1 +
-- Secret scanning, part 2 (oxygenhub#277): findings from the background
2 +
-- scan of each repository's history beside those of refused pushes, with
3 +
-- the change and bookmark they were found in and a maintainer's mark that
4 +
-- the secret was revoked; and each repository's scan progress, so a pass
5 +
-- resumes where the last one stopped and a push is scanned incrementally.
6 +
ALTER TABLE secret_findings ADD COLUMN source TEXT NOT NULL DEFAULT 'push';
7 +
ALTER TABLE secret_findings ADD COLUMN change_hash TEXT NOT NULL DEFAULT '';
8 +
ALTER TABLE secret_findings ADD COLUMN bookmark TEXT NOT NULL DEFAULT '';
9 +
ALTER TABLE secret_findings ADD COLUMN resolved_ms INTEGER;
10 +
ALTER TABLE secret_findings ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
11 +
ALTER TABLE secret_findings ADD COLUMN resolved_reason TEXT NOT NULL DEFAULT '';
12 +
-- One history finding per value and file, however many changes carry it.
13 +
CREATE UNIQUE INDEX secret_findings_history ON secret_findings(repo_id, fingerprint, path) WHERE source = 'history';
14 +
CREATE TABLE secret_scans (
15 +
    repo_id       INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
16 +
    rules_version INTEGER NOT NULL DEFAULT 0,
17 +
    done_heads    TEXT NOT NULL DEFAULT '',
18 +
    target_heads  TEXT NOT NULL DEFAULT '',
19 +
    position      INTEGER NOT NULL DEFAULT 0,
20 +
    pending       INTEGER NOT NULL DEFAULT 0,
21 +
    changes       INTEGER NOT NULL DEFAULT 0,
22 +
    bytes         INTEGER NOT NULL DEFAULT 0,
23 +
    requested_ms  INTEGER,
24 +
    pass_ms       INTEGER,
25 +
    finished_ms   INTEGER,
26 +
    error         TEXT NOT NULL DEFAULT ''
27 +
);
added crates/hub/src/history_scan.rs +473 −0
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 {
114 +
            order.extend(db::repos::list(&app.db).await.unwrap_or_default().into_iter().map(|r| r.id));
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 +
}
added crates/hub/tests/history_scan.rs +419 −0
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 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Secret scanning finds what is already in history (#277):** push protection's rules now also run over each repository's existing history in the background (every change reachable from its bookmarks: the lines it inserts and the text files it replaces), so a credential committed before push protection existed, or while it was off, is found too. Every repository's whole history is scanned once after this deploy, then only what each push adds, a budget at a time (`[limits] history_scan_limit`, 64 MiB, and `history_scan_time_ms`, 5 s, per repository per pass; `0` turns it off), at low priority, resuming where it stopped. Maintainers see the findings, with the change, bookmark, file and line, under Settings › Push protection › Found in history, and either allow one (not a secret) or mark it resolved with how it was revoked (audited as `secret.resolved`); "Scan history now" scans the whole history again. An allowed fingerprint can now be removed (audited as `secret.bypass_removed`), so pushes adding that value are refused again. Repositories with push protection off are not scanned.
8 +
7 9
## 0.6.0 (2026-10-20)
8 10
9 11
- **An event bus between the hub's processes (#273):** on PostgreSQL, what one process publishes (a push, a check, a run's jobs, a comment, a waiting webhook delivery, a choice in the conflict resolver) reaches every other process over `LISTEN/NOTIFY`, so live pages on any web process see a push made through another, and a separate worker delivers a push's webhooks at once instead of on its once-a-minute pass. Nothing to configure; on SQLite the bus stays in process. Events say "look now" and are delivered at most once; a process that loses its listening connection listens again on a new one and logs that events may have been missed. A live page that reconnects with an event id its process never gave (another web process's, or one from before a restart) is now told to reload instead of waiting for newer ids. In the conflict resolver, choices and landings cross processes; who is present stays per process. See docs/roles.md.
…
252 unchanged lines
modified crates/hub/src/backups.rs +12 −15
…
1923 unchanged lines
1924 1924
    #[test]
1925 1925
    fn requests_are_signed_as_aws_documents() {
1926 1926
        // AWS's own example (S3 API reference, "Signature calculations":
1927 -
        // GET Object), with its published signature.
1927 +
        // GET Object), with its published signature. Its documented example
1928 +
        // keys are put together at run time, so this file is not itself a
1929 +
        // push protection or history scan finding (#277).
1930 +
        let key_id = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE");
1931 +
        let secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY");
1928 1932
        let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1929 1933
        let headers = vec![
1930 1934
            ("Host".to_string(), "examplebucket.s3.amazonaws.com".to_string()),
…
1 unchanged lines
1932 1936
            ("x-amz-content-sha256".to_string(), empty.to_string()),
1933 1937
            ("x-amz-date".to_string(), "20130524T000000Z".to_string()),
1934 1938
        ];
1935 -
        let auth = sigv4(
1936 -
            "GET",
1937 -
            "/test.txt",
1938 -
            &[],
1939 -
            &headers,
1940 -
            empty,
1941 -
            ("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
1942 -
            "us-east-1",
1943 -
            "s3",
1944 -
            "20130524T000000Z",
1945 -
        );
1939 +
        let auth =
1940 +
            sigv4("GET", "/test.txt", &[], &headers, empty, (key_id.as_str(), secret.as_str()), "us-east-1", "s3", "20130524T000000Z");
1946 1941
        assert_eq!(
1947 1942
            auth,
1948 -
            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, \
1949 -
             SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \
1950 -
             Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
1943 +
            format!(
1944 +
                "AWS4-HMAC-SHA256 Credential={key_id}/20130524/us-east-1/s3/aws4_request, \
1945 +
                 SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \
1946 +
                 Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
1947 +
            )
1951 1948
        );
1952 1949
        assert_eq!(aws_encode("runs/2026 x+y", true), "runs/2026%20x%2By");
1953 1950
        assert_eq!(aws_encode("a/b", false), "a%2Fb");
…
9 unchanged lines
modified crates/hub/src/config.rs +8 −0
…
354 unchanged lines
355 355
    pub scan_limit: ByteSize,
356 356
    pub scan_file_limit: ByteSize,
357 357
    pub scan_time_ms: u64,
358 +
    /// The history scan's budget (oxygenhub#277): what one pass over one
359 +
    /// repository may scan, and for how long, before it moves on to the
360 +
    /// next and resumes there later. A limit of 0 turns the scan off.
361 +
    pub history_scan_limit: ByteSize,
362 +
    pub history_scan_time_ms: u64,
358 363
}
359 364
360 365
#[derive(Clone, Debug, Deserialize)]
…
75 unchanged lines
436 441
impl Default for Limits {
437 442
    fn default() -> Self {
438 443
        let scan = crate::push_protection::Budget::default();
444 +
        let history = crate::history_scan::Budget::default();
439 445
        Limits {
440 446
            render_limit: ByteSize(2 << 20),
441 447
            asset_limit: ByteSize(512 << 20),
…
1 unchanged lines
443 449
            scan_limit: ByteSize(scan.bytes),
444 450
            scan_file_limit: ByteSize(scan.file),
445 451
            scan_time_ms: scan.time.as_millis() as u64,
452 +
            history_scan_limit: ByteSize(history.bytes),
453 +
            history_scan_time_ms: history.time.as_millis() as u64,
446 454
        }
447 455
    }
448 456
}
…
212 unchanged lines
modified crates/hub/src/db/push_protection.rs +210 −4
1 1
//! Push protection's records (migration 0085, oxygenhub#268): fingerprints
2 -
//! a maintainer allowed, and the findings of recent refusals.
2 +
//! a maintainer allowed, and the findings of recent refusals; and the
3 +
//! history scan's (migration 0086, oxygenhub#277): its findings, marked
4 +
//! resolved once the secret was revoked, and each repository's progress.
3 5
4 6
use super::{now_ms, Pool};
5 7
use sqlx::FromRow;
6 8
7 -
/// Findings kept per repository: the newest, for maintainers to judge.
9 +
/// Refused pushes' findings kept per repository: the newest, for
10 +
/// maintainers to judge. The history scan's are all kept (one per value
11 +
/// and file).
8 12
const KEPT_FINDINGS: i64 = 200;
9 13
14 +
/// A finding's source: a refused push, or the scan of history.
15 +
pub const PUSH: &str = "push";
16 +
pub const HISTORY: &str = "history";
17 +
10 18
#[derive(Clone, Debug, FromRow)]
11 19
pub struct Bypass {
12 20
    pub id: i64,
…
10 unchanged lines
23 31
    pub at_ms: i64,
24 32
    pub pusher: String,
25 33
    pub via: String,
34 +
    pub rule: String,
35 +
    pub description: String,
36 +
    pub path: String,
37 +
    pub line: i64,
38 +
    pub fingerprint: String,
39 +
    pub excerpt: String,
40 +
}
41 +
42 +
/// A finding of the history scan (#277): where it is, and whether a
43 +
/// maintainer marked it resolved.
44 +
#[derive(Clone, Debug, FromRow)]
45 +
pub struct HistoryRow {
46 +
    pub id: i64,
47 +
    pub at_ms: i64,
26 48
    pub rule: String,
27 49
    pub description: String,
28 50
    pub path: String,
29 51
    pub line: i64,
30 52
    pub fingerprint: String,
31 53
    pub excerpt: String,
54 +
    pub change_hash: String,
55 +
    pub bookmark: String,
56 +
    pub resolved_ms: Option<i64>,
57 +
    pub resolved_by: String,
58 +
    pub resolved_reason: String,
32 59
}
33 60
34 61
/// A finding to record.
…
58 unchanged lines
93 120
        .await?;
94 121
    }
95 122
    sqlx::query(
96 -
        "DELETE FROM secret_findings WHERE repo_id = $1 AND id NOT IN (SELECT id FROM secret_findings WHERE repo_id = $2 ORDER BY id DESC LIMIT $3)",
123 +
        "DELETE FROM secret_findings WHERE repo_id = $1 AND source = 'push' \
124 +
         AND id NOT IN (SELECT id FROM secret_findings WHERE repo_id = $2 AND source = 'push' ORDER BY id DESC LIMIT $3)",
97 125
    )
98 126
    .bind(repo_id)
99 127
    .bind(repo_id)
…
7 unchanged lines
107 135
pub async fn recent(pool: &Pool, repo_id: i64, limit: i64) -> sqlx::Result<Vec<FindingRow>> {
108 136
    sqlx::query_as(
109 137
        "SELECT id, at_ms, pusher, via, rule, description, path, line, fingerprint, excerpt FROM secret_findings f \
110 -
         WHERE repo_id = $1 AND NOT EXISTS (SELECT 1 FROM secret_bypasses b WHERE b.repo_id = f.repo_id AND b.fingerprint = f.fingerprint) \
138 +
         WHERE repo_id = $1 AND source = 'push' \
139 +
         AND NOT EXISTS (SELECT 1 FROM secret_bypasses b WHERE b.repo_id = f.repo_id AND b.fingerprint = f.fingerprint) \
111 140
         ORDER BY id DESC LIMIT $2",
112 141
    )
113 142
    .bind(repo_id)
…
14 unchanged lines
128 157
/// Whether pushes adding possible secrets are refused here.
129 158
pub async fn set_enabled(pool: &Pool, repo_id: i64, on: bool) -> sqlx::Result<()> {
130 159
    sqlx::query("UPDATE repositories SET push_protection = $1 WHERE id = $2").bind(on).bind(repo_id).execute(pool).await?;
160 +
    Ok(())
161 +
}
162 +
163 +
/// Remove an allowed fingerprint (#277): pushes adding that value are
164 +
/// refused again. The row removed, if there was one.
165 +
pub async fn remove_bypass(pool: &Pool, repo_id: i64, fingerprint: &str) -> sqlx::Result<Option<Bypass>> {
166 +
    let row: Option<Bypass> = sqlx::query_as(
167 +
        "SELECT id, fingerprint, rule, reason, by_name, created_ms FROM secret_bypasses WHERE repo_id = $1 AND fingerprint = $2",
168 +
    )
169 +
    .bind(repo_id)
170 +
    .bind(fingerprint)
171 +
    .fetch_optional(pool)
172 +
    .await?;
173 +
    if let Some(b) = &row {
174 +
        sqlx::query("DELETE FROM secret_bypasses WHERE id = $1").bind(b.id).execute(pool).await?;
175 +
    }
176 +
    Ok(row)
177 +
}
178 +
179 +
/// A finding of the history scan to record.
180 +
pub struct NewHistoryFinding<'a> {
181 +
    pub finding: NewFinding<'a>,
182 +
    pub change_hash: String,
183 +
    pub bookmark: String,
184 +
}
185 +
186 +
/// Record the history scan's findings: one per value and file, so a value
187 +
/// found again (in a later change, or by a rescan) keeps its first row and
188 +
/// whether it was resolved. How many were new.
189 +
pub async fn record_history(pool: &Pool, repo_id: i64, findings: &[NewHistoryFinding<'_>]) -> sqlx::Result<usize> {
190 +
    let now = now_ms();
191 +
    let mut added = 0;
192 +
    for h in findings {
193 +
        let f = &h.finding;
194 +
        let r = sqlx::query(
195 +
            "INSERT INTO secret_findings (repo_id, at_ms, pusher, via, rule, description, path, line, fingerprint, excerpt, source, change_hash, bookmark) \
196 +
             VALUES ($1, $2, '', '', $3, $4, $5, $6, $7, $8, 'history', $9, $10) \
197 +
             ON CONFLICT (repo_id, fingerprint, path) WHERE source = 'history' DO NOTHING",
198 +
        )
199 +
        .bind(repo_id)
200 +
        .bind(now)
201 +
        .bind(f.rule)
202 +
        .bind(f.description)
203 +
        .bind(f.path)
204 +
        .bind(f.line)
205 +
        .bind(f.fingerprint)
206 +
        .bind(f.excerpt)
207 +
        .bind(&h.change_hash)
208 +
        .bind(&h.bookmark)
209 +
        .execute(pool)
210 +
        .await?;
211 +
        added += r.rows_affected() as usize;
212 +
    }
213 +
    Ok(added)
214 +
}
215 +
216 +
const HISTORY_COLUMNS: &str =
217 +
    "id, at_ms, rule, description, path, line, fingerprint, excerpt, change_hash, bookmark, resolved_ms, resolved_by, resolved_reason";
218 +
219 +
/// The history scan's findings still open: not resolved, their value not
220 +
/// allowed; oldest first, as history has them.
221 +
pub async fn history(pool: &Pool, repo_id: i64, limit: i64) -> sqlx::Result<Vec<HistoryRow>> {
222 +
    sqlx::query_as(&format!(
223 +
        "SELECT {HISTORY_COLUMNS} FROM secret_findings f WHERE repo_id = $1 AND source = 'history' AND resolved_ms IS NULL \
224 +
         AND NOT EXISTS (SELECT 1 FROM secret_bypasses b WHERE b.repo_id = f.repo_id AND b.fingerprint = f.fingerprint) \
225 +
         ORDER BY id LIMIT $2"
226 +
    ))
227 +
    .bind(repo_id)
228 +
    .bind(limit)
229 +
    .fetch_all(pool)
230 +
    .await
231 +
}
232 +
233 +
/// The history scan's findings marked resolved, newest mark first.
234 +
pub async fn resolved(pool: &Pool, repo_id: i64, limit: i64) -> sqlx::Result<Vec<HistoryRow>> {
235 +
    sqlx::query_as(&format!(
236 +
        "SELECT {HISTORY_COLUMNS} FROM secret_findings WHERE repo_id = $1 AND source = 'history' AND resolved_ms IS NOT NULL \
237 +
         ORDER BY resolved_ms DESC, id DESC LIMIT $2"
238 +
    ))
239 +
    .bind(repo_id)
240 +
    .bind(limit)
241 +
    .fetch_all(pool)
242 +
    .await
243 +
}
244 +
245 +
/// Mark history finding `id` resolved (the secret was revoked) with
246 +
/// `reason`; the finding, if it was an open one of this repository's.
247 +
pub async fn resolve(pool: &Pool, repo_id: i64, id: i64, by: &str, reason: &str) -> sqlx::Result<Option<HistoryRow>> {
248 +
    let done = sqlx::query(
249 +
        "UPDATE secret_findings SET resolved_ms = $1, resolved_by = $2, resolved_reason = $3 \
250 +
         WHERE id = $4 AND repo_id = $5 AND source = 'history' AND resolved_ms IS NULL",
251 +
    )
252 +
    .bind(now_ms())
253 +
    .bind(by)
254 +
    .bind(reason)
255 +
    .bind(id)
256 +
    .bind(repo_id)
257 +
    .execute(pool)
258 +
    .await?;
259 +
    if done.rows_affected() == 0 {
260 +
        return Ok(None);
261 +
    }
262 +
    sqlx::query_as(&format!("SELECT {HISTORY_COLUMNS} FROM secret_findings WHERE id = $1")).bind(id).fetch_optional(pool).await
263 +
}
264 +
265 +
/// A repository's history scan (#277): the rules it ran with, the heads
266 +
/// whose history is scanned (`done_heads`), the heads a scan under way
267 +
/// works toward (`target_heads`, empty when none is) and how far it got
268 +
/// (`position` of `pending` changes, in the scan's order), totals, and a
269 +
/// maintainer's request for a scan now.
270 +
#[derive(Clone, Debug, Default, PartialEq, Eq, FromRow)]
271 +
pub struct ScanRow {
272 +
    pub repo_id: i64,
273 +
    pub rules_version: i64,
274 +
    pub done_heads: String,
275 +
    pub target_heads: String,
276 +
    pub position: i64,
277 +
    pub pending: i64,
278 +
    pub changes: i64,
279 +
    pub bytes: i64,
280 +
    pub requested_ms: Option<i64>,
281 +
    pub pass_ms: Option<i64>,
282 +
    pub finished_ms: Option<i64>,
283 +
    pub error: String,
284 +
}
285 +
286 +
const SCAN_COLUMNS: &str =
287 +
    "repo_id, rules_version, done_heads, target_heads, position, pending, changes, bytes, requested_ms, pass_ms, finished_ms, error";
288 +
289 +
pub async fn scan_of(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<ScanRow>> {
290 +
    sqlx::query_as(&format!("SELECT {SCAN_COLUMNS} FROM secret_scans WHERE repo_id = $1")).bind(repo_id).fetch_optional(pool).await
291 +
}
292 +
293 +
/// Save a repository's scan progress.
294 +
pub async fn save_scan(pool: &Pool, s: &ScanRow) -> sqlx::Result<()> {
295 +
    sqlx::query(&format!(
296 +
        "INSERT INTO secret_scans ({SCAN_COLUMNS}) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
297 +
         ON CONFLICT (repo_id) DO UPDATE SET rules_version = excluded.rules_version, done_heads = excluded.done_heads, \
298 +
         target_heads = excluded.target_heads, position = excluded.position, pending = excluded.pending, changes = excluded.changes, \
299 +
         bytes = excluded.bytes, requested_ms = excluded.requested_ms, pass_ms = excluded.pass_ms, finished_ms = excluded.finished_ms, \
300 +
         error = excluded.error"
301 +
    ))
302 +
    .bind(s.repo_id)
303 +
    .bind(s.rules_version)
304 +
    .bind(&s.done_heads)
305 +
    .bind(&s.target_heads)
306 +
    .bind(s.position)
307 +
    .bind(s.pending)
308 +
    .bind(s.changes)
309 +
    .bind(s.bytes)
310 +
    .bind(s.requested_ms)
311 +
    .bind(s.pass_ms)
312 +
    .bind(s.finished_ms)
313 +
    .bind(&s.error)
314 +
    .execute(pool)
315 +
    .await?;
131 316
    Ok(())
317 +
}
318 +
319 +
/// A maintainer asked for the whole history to be scanned again now:
320 +
/// what was scanned is forgotten (findings and their marks are kept) and
321 +
/// the repository goes first.
322 +
pub async fn request_scan(pool: &Pool, repo_id: i64) -> sqlx::Result<()> {
323 +
    let now = now_ms();
324 +
    sqlx::query(
325 +
        "INSERT INTO secret_scans (repo_id, requested_ms) VALUES ($1, $2) \
326 +
         ON CONFLICT (repo_id) DO UPDATE SET requested_ms = excluded.requested_ms, done_heads = '', target_heads = '', position = 0, pending = 0",
327 +
    )
328 +
    .bind(repo_id)
329 +
    .bind(now)
330 +
    .execute(pool)
331 +
    .await?;
332 +
    Ok(())
333 +
}
334 +
335 +
/// Repositories whose maintainers asked for a scan, oldest request first.
336 +
pub async fn requested(pool: &Pool) -> sqlx::Result<Vec<i64>> {
337 +
    sqlx::query_scalar("SELECT repo_id FROM secret_scans WHERE requested_ms IS NOT NULL ORDER BY requested_ms").fetch_all(pool).await
132 338
}
modified crates/hub/src/lib.rs +4 −0
…
18 unchanged lines
19 19
pub mod events;
20 20
pub mod flow;
21 21
pub mod headers;
22 +
pub mod history_scan;
22 23
pub mod hooks;
23 24
pub mod images;
24 25
pub mod insights;
…
177 unchanged lines
202 203
    leader::run(app, "diffusion.backfill", |app| async move { diffusion::backfill(&app).await });
203 204
    // Mend the pin index and the bumps it drives (oxygenhub#210).
204 205
    leader::run(app, "diffusion.reconciler", diffusion::reconciler);
206 +
    // Each repository's history scanned for secrets (#277), a budget at a
207 +
    // time, at low priority.
208 +
    leader::run(app, "secrets.history_scan", history_scan::worker);
205 209
    // What each image is for, read or learned once (oxygenhub#173): safe
206 210
    // in every worker, so it takes no lock.
207 211
    app.leader.note("images.learn_archs");
…
208 unchanged lines
modified crates/hub/src/push_protection.rs +7 −1
…
7 unchanged lines
8 8
//! stays fast; past it the rest goes unscanned, the push is accepted and its
9 9
//! log entry says how much was left (a refusal would block every large
10 10
//! import for what is most likely nothing). Rules are data, in
11 -
//! `push_protection.toml`, compiled in and versioned.
11 +
//! `push_protection.toml`, compiled in and versioned. What a repository
12 +
//! already holds is scanned with the same rules by the history scan
13 +
//! ([`crate::history_scan`], #277).
12 14
//!
13 15
//! A finding names its rule, file and line, a fingerprint (BLAKE3 of the
14 16
//! rule id and the secret, the first 16 hex digits) and an excerpt with the
…
513 unchanged lines
528 530
            ("push_protection.rs", include_str!("push_protection.rs")),
529 531
            ("tests/push_protection.rs", include_str!("../tests/push_protection.rs")),
530 532
            ("docs/spec.md", include_str!("../../../docs/spec.md")),
533 +
            // AWS's documented example keys, put together at run time (#277).
534 +
            ("backups.rs", include_str!("backups.rs")),
535 +
            ("history_scan.rs", include_str!("history_scan.rs")),
536 +
            ("tests/history_scan.rs", include_str!("../tests/history_scan.rs")),
531 537
        ] {
532 538
            assert!(scan_text(rules(), text.as_bytes()).is_empty(), "{name}");
533 539
        }
…
41 unchanged lines
modified crates/hub/src/web/mod.rs +3 −0
…
321 unchanged lines
322 322
        .route("/{owner}/{name}/settings/agent-gates", post(repo_settings::agent_gates))
323 323
        .route("/{owner}/{name}/settings/push-protection", post(repo_settings::push_protection))
324 324
        .route("/{owner}/{name}/settings/secret-bypass", post(repo_settings::secret_bypass))
325 +
        .route("/{owner}/{name}/settings/secret-bypass/remove", post(repo_settings::secret_bypass_remove))
326 +
        .route("/{owner}/{name}/settings/secret-resolve", post(repo_settings::secret_resolve))
327 +
        .route("/{owner}/{name}/settings/secret-scan", post(repo_settings::secret_scan))
325 328
        .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede))
326 329
        .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion))
327 330
        .route("/{owner}/{name}/settings/backup", post(repo_settings::backup))
…
537 unchanged lines
modified crates/hub/src/web/repo_settings.rs +130 −0
…
199 unchanged lines
200 200
    /// yet allowed, and the fingerprints allowed.
201 201
    push_findings: Vec<db::push_protection::FindingRow>,
202 202
    secret_bypasses: Vec<db::push_protection::Bypass>,
203 +
    /// The history scan (#277): its progress, its open findings and those
204 +
    /// marked resolved, and whether the hub runs it at all.
205 +
    history_scan: Option<db::push_protection::ScanRow>,
206 +
    history_findings: Vec<db::push_protection::HistoryRow>,
207 +
    resolved_findings: Vec<db::push_protection::HistoryRow>,
208 +
    history_scan_on: bool,
203 209
}
204 210
205 211
impl RepoSettingsPage {
…
49 unchanged lines
255 261
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
256 262
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
257 263
    let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
264 +
    let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
265 +
    let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
266 +
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
267 +
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
258 268
    Ok(Page(RepoSettingsPage {
259 269
        push_findings,
260 270
        secret_bypasses,
271 +
        history_scan,
272 +
        history_findings,
273 +
        resolved_findings,
274 +
        history_scan_on,
261 275
        backup_owner,
262 276
        coverage,
263 277
        storage,
…
653 unchanged lines
917 931
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
918 932
        .await
919 933
        .ok();
934 +
    if on {
935 +
        // What was pushed while it was off is scanned now (#277).
936 +
        crate::history_scan::wake(&app, row.id);
937 +
    }
920 938
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
921 939
    let notice = if on {
922 940
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
…
48 unchanged lines
971 989
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
972 990
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
973 991
    settings_page(&app, shell, row, role, Some(notice), None).await
992 +
}
993 +
994 +
#[derive(Deserialize)]
995 +
pub struct BypassRemoveForm {
996 +
    #[serde(rename = "_csrf")]
997 +
    csrf_token: String,
998 +
    fingerprint: String,
999 +
}
1000 +
1001 +
/// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277):
1002 +
/// take back an allowed fingerprint, so a push adding that value is refused
1003 +
/// again; for maintainers, audited as `secret.bypass_removed`.
1004 +
pub async fn secret_bypass_remove(
1005 +
    State(app): State<Shared>,
1006 +
    viewer: Viewer,
1007 +
    Path((owner, name)): Path<(String, String)>,
1008 +
    Form(f): Form<BypassRemoveForm>,
1009 +
) -> Result<Page<RepoSettingsPage>, Error> {
1010 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1011 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1012 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1013 +
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1014 +
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1015 +
    let Some(b) = removed else {
1016 +
        return settings_page(&app, shell, row, role, None, Some(format!("Fingerprint {fingerprint} is not allowed here."))).await;
1017 +
    };
1018 +
    let detail = if b.rule.is_empty() {
1019 +
        format!("{fingerprint} (was: {})", b.reason)
1020 +
    } else {
1021 +
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1022 +
    };
1023 +
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
1024 +
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
1025 +
    settings_page(&app, shell, row, role, Some(notice), None).await
1026 +
}
1027 +
1028 +
#[derive(Deserialize)]
1029 +
pub struct SecretResolveForm {
1030 +
    #[serde(rename = "_csrf")]
1031 +
    csrf_token: String,
1032 +
    finding: i64,
1033 +
    #[serde(default)]
1034 +
    reason: String,
1035 +
}
1036 +
1037 +
/// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a
1038 +
/// finding of the history scan resolved (the secret was revoked or
1039 +
/// rotated), with a reason; for maintainers, audited as `secret.resolved`.
1040 +
pub async fn secret_resolve(
1041 +
    State(app): State<Shared>,
1042 +
    viewer: Viewer,
1043 +
    Path((owner, name)): Path<(String, String)>,
1044 +
    Form(f): Form<SecretResolveForm>,
1045 +
) -> Result<Page<RepoSettingsPage>, Error> {
1046 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1047 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1048 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1049 +
    let reason = f.reason.trim();
1050 +
    let error = if reason.is_empty() {
1051 +
        Some("Say how it was resolved (the key was revoked, the token rotated): the reason is kept with the finding and in the audit log.")
1052 +
    } else if reason.chars().count() > 500 {
1053 +
        Some("Keep the reason under 500 characters.")
1054 +
    } else {
1055 +
        None
1056 +
    };
1057 +
    if let Some(e) = error {
1058 +
        return settings_page(&app, shell, row, role, None, Some(e.into())).await;
1059 +
    }
1060 +
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
1061 +
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1062 +
    let Some(found) = done else {
1063 +
        return settings_page(&app, shell, row, role, None, Some("That finding is not open here.".into())).await;
1064 +
    };
1065 +
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
1066 +
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
1067 +
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
1068 +
    settings_page(&app, shell, row, role, Some(notice), None).await
1069 +
}
1070 +
1071 +
#[derive(Deserialize)]
1072 +
pub struct SecretScanForm {
1073 +
    #[serde(rename = "_csrf")]
1074 +
    csrf_token: String,
1075 +
}
1076 +
1077 +
/// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the
1078 +
/// whole history again now, with the current rules; for maintainers,
1079 +
/// audited as `secret.history_scan`.
1080 +
pub async fn secret_scan(
1081 +
    State(app): State<Shared>,
1082 +
    viewer: Viewer,
1083 +
    Path((owner, name)): Path<(String, String)>,
1084 +
    Form(f): Form<SecretScanForm>,
1085 +
) -> Result<Page<RepoSettingsPage>, Error> {
1086 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1087 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1088 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1089 +
    if !row.push_protection {
1090 +
        return settings_page(
1091 +
            &app,
1092 +
            shell,
1093 +
            row,
1094 +
            role,
1095 +
            None,
1096 +
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1097 +
        )
1098 +
        .await;
1099 +
    }
1100 +
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1101 +
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
1102 +
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
1103 +
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
974 1104
}
975 1105
976 1106
#[derive(Deserialize)]
…
289 unchanged lines
modified crates/hub/tests/roles.rs +11 −3
…
11 unchanged lines
12 12
use tower::ServiceExt;
13 13
14 14
/// The worker's singleton tasks that run for as long as the process does.
15 -
const WORKER_SINGLETONS: [&str; 7] =
16 -
    ["backups.scheduler", "diffusion.reconciler", "flow.placement", "flow.reaper", "flow.scheduler", "hooks.worker", "maintenance.nightly"];
15 +
const WORKER_SINGLETONS: [&str; 8] = [
16 +
    "backups.scheduler",
17 +
    "diffusion.reconciler",
18 +
    "flow.placement",
19 +
    "flow.reaper",
20 +
    "flow.scheduler",
21 +
    "hooks.worker",
22 +
    "maintenance.nightly",
23 +
    "secrets.history_scan",
24 +
];
17 25
18 26
fn hermetic() {
19 27
    static ONCE: std::sync::Once = std::sync::Once::new();
…
91 unchanged lines
111 119
    hermetic();
112 120
    let dir = tempfile::tempdir().unwrap();
113 121
    let app = oxygenhub::build(config(dir.path(), &[])).await.unwrap();
114 -
    assert_eq!(app.leader.tasks().len(), 10, "{:?}", app.leader.tasks());
122 +
    assert_eq!(app.leader.tasks().len(), 11, "{:?}", app.leader.tasks());
115 123
    assert_eq!(health(&app).await["roles"], serde_json::json!(["web", "worker"]));
116 124
    assert_eq!(get(&app, "/explore", None).await.0, StatusCode::OK);
117 125
    let mut bad = config(dir.path(), &["web", "cron"]);
…
57 unchanged lines
modified docs/roles.md +3 −3
…
106 unchanged lines
107 107
108 108
The locks: `hooks.worker`, `flow.reaper`, `flow.scheduler`,
109 109
`flow.placement`, `maintenance.nightly`, `backups.scheduler`,
110 -
`diffusion.backfill`, `diffusion.reconciler` (worker role) and
111 -
`tlog.checkpointer` (web role, below). `images.learn_archs` runs in every
112 -
worker, without a lock.
110 +
`diffusion.backfill`, `diffusion.reconciler`, `secrets.history_scan`
111 +
(worker role) and `tlog.checkpointer` (web role, below).
112 +
`images.learn_archs` runs in every worker, without a lock.
113 113
114 114
`/health` says what the process does and which locks it holds:
115 115
`"roles": ["web", "worker"], "leading": ["backups.scheduler", …]`.
…
126 unchanged lines
modified docs/spec.md +9 −2
…
720 unchanged lines
721 721
722 722
- **Rules** are data, `crates/hub/src/push_protection.toml`, compiled in and versioned (`version`, shown in every refusal as `rules vN`). Each has an id, a description, a regex matched on bytes (its `secret` group, else the whole match, is the secret), optional keywords that must occur (case-insensitive) before the regex runs, and optionally a regex the same file or inserted text must also match, for secrets that are only one when paired. The first set: AWS access key IDs, AWS secret access keys next to a key ID, Google Cloud service account keys (`"type": "service_account"` with a `private_key`), Azure storage connection strings, GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), GitLab personal access tokens (`glpat-`), this hub's own credentials (personal access tokens `oxyhub_`, agent grant tokens `oxyagent_`, OxygenFlow job tokens `flowjob_`, runner credentials `flowrunner_` and registration tokens `flowreg_`), Slack tokens (`xoxb-` and kin), Stripe live keys (`sk_live_`, `rk_live_`), OpenSSH and PEM private keys (a `BEGIN … PRIVATE KEY` line, a body and its `END` line; the header alone, as in documentation, is not one). Rules are tried in order and a match overlapping an earlier rule's is dropped, so one secret is reported once, by its most specific rule.
723 723
- **Findings.** Each names its rule, the file and line (the line as the change that adds it leaves the file, worked out from the repository's state with the push's changes applied in memory, only when there is a finding), a fingerprint (the first 16 hex digits of BLAKE3 of the rule id, a NUL and the secret) and the line with the secret masked (its first four characters kept). The secret itself is never shown, logged or stored.
724 -
- **Budget**, so pushes stay fast: at most `scan_limit` bytes scanned per push request (16 MiB), each file or inserted text at most `scan_file_limit` (1 MiB; larger ones, and binary blobs, with a NUL in the first 8000 bytes, are skipped), and `scan_time_ms` (2000) in all, all under `[limits]` in `hub.toml`. Past the size or time budget the rest goes unscanned and the push is **accepted**: refusing would block every large import (a whole history pushed in parts, each part with its own budget) for what is most likely nothing. The accepted references' push log entries say how much went unscanned (`push protection scanned 16.0 MiB and stopped at its size budget: … more were not scanned`). Scanning history the hub holds is later work.
724 +
- **Budget**, so pushes stay fast: at most `scan_limit` bytes scanned per push request (16 MiB), each file or inserted text at most `scan_file_limit` (1 MiB; larger ones, and binary blobs, with a NUL in the first 8000 bytes, are skipped), and `scan_time_ms` (2000) in all, all under `[limits]` in `hub.toml`. Past the size or time budget the rest goes unscanned and the push is **accepted**: refusing would block every large import (a whole history pushed in parts, each part with its own budget) for what is most likely nothing. The accepted references' push log entries say how much went unscanned (`push protection scanned 16.0 MiB and stopped at its size budget: … more were not scanned`). History the hub already holds is the history scan's (below).
725 725
- **Refusal.** The push fails with HTTP `422`, nothing of it stored or moved, and a plain text the client prints as it is: one line per finding, `path:line: description (fingerprint F)` with its masked line under it (20 at most, then a count), then what to do: take the value out of the change that adds it (`oxy amend`, or `oxy abandon` a change that only adds it) and push again, revoke the credential if it was real, or have a maintainer allow the fingerprint at `{base_url}/{owner}/{name}/settings#push-protection`. A push through an agent's token (an agent-marked token or a keyless agent's grant) adds a paragraph for the agent naming it: remove the value and push again, do not work around the refusal, and tell its person if it was asked to commit the value, since only a maintainer can allow one. The refusal is recorded in the push log (`/{owner}/{name}/pushes`, each named reference refused with `push protection: N possible secrets (path:line, …)`), and its findings (rule, path, line, fingerprint, masked line, pusher and agent) in `secret_findings` (the newest 200 per repository) for the maintainers.
726 -
- **Bypass.** Under Settings › Push protection (maintain role) a maintainer sees the recent findings not yet allowed and allows one by its fingerprint with a reason (required, at most 500 characters), from the finding's row or by typing a fingerprint from a refusal: a row in `secret_bypasses` (repository, fingerprint, the rule it was found under, reason, who, when), audited as `secret.bypass`. A finding whose fingerprint is allowed passes; the allowed fingerprints are listed with their reasons. A writer cannot allow one.
726 +
- **Bypass.** Under Settings › Push protection (maintain role) a maintainer sees the recent findings not yet allowed and allows one by its fingerprint with a reason (required, at most 500 characters), from the finding's row or by typing a fingerprint from a refusal: a row in `secret_bypasses` (repository, fingerprint, the rule it was found under, reason, who, when), audited as `secret.bypass`. A finding whose fingerprint is allowed passes; the allowed fingerprints are listed with their reasons. A writer cannot allow one. Each allowed fingerprint has a **remove** button (maintain role, `POST …/settings/secret-bypass/remove`), audited as `secret.bypass_removed` with the reason it had: pushes adding that value are refused again and its history findings are open again (oxygenhub#277).
727 727
- **Setting.** Per repository, `repositories.push_protection`, on by default; Settings › Push protection (maintain role) turns it off or on, audited as `repo.push_protection`. A wiki follows its repository's setting and bypasses (migration 0085, both databases).
728 +
- **History scan** (oxygenhub#277). The same rules run over what each repository already holds, so a secret committed before push protection existed, or while it was off, is found too. What is scanned is every change reachable from the repository's bookmarks, read as push protection reads a push: the lines each change inserts and the text blobs it puts in place of a file's content (within `scan_file_limit`; binary and lazy blobs are skipped). It is the worker role's `secrets.history_scan` task (docs/roles.md), under its leader lock: ten seconds after the process starts it passes over every repository (so the first deploy scans every repository's whole history), then over a repository again when a push to it is announced on the bus (`repo:N`, `push`), when a maintainer asks or turns push protection back on, and every 15 minutes over all of them for what the bus missed (a landing, a lost event). Repositories with push protection off are skipped; wikis are not scanned.
729 +
  - **Progress and budget.** Each repository's scan is a row in `secret_scans`: the rules version it ran with, the heads whose history is scanned (`done_heads`), and while a scan is under way the heads it works toward (`target_heads`, the bookmarks' heads when it started) and how far it got (`position` of `pending` changes). The changes to scan are those reachable from the target and not from the done heads, ordered by clock then hash, so every pass sees the same list and resumes at `position`. A pass over one repository stops at `history_scan_limit` bytes (64 MiB; a change counts at least one byte) or `history_scan_time_ms` (5000) wall-clock, under `[limits]`, after at least one change; the worker then moves to the next repository and comes back, so every repository progresses in turn. When the list is done the target becomes the done heads: after a push only the new changes are scanned. A push during a scan waits for it to finish. A target that no longer loads (a bookmark rewritten and collected) is replaced by the bookmarks as they are. A new rules `version` scans every history again. `history_scan_limit = 0` turns the scan off.
730 +
  - **Low priority.** One repository at a time, one pass each in turn, on one blocking thread that rests as long as it worked (at most half a core), 200 ms between repositories, holding only the repository's shared lock, as pages do: it never holds up a push, a page or a gc.
731 +
  - **Findings** go into `secret_findings` with `source` `history` (refused pushes' are `push`), the change that adds the value (`change_hash`), the first bookmark that reaches it (the default bookmark first) and the file and line as that change leaves the file, worked out from the state of its dependencies with it applied. One row per value and file however many changes carry it (a unique index on repository, fingerprint and path for `history` rows), so a rescan adds nothing known and keeps what was resolved; they are not trimmed with the 200 push findings.
732 +
  - **Found in history.** Settings › Push protection (maintain role) shows the scan's state (scanning N of M changes, or scanned up to the bookmarks and when, with totals and the last error) and the open findings, oldest first: each with its change (linked), bookmark, file and line, masked line and fingerprint, and two actions. **Allow** is the push bypass above (a value that is not a secret is then allowed for pushes too). **Mark resolved** (`POST …/settings/secret-resolve`, a reason required, at most 500 characters) says the secret was revoked or rotated, since removing it from the files now leaves it in every earlier change; it records who, when and how, audited as `secret.resolved`, and moves the finding to a Resolved list. **Scan history now** (`POST …/settings/secret-scan`, maintain role, audited as `secret.history_scan`) forgets what was scanned (findings and marks stay) and puts the repository first. A writer can do none of these (migration 0086, both databases).
728 733
729 734
### 15.5 Keys and TLS
730 735
…
52 unchanged lines
783 788
scan_limit = "16MiB"            # push protection (§15.4): scanned per push
784 789
scan_file_limit = "1MiB"        # larger files and inserts are not scanned
785 790
scan_time_ms = 2000
791 +
history_scan_limit = "64MiB"    # the history scan (§15.4): per repository per pass; 0: off
792 +
history_scan_time_ms = 5000
786 793
787 794
[email]
788 795
smtp = "smtp://…"               # optional
…
71 unchanged lines
modified templates/repo_settings.html +29 −3
…
55 unchanged lines
56 56
</form>
57 57
<section class="panel" id="push-protection">
58 58
  <h2>Push protection</h2>
59 -
  <p>A push that adds what looks like a credential (cloud keys, tokens, private keys) is refused before any of it is stored, naming the file, line and a fingerprint for each. Only what a push adds is scanned, up to a budget; the push log says when a large push was not scanned in full.</p>
59 +
  <p>A push that adds what looks like a credential (cloud keys, tokens, private keys) is refused before any of it is stored, naming the file, line and a fingerprint for each. Only what a push adds is scanned, up to a budget; the push log says when a large push was not scanned in full. What the repository already holds is scanned in the background (Found in history, below).</p>
60 60
  <form class="card" method="post" action="{{ repo.url }}/settings/push-protection">
61 61
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
62 62
    <label class="check"><input type="checkbox" name="protect"{% if row.push_protection %} checked{% endif %}> Refuse pushes that add possible secrets</label>
…
17 unchanged lines
80 80
    </div>
81 81
    <button type="submit">ALLOW</button>
82 82
  </form>
83 +
  <h3 id="history-scan">Found in history</h3>
84 +
  <p class="small mute">Every change reachable from this repository's bookmarks is scanned with the same rules in the background, a budget at a time: its whole history once, then what each push adds.
85 +
  {% if !history_scan_on %}The history scan is off on this hub (<code>history_scan_limit = 0</code> under <code>[limits]</code>).
86 +
  {% else if !row.push_protection %}History is not scanned while push protection is off.
87 +
  {% else if let Some(s) = history_scan %}{% if s.requested_ms.is_some() && s.target_heads.is_empty() %}A scan of the whole history is waiting to start.{% else if !s.target_heads.is_empty() %}Scanning: {{ s.position }} of {{ s.pending }} changes{% if s.requested_ms.is_some() %} (asked for){% endif %}.{% else if let Some(t) = s.finished_ms %}Scanned up to the bookmarks <time title="{{ t|when_i }}">{{ t|ago_i }}</time>.{% else %}Scanned up to the bookmarks.{% endif %} {{ s.changes }} changes ({{ s.bytes|bytes }}) scanned in all.{% if !s.error.is_empty() %} The last pass failed: {{ s.error }}{% endif %}
88 +
  {% else %}Not scanned yet: the scan reaches every repository soon after the hub starts.{% endif %}</p>
89 +
  {% if history_scan_on && row.push_protection %}<form class="tiny" method="post" action="{{ repo.url }}/settings/secret-scan"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">SCAN HISTORY NOW</button></form>{% endif %}
90 +
  {% if history_findings.is_empty() %}<p class="mute small">Nothing open: no possible secret was found in history, or each was allowed or resolved.</p>{% else %}
91 +
  <p class="small mute">Values already in history: removing them from the files now leaves them in every earlier change, so a real one must be <strong>revoked or rotated</strong>, then marked resolved, with how. One that is not a secret (an example key, a test fixture) can be allowed instead, as for a push.</p>
92 +
  <table class="small">
93 +
  <tr><th>Where</th><th>What</th><th>Fingerprint</th><th>Found</th><th>Allow, because…</th><th>Resolved, because…</th></tr>
94 +
  {% for f in history_findings %}
95 +
  <tr><td class="px">{{ f.path }}:{{ f.line }}{% if !f.change_hash.is_empty() %}<br><span class="mute">in</span> <a class="chip" href="{{ repo.url }}/change/{{ f.change_hash }}">{{ f.change_hash|short }}</a>{% endif %}{% if !f.bookmark.is_empty() %} <span class="mute">on</span> {{ f.bookmark }}{% endif %}</td><td>{{ f.description }}<br><code class="mute">{{ f.excerpt }}</code></td><td><code>{{ f.fingerprint }}</code></td><td><time title="{{ f.at_ms|when_i }}">{{ f.at_ms|ago_i }}</time></td>
96 +
  <td><form class="tiny" method="post" action="{{ repo.url }}/settings/secret-bypass"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="fingerprint" value="{{ f.fingerprint }}"><input type="text" name="reason" aria-label="Reason to allow" placeholder="not a secret: …" required><button type="submit">allow</button></form></td>
97 +
  <td><form class="tiny" method="post" action="{{ repo.url }}/settings/secret-resolve"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="finding" value="{{ f.id }}"><input type="text" name="reason" aria-label="How it was resolved" placeholder="revoked: …" required><button type="submit">mark resolved</button></form></td></tr>
98 +
  {% endfor %}
99 +
  </table>{% endif %}
100 +
  {% if !resolved_findings.is_empty() %}
101 +
  <details><summary class="small">Resolved ({{ resolved_findings.len() }})</summary>
102 +
  <table class="small">
103 +
  <tr><th>Where</th><th>What</th><th>Fingerprint</th><th>How</th><th>By</th><th>When</th></tr>
104 +
  {% for f in resolved_findings %}
105 +
  <tr><td class="px">{{ f.path }}:{{ f.line }}{% if !f.change_hash.is_empty() %} <a class="chip" href="{{ repo.url }}/change/{{ f.change_hash }}">{{ f.change_hash|short }}</a>{% endif %}</td><td>{{ f.description }}</td><td><code>{{ f.fingerprint }}</code></td><td>{{ f.resolved_reason }}</td><td>{{ f.resolved_by }}</td><td>{% if let Some(t) = f.resolved_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}</td></tr>
106 +
  {% endfor %}
107 +
  </table></details>{% endif %}
83 108
  {% if !secret_bypasses.is_empty() %}
84 109
  <h3>Allowed</h3>
85 110
  <table class="small">
86 -
  <tr><th>Fingerprint</th><th>Rule</th><th>Reason</th><th>By</th><th>When</th></tr>
111 +
  <tr><th>Fingerprint</th><th>Rule</th><th>Reason</th><th>By</th><th>When</th><th></th></tr>
87 112
  {% for b in secret_bypasses %}
88 -
  <tr><td><code>{{ b.fingerprint }}</code></td><td>{{ b.rule }}</td><td>{{ b.reason }}</td><td>{{ b.by_name }}</td><td><time title="{{ b.created_ms|when_i }}">{{ b.created_ms|ago_i }}</time></td></tr>
113 +
  <tr><td><code>{{ b.fingerprint }}</code></td><td>{{ b.rule }}</td><td>{{ b.reason }}</td><td>{{ b.by_name }}</td><td><time title="{{ b.created_ms|when_i }}">{{ b.created_ms|ago_i }}</time></td>
114 +
  <td><form class="tiny" method="post" action="{{ repo.url }}/settings/secret-bypass/remove"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="fingerprint" value="{{ b.fingerprint }}"><button type="submit" title="Refuse pushes adding this value again">remove</button></form></td></tr>
89 115
  {% endfor %}
90 116
  </table>{% endif %}
91 117
</section>
…
216 unchanged lines