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 |