Push protection: refuse a push that adds what looks like a secret 0ee5c3fb32eb
The hub implements oxy-server's new vet_push hook (andymccall/oxygen, vet-push): after a push is decoded and before anything is stored, it scans what the push adds (inserted lines, new blobs) against rules kept as data (crates/hub/src/push_protection.toml, compiled in, v1): AWS key IDs and paired secret keys, GCP service account keys, Azure storage connection strings, GitHub, GitLab, Slack and Stripe live tokens, the hub's own (oxyhub_, oxyagent_, flowjob_, flowrunner_, flowreg_), and OpenSSH and PEM private keys. Each finding names the file, its line as the change leaves the file, a fingerprint (BLAKE3 of rule id and secret, 16 hex) and a masked excerpt; the secret is never shown or kept. A refusal is HTTP 422 with plain text the client prints as it is: each finding as path:line: description (fingerprint F), then what to do (oxy amend / oxy abandon and push again, revoke if real, or ask a maintainer to allow it at the settings page), and a paragraph for agents when the push came through an agent's token. It is recorded in the push log and its findings in secret_findings. Budget: 16 MiB scanned per push request, files and inserts over 1 MiB or binary skipped, 2 s ([limits] scan_limit, scan_file_limit, scan_time_ms). Past it the push is accepted and its push log entry says how much went unscanned. Settings > Push protection (maintain role): on by default, audited as repo.push_protection; recent findings with an allow form, and allowed fingerprints with reasons (secret_bypasses, audited secret.bypass). Migration 0085 in both databases. Spec §15.4. Tests: each kind of fake secret refused with path:line and fingerprint, nothing stored or moved; a bypass lets it through; a writer cannot bypass; setting off accepts; an agent's push gets the agent text; an over-budget push is accepted and says so. Postgres: push_protection and schema pass. The hub builds against deps/oxygenflow/deps/oxygen with the oxygen change applied; the pin follows once oxygen is pushed. Refs #268
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 41d58b64bdd4
- When
- · 2026-10-19 20:57 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
0ee5c3fb32eb9084f236277ff9c487c20adbe99ff6f26a2a2e04d2069e98f594- Depends on
- 076c7a7c86b1 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 1f78655a0466 2a0c09c09716 3b1bc27fe805 3eb491e9489b 3f4b0ea6eabf 4a68f1064b08 4bae285c6ed8 62d6e969f180 64caed18236a 66fd9d6b1452 78001759604c 9008bf6c2b8c 93be9ce4653e a9f1c0bf403a b64e2537d702 d0fbea2eb46e d23464c0ac60 d3b048b27277 de505df5cf8d e5b97ac399e7
- Depended on by
- aeed63c00ad7 dd327d76062d 5588bb3631d2 b349e15a3eab 367f34b8e590 d366306dae7d a0a1601eb5a5 166f6e9bf4f1 c7067c7bda56 cb7b3cd45dc0
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.6.0 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
- 34 atoms, 6 tree operations, 17 files
Browse the tree at this change
added crates/hub/migrations-pg/0085_push_protection.sql +29 −0
| 1 | + | -- Push protection (oxygenhub#268): whether pushes that add possible |
|
| 2 | + | -- secrets are refused (on by default), the fingerprints a maintainer let |
|
| 3 | + | -- through, and the findings of recent refusals for maintainers to judge. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN push_protection BOOLEAN NOT NULL DEFAULT TRUE; |
|
| 5 | + | CREATE TABLE secret_bypasses ( |
|
| 6 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 7 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 8 | + | fingerprint TEXT NOT NULL, |
|
| 9 | + | rule TEXT NOT NULL DEFAULT '', |
|
| 10 | + | reason TEXT NOT NULL, |
|
| 11 | + | by_account BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 12 | + | by_name TEXT NOT NULL DEFAULT '', |
|
| 13 | + | created_ms BIGINT NOT NULL, |
|
| 14 | + | UNIQUE (repo_id, fingerprint) |
|
| 15 | + | ); |
|
| 16 | + | CREATE TABLE secret_findings ( |
|
| 17 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 18 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 19 | + | at_ms BIGINT NOT NULL, |
|
| 20 | + | pusher TEXT NOT NULL DEFAULT '', |
|
| 21 | + | via TEXT NOT NULL DEFAULT '', |
|
| 22 | + | rule TEXT NOT NULL, |
|
| 23 | + | description TEXT NOT NULL, |
|
| 24 | + | path TEXT NOT NULL, |
|
| 25 | + | line BIGINT NOT NULL, |
|
| 26 | + | fingerprint TEXT NOT NULL, |
|
| 27 | + | excerpt TEXT NOT NULL |
|
| 28 | + | ); |
|
| 29 | + | CREATE INDEX secret_findings_repo ON secret_findings(repo_id, id); |
added crates/hub/migrations/0085_push_protection.sql +29 −0
| 1 | + | -- Push protection (oxygenhub#268): whether pushes that add possible |
|
| 2 | + | -- secrets are refused (on by default), the fingerprints a maintainer let |
|
| 3 | + | -- through, and the findings of recent refusals for maintainers to judge. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN push_protection INTEGER NOT NULL DEFAULT 1; |
|
| 5 | + | CREATE TABLE secret_bypasses ( |
|
| 6 | + | id INTEGER PRIMARY KEY, |
|
| 7 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 8 | + | fingerprint TEXT NOT NULL, |
|
| 9 | + | rule TEXT NOT NULL DEFAULT '', |
|
| 10 | + | reason TEXT NOT NULL, |
|
| 11 | + | by_account INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 12 | + | by_name TEXT NOT NULL DEFAULT '', |
|
| 13 | + | created_ms INTEGER NOT NULL, |
|
| 14 | + | UNIQUE (repo_id, fingerprint) |
|
| 15 | + | ); |
|
| 16 | + | CREATE TABLE secret_findings ( |
|
| 17 | + | id INTEGER PRIMARY KEY, |
|
| 18 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 19 | + | at_ms INTEGER NOT NULL, |
|
| 20 | + | pusher TEXT NOT NULL DEFAULT '', |
|
| 21 | + | via TEXT NOT NULL DEFAULT '', |
|
| 22 | + | rule TEXT NOT NULL, |
|
| 23 | + | description TEXT NOT NULL, |
|
| 24 | + | path TEXT NOT NULL, |
|
| 25 | + | line INTEGER NOT NULL, |
|
| 26 | + | fingerprint TEXT NOT NULL, |
|
| 27 | + | excerpt TEXT NOT NULL |
|
| 28 | + | ); |
|
| 29 | + | CREATE INDEX secret_findings_repo ON secret_findings(repo_id, id); |
added crates/hub/src/db/push_protection.rs +132 −0
| 1 | + | //! Push protection's records (migration 0085, oxygenhub#268): fingerprints |
|
| 2 | + | //! a maintainer allowed, and the findings of recent refusals. |
|
| 3 | + | ||
| 4 | + | use super::{now_ms, Pool}; |
|
| 5 | + | use sqlx::FromRow; |
|
| 6 | + | ||
| 7 | + | /// Findings kept per repository: the newest, for maintainers to judge. |
|
| 8 | + | const KEPT_FINDINGS: i64 = 200; |
|
| 9 | + | ||
| 10 | + | #[derive(Clone, Debug, FromRow)] |
|
| 11 | + | pub struct Bypass { |
|
| 12 | + | pub id: i64, |
|
| 13 | + | pub fingerprint: String, |
|
| 14 | + | pub rule: String, |
|
| 15 | + | pub reason: String, |
|
| 16 | + | pub by_name: String, |
|
| 17 | + | pub created_ms: i64, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | #[derive(Clone, Debug, FromRow)] |
|
| 21 | + | pub struct FindingRow { |
|
| 22 | + | pub id: i64, |
|
| 23 | + | pub at_ms: i64, |
|
| 24 | + | pub pusher: String, |
|
| 25 | + | pub via: String, |
|
| 26 | + | pub rule: String, |
|
| 27 | + | pub description: String, |
|
| 28 | + | pub path: String, |
|
| 29 | + | pub line: i64, |
|
| 30 | + | pub fingerprint: String, |
|
| 31 | + | pub excerpt: String, |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /// A finding to record. |
|
| 35 | + | pub struct NewFinding<'a> { |
|
| 36 | + | pub rule: &'a str, |
|
| 37 | + | pub description: &'a str, |
|
| 38 | + | pub path: &'a str, |
|
| 39 | + | pub line: i64, |
|
| 40 | + | pub fingerprint: &'a str, |
|
| 41 | + | pub excerpt: &'a str, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | /// The fingerprints allowed in a repository. |
|
| 45 | + | pub async fn allowed(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> { |
|
| 46 | + | sqlx::query_scalar("SELECT fingerprint FROM secret_bypasses WHERE repo_id = $1").bind(repo_id).fetch_all(pool).await |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | pub async fn bypasses(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Bypass>> { |
|
| 50 | + | sqlx::query_as("SELECT id, fingerprint, rule, reason, by_name, created_ms FROM secret_bypasses WHERE repo_id = $1 ORDER BY id DESC") |
|
| 51 | + | .bind(repo_id) |
|
| 52 | + | .fetch_all(pool) |
|
| 53 | + | .await |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /// Allow `fingerprint` with `reason`; allowing it again replaces the reason. |
|
| 57 | + | pub async fn allow(pool: &Pool, repo_id: i64, fingerprint: &str, rule: &str, reason: &str, by: Option<(i64, &str)>) -> sqlx::Result<()> { |
|
| 58 | + | sqlx::query( |
|
| 59 | + | "INSERT INTO secret_bypasses (repo_id, fingerprint, rule, reason, by_account, by_name, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) \ |
|
| 60 | + | ON CONFLICT (repo_id, fingerprint) DO UPDATE SET reason = excluded.reason, rule = excluded.rule, by_account = excluded.by_account, by_name = excluded.by_name, created_ms = excluded.created_ms", |
|
| 61 | + | ) |
|
| 62 | + | .bind(repo_id) |
|
| 63 | + | .bind(fingerprint) |
|
| 64 | + | .bind(rule) |
|
| 65 | + | .bind(reason) |
|
| 66 | + | .bind(by.map(|b| b.0)) |
|
| 67 | + | .bind(by.map(|b| b.1).unwrap_or("")) |
|
| 68 | + | .bind(now_ms()) |
|
| 69 | + | .execute(pool) |
|
| 70 | + | .await?; |
|
| 71 | + | Ok(()) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | /// Record a refused push's findings, keeping the newest few hundred. |
|
| 75 | + | pub async fn record(pool: &Pool, repo_id: i64, pusher: &str, via: &str, findings: &[NewFinding<'_>]) -> sqlx::Result<()> { |
|
| 76 | + | let now = now_ms(); |
|
| 77 | + | let mut tx = super::begin_write(pool).await?; |
|
| 78 | + | for f in findings { |
|
| 79 | + | sqlx::query( |
|
| 80 | + | "INSERT INTO secret_findings (repo_id, at_ms, pusher, via, rule, description, path, line, fingerprint, excerpt) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", |
|
| 81 | + | ) |
|
| 82 | + | .bind(repo_id) |
|
| 83 | + | .bind(now) |
|
| 84 | + | .bind(pusher) |
|
| 85 | + | .bind(via) |
|
| 86 | + | .bind(f.rule) |
|
| 87 | + | .bind(f.description) |
|
| 88 | + | .bind(f.path) |
|
| 89 | + | .bind(f.line) |
|
| 90 | + | .bind(f.fingerprint) |
|
| 91 | + | .bind(f.excerpt) |
|
| 92 | + | .execute(&mut *tx) |
|
| 93 | + | .await?; |
|
| 94 | + | } |
|
| 95 | + | 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)", |
|
| 97 | + | ) |
|
| 98 | + | .bind(repo_id) |
|
| 99 | + | .bind(repo_id) |
|
| 100 | + | .bind(KEPT_FINDINGS) |
|
| 101 | + | .execute(&mut *tx) |
|
| 102 | + | .await?; |
|
| 103 | + | tx.commit().await |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | /// The newest findings of refused pushes, those not since allowed. |
|
| 107 | + | pub async fn recent(pool: &Pool, repo_id: i64, limit: i64) -> sqlx::Result<Vec<FindingRow>> { |
|
| 108 | + | sqlx::query_as( |
|
| 109 | + | "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) \ |
|
| 111 | + | ORDER BY id DESC LIMIT $2", |
|
| 112 | + | ) |
|
| 113 | + | .bind(repo_id) |
|
| 114 | + | .bind(limit) |
|
| 115 | + | .fetch_all(pool) |
|
| 116 | + | .await |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | /// The rule a fingerprint was found under in this repository, if it was. |
|
| 120 | + | pub async fn rule_of(pool: &Pool, repo_id: i64, fingerprint: &str) -> sqlx::Result<Option<String>> { |
|
| 121 | + | sqlx::query_scalar("SELECT rule FROM secret_findings WHERE repo_id = $1 AND fingerprint = $2 ORDER BY id DESC LIMIT 1") |
|
| 122 | + | .bind(repo_id) |
|
| 123 | + | .bind(fingerprint) |
|
| 124 | + | .fetch_optional(pool) |
|
| 125 | + | .await |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | /// Whether pushes adding possible secrets are refused here. |
|
| 129 | + | pub async fn set_enabled(pool: &Pool, repo_id: i64, on: bool) -> sqlx::Result<()> { |
|
| 130 | + | sqlx::query("UPDATE repositories SET push_protection = $1 WHERE id = $2").bind(on).bind(repo_id).execute(pool).await?; |
|
| 131 | + | Ok(()) |
|
| 132 | + | } |
added crates/hub/src/push_protection.rs +574 −0
| 1 | + | //! Push protection (hub spec §15.4, oxygenhub#268): a push that adds |
|
| 2 | + | //! something that looks like a credential is refused before any of it is |
|
| 3 | + | //! stored, saying where, so it never lands in a repository's history. |
|
| 4 | + | //! |
|
| 5 | + | //! Only what a push adds is read: the lines its changes insert and the new |
|
| 6 | + | //! blobs it carries, never history the hub already holds. The scan has a |
|
| 7 | + | //! budget ([`Budget`]: bytes per push, the largest file, time), so a push |
|
| 8 | + | //! stays fast; past it the rest goes unscanned, the push is accepted and its |
|
| 9 | + | //! log entry says how much was left (a refusal would block every large |
|
| 10 | + | //! import for what is most likely nothing). Rules are data, in |
|
| 11 | + | //! `push_protection.toml`, compiled in and versioned. |
|
| 12 | + | //! |
|
| 13 | + | //! A finding names its rule, file and line, a fingerprint (BLAKE3 of the |
|
| 14 | + | //! rule id and the secret, the first 16 hex digits) and an excerpt with the |
|
| 15 | + | //! secret masked. A maintainer lets one finding through by its fingerprint |
|
| 16 | + | //! (Settings › Push protection); the setting itself is per repository and |
|
| 17 | + | //! on by default. |
|
| 18 | + | ||
| 19 | + | use oxy_core::hash::ObjectType; |
|
| 20 | + | use oxy_core::hash::{ChangeHash, ObjectRef}; |
|
| 21 | + | use oxy_core::ids::NodeId; |
|
| 22 | + | use oxy_core::objects::{Edit, TreeOp}; |
|
| 23 | + | use oxy_core::protocol::Bundle; |
|
| 24 | + | use oxy_core::repo::Repo; |
|
| 25 | + | use regex::bytes::Regex; |
|
| 26 | + | use serde::Deserialize; |
|
| 27 | + | use std::collections::{HashMap, HashSet}; |
|
| 28 | + | use std::sync::OnceLock; |
|
| 29 | + | use std::time::{Duration, Instant}; |
|
| 30 | + | ||
| 31 | + | /// The rules, as compiled in. |
|
| 32 | + | const RULES: &str = include_str!("push_protection.toml"); |
|
| 33 | + | ||
| 34 | + | /// How much of a push is scanned: past `bytes` in all, or `time`, the rest |
|
| 35 | + | /// is not; a file or inserted text over `file` bytes, or binary, is skipped. |
|
| 36 | + | #[derive(Clone, Copy, Debug)] |
|
| 37 | + | pub struct Budget { |
|
| 38 | + | pub bytes: u64, |
|
| 39 | + | pub file: u64, |
|
| 40 | + | pub time: Duration, |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | impl Default for Budget { |
|
| 44 | + | fn default() -> Self { |
|
| 45 | + | Budget { bytes: 16 << 20, file: 1 << 20, time: Duration::from_secs(2) } |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | #[derive(Deserialize)] |
|
| 50 | + | #[serde(deny_unknown_fields)] |
|
| 51 | + | struct RulesFile { |
|
| 52 | + | version: u32, |
|
| 53 | + | rule: Vec<RuleSpec>, |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | #[derive(Deserialize)] |
|
| 57 | + | #[serde(deny_unknown_fields)] |
|
| 58 | + | struct RuleSpec { |
|
| 59 | + | id: String, |
|
| 60 | + | description: String, |
|
| 61 | + | regex: String, |
|
| 62 | + | #[serde(default)] |
|
| 63 | + | keywords: Vec<String>, |
|
| 64 | + | #[serde(default)] |
|
| 65 | + | requires: Option<String>, |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | /// One compiled rule. |
|
| 69 | + | pub struct Rule { |
|
| 70 | + | pub id: String, |
|
| 71 | + | pub description: String, |
|
| 72 | + | regex: Regex, |
|
| 73 | + | /// Lower-cased; empty runs the regex on every text. |
|
| 74 | + | keywords: Vec<String>, |
|
| 75 | + | requires: Option<Regex>, |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | /// The compiled rule set. |
|
| 79 | + | pub struct Rules { |
|
| 80 | + | pub version: u32, |
|
| 81 | + | pub rules: Vec<Rule>, |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /// The compiled-in rules. A rule that does not compile is a build defect, |
|
| 85 | + | /// caught by this module's tests. |
|
| 86 | + | pub fn rules() -> &'static Rules { |
|
| 87 | + | static RULESET: OnceLock<Rules> = OnceLock::new(); |
|
| 88 | + | RULESET.get_or_init(|| compile(RULES).expect("push_protection.toml")) |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | fn compile(text: &str) -> Result<Rules, String> { |
|
| 92 | + | let file: RulesFile = toml::from_str(text).map_err(|e| e.to_string())?; |
|
| 93 | + | let mut rules = Vec::new(); |
|
| 94 | + | let mut seen = HashSet::new(); |
|
| 95 | + | for r in file.rule { |
|
| 96 | + | if !seen.insert(r.id.clone()) { |
|
| 97 | + | return Err(format!("rule {} is listed twice", r.id)); |
|
| 98 | + | } |
|
| 99 | + | let regex = Regex::new(&r.regex).map_err(|e| format!("rule {}: {e}", r.id))?; |
|
| 100 | + | let requires = match r.requires { |
|
| 101 | + | Some(q) => Some(Regex::new(&q).map_err(|e| format!("rule {} requires: {e}", r.id))?), |
|
| 102 | + | None => None, |
|
| 103 | + | }; |
|
| 104 | + | rules.push(Rule { |
|
| 105 | + | id: r.id, |
|
| 106 | + | description: r.description, |
|
| 107 | + | regex, |
|
| 108 | + | keywords: r.keywords.iter().map(|k| k.to_ascii_lowercase()).collect(), |
|
| 109 | + | requires, |
|
| 110 | + | }); |
|
| 111 | + | } |
|
| 112 | + | Ok(Rules { version: file.version, rules }) |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | /// Where a finding's text came from in the push. |
|
| 116 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 117 | + | pub enum Origin { |
|
| 118 | + | /// Lines an insert adds; `atom` is the finding's line's atom index in |
|
| 119 | + | /// its change (spec §7.4), counted per file. |
|
| 120 | + | Insert { atom: u32 }, |
|
| 121 | + | /// A new blob replacing a file's content. |
|
| 122 | + | Blob, |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | /// A possible secret a push adds. |
|
| 126 | + | #[derive(Clone, Debug)] |
|
| 127 | + | pub struct Finding { |
|
| 128 | + | pub rule: String, |
|
| 129 | + | pub description: String, |
|
| 130 | + | /// The change that adds it: its index in the bundle and its hash. |
|
| 131 | + | pub change: usize, |
|
| 132 | + | pub change_hash: Option<ChangeHash>, |
|
| 133 | + | pub node: NodeId, |
|
| 134 | + | pub origin: Origin, |
|
| 135 | + | /// The file's path, once [`locate`] found it. |
|
| 136 | + | pub path: String, |
|
| 137 | + | /// 1-based: in the file once [`locate`] worked it out, else in the |
|
| 138 | + | /// inserted text or blob. |
|
| 139 | + | pub line: usize, |
|
| 140 | + | /// The first 16 hex digits of BLAKE3(rule id, NUL, secret). |
|
| 141 | + | pub fingerprint: String, |
|
| 142 | + | /// The finding's line, the secret masked. |
|
| 143 | + | pub excerpt: String, |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | /// What a scan found and how much it read. |
|
| 147 | + | #[derive(Clone, Debug, Default)] |
|
| 148 | + | pub struct Scan { |
|
| 149 | + | pub findings: Vec<Finding>, |
|
| 150 | + | /// Bytes scanned. |
|
| 151 | + | pub scanned: u64, |
|
| 152 | + | /// Texts and blobs skipped as over the file budget or binary. |
|
| 153 | + | pub skipped: usize, |
|
| 154 | + | /// Bytes left unscanned when the budget ran out, and why. |
|
| 155 | + | pub cut_short: Option<(u64, &'static str)>, |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | impl Scan { |
|
| 159 | + | /// One line for the push log when the scan stopped early. |
|
| 160 | + | pub fn note(&self) -> Option<String> { |
|
| 161 | + | let (left, why) = self.cut_short?; |
|
| 162 | + | Some(format!( |
|
| 163 | + | "push protection scanned {} and stopped at its {why} budget: {} more were not scanned", |
|
| 164 | + | human(self.scanned), |
|
| 165 | + | human(left) |
|
| 166 | + | )) |
|
| 167 | + | } |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | fn human(n: u64) -> String { |
|
| 171 | + | match n { |
|
| 172 | + | n if n >= 1 << 20 => format!("{:.1} MiB", n as f64 / (1u64 << 20) as f64), |
|
| 173 | + | n if n >= 1 << 10 => format!("{:.1} KiB", n as f64 / 1024.0), |
|
| 174 | + | n => format!("{n} bytes"), |
|
| 175 | + | } |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | /// The fingerprint of `secret` under `rule`. |
|
| 179 | + | pub fn fingerprint(rule: &str, secret: &[u8]) -> String { |
|
| 180 | + | let mut h = blake3::Hasher::new(); |
|
| 181 | + | h.update(rule.as_bytes()); |
|
| 182 | + | h.update(&[0]); |
|
| 183 | + | h.update(secret); |
|
| 184 | + | h.finalize().to_hex()[..16].to_string() |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | /// A finding in one text: its rule, 1-based line, fingerprint and excerpt. |
|
| 188 | + | #[derive(Clone, Debug)] |
|
| 189 | + | pub struct Hit { |
|
| 190 | + | pub rule: usize, |
|
| 191 | + | pub line: usize, |
|
| 192 | + | pub fingerprint: String, |
|
| 193 | + | pub excerpt: String, |
|
| 194 | + | } |
|
| 195 | + | ||
| 196 | + | /// Every rule over one text. Matches overlapping an earlier rule's are |
|
| 197 | + | /// dropped, so one secret is reported once, by its most specific rule. |
|
| 198 | + | pub fn scan_text(rules: &Rules, text: &[u8]) -> Vec<Hit> { |
|
| 199 | + | let mut hits = Vec::new(); |
|
| 200 | + | let mut taken: Vec<(usize, usize)> = Vec::new(); |
|
| 201 | + | let mut lower: Option<Vec<u8>> = None; |
|
| 202 | + | for (i, rule) in rules.rules.iter().enumerate() { |
|
| 203 | + | if !rule.keywords.is_empty() { |
|
| 204 | + | let lower = lower.get_or_insert_with(|| text.to_ascii_lowercase()); |
|
| 205 | + | if !rule.keywords.iter().any(|k| memchr::memmem::find(lower, k.as_bytes()).is_some()) { |
|
| 206 | + | continue; |
|
| 207 | + | } |
|
| 208 | + | } |
|
| 209 | + | let mut required: Option<bool> = None; |
|
| 210 | + | for caps in rule.regex.captures_iter(text) { |
|
| 211 | + | let m = caps.name("secret").or_else(|| caps.get(0)).expect("a match"); |
|
| 212 | + | let (start, end) = (m.start(), m.end()); |
|
| 213 | + | if start == end || taken.iter().any(|(s, e)| start < *e && *s < end) { |
|
| 214 | + | continue; |
|
| 215 | + | } |
|
| 216 | + | if let Some(q) = &rule.requires { |
|
| 217 | + | if !*required.get_or_insert_with(|| q.is_match(text)) { |
|
| 218 | + | break; |
|
| 219 | + | } |
|
| 220 | + | } |
|
| 221 | + | taken.push((start, end)); |
|
| 222 | + | hits.push(Hit { |
|
| 223 | + | rule: i, |
|
| 224 | + | line: memchr::memchr_iter(b'\n', &text[..start]).count() + 1, |
|
| 225 | + | fingerprint: fingerprint(&rule.id, m.as_bytes()), |
|
| 226 | + | excerpt: excerpt(text, start, end), |
|
| 227 | + | }); |
|
| 228 | + | } |
|
| 229 | + | } |
|
| 230 | + | hits |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | /// The line holding `text[start..end]` with the secret masked: its first |
|
| 234 | + | /// four characters kept, the rest starred, at most 160 characters around. |
|
| 235 | + | fn excerpt(text: &[u8], start: usize, end: usize) -> String { |
|
| 236 | + | let line_start = memchr::memrchr(b'\n', &text[..start]).map(|i| i + 1).unwrap_or(0); |
|
| 237 | + | let line_end = memchr::memchr(b'\n', &text[start..]).map(|i| start + i).unwrap_or(text.len()); |
|
| 238 | + | let secret_end = end.min(line_end); |
|
| 239 | + | let kept = (secret_end - start).min(4); |
|
| 240 | + | let before_from = start.saturating_sub(60).max(line_start); |
|
| 241 | + | let after_to = (secret_end + 40).min(line_end); |
|
| 242 | + | let mut out = String::new(); |
|
| 243 | + | if before_from > line_start { |
|
| 244 | + | out.push('…'); |
|
| 245 | + | } |
|
| 246 | + | out.push_str(&String::from_utf8_lossy(&text[before_from..start + kept])); |
|
| 247 | + | out.push_str(&"*".repeat((secret_end - start - kept).clamp(4, 16))); |
|
| 248 | + | let mut after = String::from_utf8_lossy(&text[secret_end..after_to]).into_owned(); |
|
| 249 | + | // The same secret again on the line is masked too. |
|
| 250 | + | let secret = String::from_utf8_lossy(&text[start..secret_end]).into_owned(); |
|
| 251 | + | if secret.len() > 4 { |
|
| 252 | + | after = after.replace(&secret, "****"); |
|
| 253 | + | } |
|
| 254 | + | out.push_str(&after); |
|
| 255 | + | if after_to < line_end { |
|
| 256 | + | out.push('…'); |
|
| 257 | + | } |
|
| 258 | + | out.trim_end_matches(['\r', '\n']).trim().to_string() |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | /// Scan what `bundle` adds: each insert's lines and each new blob a change |
|
| 262 | + | /// puts in place of a file's content, in the push's order, within |
|
| 263 | + | /// `budget`. Past the size or time budget the rest is only counted. |
|
| 264 | + | pub fn scan(bundle: &Bundle, budget: Budget) -> Scan { |
|
| 265 | + | let rules = rules(); |
|
| 266 | + | let started = Instant::now(); |
|
| 267 | + | let mut out = Scan::default(); |
|
| 268 | + | // The bundle's blobs and chunks by reference, made when a change first |
|
| 269 | + | // replaces a file's content. |
|
| 270 | + | let mut objects: Option<HashMap<ObjectRef, usize>> = None; |
|
| 271 | + | let mut stop: Option<&'static str> = None; |
|
| 272 | + | let mut left: u64 = 0; |
|
| 273 | + | for (ci, (change, _)) in bundle.changes.iter().enumerate() { |
|
| 274 | + | let mut counters: HashMap<NodeId, u32> = HashMap::new(); |
|
| 275 | + | for e in &change.edits { |
|
| 276 | + | let (node, origin, size) = match e { |
|
| 277 | + | Edit::Insert { node, lines, .. } => { |
|
| 278 | + | let idx = counters.entry(*node).or_default(); |
|
| 279 | + | let first = *idx; |
|
| 280 | + | *idx += lines.len() as u32; |
|
| 281 | + | let n: usize = lines.iter().map(<[u8]>::len).sum(); |
|
| 282 | + | (*node, Origin::Insert { atom: first }, n as u64) |
|
| 283 | + | } |
|
| 284 | + | Edit::Replace { node, new: Some(r), .. } => { |
|
| 285 | + | let objects = objects.get_or_insert_with(|| { |
|
| 286 | + | bundle |
|
| 287 | + | .objects |
|
| 288 | + | .iter() |
|
| 289 | + | .enumerate() |
|
| 290 | + | .filter(|(_, (ty, _))| matches!(ty, ObjectType::Blob | ObjectType::Chunk)) |
|
| 291 | + | .map(|(i, (ty, body))| (oxy_core::hash::hash_object(*ty, body), i)) |
|
| 292 | + | .collect() |
|
| 293 | + | }); |
|
| 294 | + | // A blob the push does not carry is already on the hub |
|
| 295 | + | // (or lazy): nothing new. |
|
| 296 | + | let Some(blob) = blob_of(bundle, objects, r) else { continue }; |
|
| 297 | + | (*node, Origin::Blob, blob.length) |
|
| 298 | + | } |
|
| 299 | + | _ => continue, |
|
| 300 | + | }; |
|
| 301 | + | if size > budget.file { |
|
| 302 | + | out.skipped += 1; |
|
| 303 | + | continue; |
|
| 304 | + | } |
|
| 305 | + | if stop.is_none() { |
|
| 306 | + | if out.scanned + size > budget.bytes { |
|
| 307 | + | stop = Some("size"); |
|
| 308 | + | } else if started.elapsed() > budget.time { |
|
| 309 | + | stop = Some("time"); |
|
| 310 | + | } |
|
| 311 | + | } |
|
| 312 | + | if stop.is_some() { |
|
| 313 | + | left += size; |
|
| 314 | + | continue; |
|
| 315 | + | } |
|
| 316 | + | let blob_text; |
|
| 317 | + | let text: &[u8] = match (e, &origin) { |
|
| 318 | + | (Edit::Insert { lines, .. }, _) => &lines.bytes()[..size as usize], |
|
| 319 | + | (Edit::Replace { new: Some(r), .. }, Origin::Blob) => { |
|
| 320 | + | let objects = objects.as_ref().expect("made above"); |
|
| 321 | + | match blob_content(bundle, objects, r) { |
|
| 322 | + | Some(t) if memchr::memchr(0, &t[..t.len().min(8000)]).is_none() => { |
|
| 323 | + | blob_text = t; |
|
| 324 | + | &blob_text |
|
| 325 | + | } |
|
| 326 | + | // Binary, as Git and oxy judge it (a NUL early on), |
|
| 327 | + | // or incomplete. |
|
| 328 | + | _ => { |
|
| 329 | + | out.skipped += 1; |
|
| 330 | + | continue; |
|
| 331 | + | } |
|
| 332 | + | } |
|
| 333 | + | } |
|
| 334 | + | _ => continue, |
|
| 335 | + | }; |
|
| 336 | + | for hit in scan_text(rules, text) { |
|
| 337 | + | let rule = &rules.rules[hit.rule]; |
|
| 338 | + | let origin = match origin { |
|
| 339 | + | Origin::Insert { atom } => Origin::Insert { atom: atom + hit.line as u32 - 1 }, |
|
| 340 | + | Origin::Blob => Origin::Blob, |
|
| 341 | + | }; |
|
| 342 | + | out.findings.push(Finding { |
|
| 343 | + | rule: rule.id.clone(), |
|
| 344 | + | description: rule.description.clone(), |
|
| 345 | + | change: ci, |
|
| 346 | + | change_hash: bundle.hashes.get(ci).copied(), |
|
| 347 | + | node, |
|
| 348 | + | origin, |
|
| 349 | + | path: String::new(), |
|
| 350 | + | line: hit.line, |
|
| 351 | + | fingerprint: hit.fingerprint, |
|
| 352 | + | excerpt: hit.excerpt, |
|
| 353 | + | }); |
|
| 354 | + | } |
|
| 355 | + | out.scanned += size; |
|
| 356 | + | } |
|
| 357 | + | } |
|
| 358 | + | if let Some(why) = stop { |
|
| 359 | + | out.cut_short = Some((left, why)); |
|
| 360 | + | } |
|
| 361 | + | out |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | fn object<'b>(bundle: &'b Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef, want: ObjectType) -> Option<&'b [u8]> { |
|
| 365 | + | match objects.get(r).map(|i| &bundle.objects[*i]) { |
|
| 366 | + | Some((ty, body)) if *ty == want => Some(body), |
|
| 367 | + | _ => None, |
|
| 368 | + | } |
|
| 369 | + | } |
|
| 370 | + | ||
| 371 | + | fn blob_of(bundle: &Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef) -> Option<oxy_core::objects::Blob> { |
|
| 372 | + | oxy_core::objects::Blob::decode(object(bundle, objects, r, ObjectType::Blob)?).ok() |
|
| 373 | + | } |
|
| 374 | + | ||
| 375 | + | /// A blob's content from the bundle's chunks; `None` when one is missing. |
|
| 376 | + | fn blob_content(bundle: &Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef) -> Option<Vec<u8>> { |
|
| 377 | + | let blob = blob_of(bundle, objects, r)?; |
|
| 378 | + | let mut content = Vec::with_capacity(blob.length as usize); |
|
| 379 | + | for c in &blob.chunks { |
|
| 380 | + | content.extend_from_slice(object(bundle, objects, c, ObjectType::Chunk)?); |
|
| 381 | + | } |
|
| 382 | + | Some(content) |
|
| 383 | + | } |
|
| 384 | + | ||
| 385 | + | /// Fill in each finding's path, and its line in the file as the change that |
|
| 386 | + | /// adds it leaves the file, from `repo` with the push's changes applied in |
|
| 387 | + | /// memory. Done only when there are findings: it loads the state the push |
|
| 388 | + | /// builds on. A finding it cannot place keeps its line in the inserted text |
|
| 389 | + | /// and is named by the push's own tree operations, or by its node. |
|
| 390 | + | pub fn locate(repo: &Repo, bundle: &Bundle, findings: &mut [Finding]) { |
|
| 391 | + | let mut names: HashMap<NodeId, (NodeId, String)> = HashMap::new(); |
|
| 392 | + | for (c, _) in &bundle.changes { |
|
| 393 | + | for op in &c.tree_ops { |
|
| 394 | + | match op { |
|
| 395 | + | TreeOp::AddNode { node, parent, name, .. } | TreeOp::Move { node, parent, name, .. } => { |
|
| 396 | + | names.insert(*node, (*parent, name.clone())); |
|
| 397 | + | } |
|
| 398 | + | _ => {} |
|
| 399 | + | } |
|
| 400 | + | } |
|
| 401 | + | } |
|
| 402 | + | let placed = locate_in_state(repo, bundle, findings).unwrap_or_default(); |
|
| 403 | + | for (i, f) in findings.iter_mut().enumerate() { |
|
| 404 | + | if let Some((path, line)) = placed.get(&i) { |
|
| 405 | + | f.path = path.clone(); |
|
| 406 | + | if let Some(line) = line { |
|
| 407 | + | f.line = *line; |
|
| 408 | + | } |
|
| 409 | + | continue; |
|
| 410 | + | } |
|
| 411 | + | let mut parts = Vec::new(); |
|
| 412 | + | let mut cur = f.node; |
|
| 413 | + | while let Some((parent, name)) = names.get(&cur) { |
|
| 414 | + | parts.push(name.clone()); |
|
| 415 | + | cur = *parent; |
|
| 416 | + | if parts.len() > 256 { |
|
| 417 | + | break; |
|
| 418 | + | } |
|
| 419 | + | } |
|
| 420 | + | parts.reverse(); |
|
| 421 | + | f.path = if parts.is_empty() { format!("(file {})", &f.node.to_hex()[..12]) } else { parts.join("/") }; |
|
| 422 | + | } |
|
| 423 | + | } |
|
| 424 | + | ||
| 425 | + | /// Each finding's (path, line) by index, as the state after its change has |
|
| 426 | + | /// it; `None` when that state cannot be built. |
|
| 427 | + | fn locate_in_state(repo: &Repo, bundle: &Bundle, findings: &[Finding]) -> Option<HashMap<usize, (String, Option<usize>)>> { |
|
| 428 | + | if bundle.hashes.len() != bundle.changes.len() { |
|
| 429 | + | return None; |
|
| 430 | + | } |
|
| 431 | + | let own: HashSet<ChangeHash> = bundle.hashes.iter().copied().collect(); |
|
| 432 | + | let mut outside: Vec<ChangeHash> = |
|
| 433 | + | bundle.changes.iter().flat_map(|(c, _)| c.deps.iter().copied()).filter(|d| !own.contains(d)).collect(); |
|
| 434 | + | outside.sort(); |
|
| 435 | + | outside.dedup(); |
|
| 436 | + | let mut state = repo.load_state(&outside).ok()?; |
|
| 437 | + | let mut out = HashMap::new(); |
|
| 438 | + | for (ci, (change, _)) in bundle.changes.iter().enumerate() { |
|
| 439 | + | state.add_hashed(bundle.hashes[ci], change.clone()).ok()?; |
|
| 440 | + | let here: Vec<usize> = (0..findings.len()).filter(|i| findings[*i].change == ci).collect(); |
|
| 441 | + | if here.is_empty() { |
|
| 442 | + | continue; |
|
| 443 | + | } |
|
| 444 | + | let view = state.view(); |
|
| 445 | + | let by_node = view.by_node(); |
|
| 446 | + | let mut lines: HashMap<NodeId, Vec<(ChangeHash, u32)>> = HashMap::new(); |
|
| 447 | + | for i in here { |
|
| 448 | + | let f = &findings[i]; |
|
| 449 | + | let Some(entry) = by_node.get(&f.node) else { continue }; |
|
| 450 | + | let line = match f.origin { |
|
| 451 | + | Origin::Insert { atom } => { |
|
| 452 | + | let live = lines.entry(f.node).or_insert_with(|| state.live_lines(&f.node).into_iter().map(|(k, _)| k).collect()); |
|
| 453 | + | live.iter().position(|k| *k == (bundle.hashes[ci], atom)).map(|p| p + 1) |
|
| 454 | + | } |
|
| 455 | + | Origin::Blob => None, |
|
| 456 | + | }; |
|
| 457 | + | out.insert(i, (entry.path.clone(), line)); |
|
| 458 | + | } |
|
| 459 | + | } |
|
| 460 | + | Some(out) |
|
| 461 | + | } |
|
| 462 | + | ||
| 463 | + | /// Findings listed in a refusal; the rest are counted. |
|
| 464 | + | const LISTED: usize = 20; |
|
| 465 | + | ||
| 466 | + | /// The refusal's text: each finding as `path:line: description |
|
| 467 | + | /// (fingerprint F)` with its masked excerpt, then what to do. `via` names |
|
| 468 | + | /// the agent whose token pushed, if one did; `settings` is where a |
|
| 469 | + | /// maintainer allows a finding. |
|
| 470 | + | pub fn refusal(findings: &[Finding], settings: &str, via: &str) -> String { |
|
| 471 | + | use std::fmt::Write; |
|
| 472 | + | let n = findings.len(); |
|
| 473 | + | let mut out = format!( |
|
| 474 | + | "push protection refused this push: it adds {} that look{} like {}, and nothing of it was stored (rules v{}).\n\n", |
|
| 475 | + | if n == 1 { "a value".to_string() } else { format!("{n} values") }, |
|
| 476 | + | if n == 1 { "s" } else { "" }, |
|
| 477 | + | if n == 1 { "a secret" } else { "secrets" }, |
|
| 478 | + | rules().version |
|
| 479 | + | ); |
|
| 480 | + | for f in findings.iter().take(LISTED) { |
|
| 481 | + | let _ = writeln!(out, " {}:{}: {} (fingerprint {})", f.path, f.line, f.description, f.fingerprint); |
|
| 482 | + | if !f.excerpt.is_empty() { |
|
| 483 | + | let _ = writeln!(out, " {}", f.excerpt); |
|
| 484 | + | } |
|
| 485 | + | } |
|
| 486 | + | if n > LISTED { |
|
| 487 | + | let _ = writeln!(out, " … and {} more", n - LISTED); |
|
| 488 | + | } |
|
| 489 | + | out.push_str("\nWhat to do:\n"); |
|
| 490 | + | let (it, is) = if n == 1 { ("it", "it is") } else { ("each", "one is") }; |
|
| 491 | + | let _ = writeln!( |
|
| 492 | + | out, |
|
| 493 | + | " - Take {it} out of the change that adds it: edit the file, then `oxy amend` (or `oxy abandon` a change that only adds it), and push again. Read credentials from the environment or a secret store, not from the repository." |
|
| 494 | + | ); |
|
| 495 | + | let _ = writeln!(out, " - If {is} a real credential, revoke or rotate it as well: it has been on your disk and may be elsewhere."); |
|
| 496 | + | let _ = writeln!( |
|
| 497 | + | out, |
|
| 498 | + | " - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}#push-protection; then push again." |
|
| 499 | + | ); |
|
| 500 | + | if !via.is_empty() { |
|
| 501 | + | let _ = write!( |
|
| 502 | + | out, |
|
| 503 | + | "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for; only a maintainer can allow a fingerprint, at {settings}#push-protection." |
|
| 504 | + | ); |
|
| 505 | + | } |
|
| 506 | + | out.trim_end().to_string() |
|
| 507 | + | } |
|
| 508 | + | ||
| 509 | + | #[cfg(test)] |
|
| 510 | + | mod tests { |
|
| 511 | + | use super::*; |
|
| 512 | + | ||
| 513 | + | #[test] |
|
| 514 | + | fn the_rules_compile() { |
|
| 515 | + | let r = rules(); |
|
| 516 | + | assert!(r.version >= 1); |
|
| 517 | + | assert!(r.rules.len() >= 15); |
|
| 518 | + | } |
|
| 519 | + | ||
| 520 | + | #[test] |
|
| 521 | + | fn the_rules_file_is_not_itself_a_finding() { |
|
| 522 | + | assert!(scan_text(rules(), RULES.as_bytes()).is_empty()); |
|
| 523 | + | } |
|
| 524 | + | ||
| 525 | + | #[test] |
|
| 526 | + | fn the_hub_s_own_push_protection_sources_pass_it() { |
|
| 527 | + | for (name, text) in [ |
|
| 528 | + | ("push_protection.rs", include_str!("push_protection.rs")), |
|
| 529 | + | ("tests/push_protection.rs", include_str!("../tests/push_protection.rs")), |
|
| 530 | + | ("docs/spec.md", include_str!("../../../docs/spec.md")), |
|
| 531 | + | ] { |
|
| 532 | + | assert!(scan_text(rules(), text.as_bytes()).is_empty(), "{name}"); |
|
| 533 | + | } |
|
| 534 | + | } |
|
| 535 | + | ||
| 536 | + | #[test] |
|
| 537 | + | fn a_secret_is_found_masked_and_fingerprinted() { |
|
| 538 | + | let key = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE"); |
|
| 539 | + | let text = format!("[default]\naws_access_key_id = {key}\n"); |
|
| 540 | + | let hits = scan_text(rules(), text.as_bytes()); |
|
| 541 | + | assert_eq!(hits.len(), 1, "{hits:?}"); |
|
| 542 | + | assert_eq!(rules().rules[hits[0].rule].id, "aws-access-key-id"); |
|
| 543 | + | assert_eq!(hits[0].line, 2); |
|
| 544 | + | assert_eq!(hits[0].fingerprint, fingerprint("aws-access-key-id", key.as_bytes())); |
|
| 545 | + | assert_eq!(hits[0].fingerprint.len(), 16); |
|
| 546 | + | assert!(!hits[0].excerpt.contains(&key) && hits[0].excerpt.starts_with("aws_access_key_id = AKIA*"), "{}", hits[0].excerpt); |
|
| 547 | + | } |
|
| 548 | + | ||
| 549 | + | #[test] |
|
| 550 | + | fn an_aws_secret_needs_its_key_id_nearby() { |
|
| 551 | + | let secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY"); |
|
| 552 | + | let alone = format!("aws_secret_access_key = {secret}\n"); |
|
| 553 | + | assert!(scan_text(rules(), alone.as_bytes()).is_empty()); |
|
| 554 | + | let paired = format!("aws_access_key_id = {}{}\n{alone}", "AKIA", "IOSFODNN7EXAMPLE"); |
|
| 555 | + | let ids: Vec<&str> = scan_text(rules(), paired.as_bytes()).iter().map(|h| rules().rules[h.rule].id.as_str()).collect(); |
|
| 556 | + | assert_eq!(ids, ["aws-access-key-id", "aws-secret-access-key"]); |
|
| 557 | + | } |
|
| 558 | + | ||
| 559 | + | #[test] |
|
| 560 | + | fn a_service_account_key_is_reported_once() { |
|
| 561 | + | let pem = format!("-----BEGIN {}-----\\n{}\\n-----END {}-----\\n", "PRIVATE KEY", "A".repeat(64), "PRIVATE KEY"); |
|
| 562 | + | let json = format!("{{\n \"type\": \"service_account\",\n \"private_key\": \"{pem}\"\n}}\n"); |
|
| 563 | + | let hits = scan_text(rules(), json.as_bytes()); |
|
| 564 | + | assert_eq!(hits.len(), 1, "{hits:?}"); |
|
| 565 | + | assert_eq!(rules().rules[hits[0].rule].id, "gcp-service-account"); |
|
| 566 | + | assert_eq!(hits[0].line, 3); |
|
| 567 | + | } |
|
| 568 | + | ||
| 569 | + | #[test] |
|
| 570 | + | fn a_pem_header_alone_is_not_a_key() { |
|
| 571 | + | let doc = format!("A key starts with -----BEGIN {}-----.\n", "RSA PRIVATE KEY"); |
|
| 572 | + | assert!(scan_text(rules(), doc.as_bytes()).is_empty()); |
|
| 573 | + | } |
|
| 574 | + | } |
added crates/hub/src/push_protection.toml +116 −0
| 1 | + | # Push protection's rules (hub spec §15.4, oxygenhub#268), compiled into |
|
| 2 | + | # the hub. Each rule: |
|
| 3 | + | # |
|
| 4 | + | # id stable name, part of every fingerprint: never reuse one |
|
| 5 | + | # description what a refusal calls it |
|
| 6 | + | # regex Rust `regex` syntax, matched on bytes; the capture group |
|
| 7 | + | # named `secret`, when there is one, is the secret, else the |
|
| 8 | + | # whole match |
|
| 9 | + | # keywords optional prefilter: the regex runs on a text only when one |
|
| 10 | + | # of these occurs in it (case-insensitive) |
|
| 11 | + | # requires optional: a regex the same file or inserted text must also |
|
| 12 | + | # match somewhere, for secrets that are only one when paired |
|
| 13 | + | # |
|
| 14 | + | # Rules are tried in order and a match overlapping an earlier rule's is |
|
| 15 | + | # dropped, so the specific rules come before the general ones. Bump |
|
| 16 | + | # `version` whenever a rule is added, changed or removed. |
|
| 17 | + | ||
| 18 | + | version = 1 |
|
| 19 | + | ||
| 20 | + | [[rule]] |
|
| 21 | + | id = "gcp-service-account" |
|
| 22 | + | description = "Google Cloud service account key" |
|
| 23 | + | keywords = ["service_account"] |
|
| 24 | + | regex = '''"private_key"\s*:\s*"(?P<secret>-----BEGIN[^"]{40,})"''' |
|
| 25 | + | requires = '''"type"\s*:\s*"service_account"''' |
|
| 26 | + | ||
| 27 | + | [[rule]] |
|
| 28 | + | id = "aws-access-key-id" |
|
| 29 | + | description = "AWS access key ID" |
|
| 30 | + | keywords = ["akia", "asia", "abia", "acca"] |
|
| 31 | + | regex = '''\b(?P<secret>(?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b''' |
|
| 32 | + | ||
| 33 | + | [[rule]] |
|
| 34 | + | id = "aws-secret-access-key" |
|
| 35 | + | description = "AWS secret access key" |
|
| 36 | + | keywords = ["aws", "secret"] |
|
| 37 | + | regex = '''(?im)(?:aws|secret)[a-z0-9_ .\-]{0,30}['"]?\s*[:=]\s*['"]?(?P<secret>[A-Za-z0-9/+]{40})(?:[^A-Za-z0-9/+=]|$)''' |
|
| 38 | + | requires = '''\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b''' |
|
| 39 | + | ||
| 40 | + | [[rule]] |
|
| 41 | + | id = "azure-storage-connection-string" |
|
| 42 | + | description = "Azure storage account connection string" |
|
| 43 | + | keywords = ["accountkey="] |
|
| 44 | + | regex = '''(?i)DefaultEndpointsProtocol=https?;AccountName=[A-Za-z0-9]+;AccountKey=(?P<secret>[A-Za-z0-9+/]{40,}={0,2})''' |
|
| 45 | + | ||
| 46 | + | [[rule]] |
|
| 47 | + | id = "github-token" |
|
| 48 | + | description = "GitHub token" |
|
| 49 | + | keywords = ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"] |
|
| 50 | + | regex = '''\b(?P<secret>gh[pousr]_[A-Za-z0-9]{36,255})\b''' |
|
| 51 | + | ||
| 52 | + | [[rule]] |
|
| 53 | + | id = "github-fine-grained-token" |
|
| 54 | + | description = "GitHub fine-grained personal access token" |
|
| 55 | + | keywords = ["github_pat_"] |
|
| 56 | + | regex = '''\b(?P<secret>github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\b''' |
|
| 57 | + | ||
| 58 | + | [[rule]] |
|
| 59 | + | id = "gitlab-token" |
|
| 60 | + | description = "GitLab personal access token" |
|
| 61 | + | keywords = ["glpat-"] |
|
| 62 | + | regex = '''\b(?P<secret>glpat-[A-Za-z0-9_\-.]{20,})''' |
|
| 63 | + | ||
| 64 | + | [[rule]] |
|
| 65 | + | id = "oxygenhub-token" |
|
| 66 | + | description = "OxygenHub personal access token" |
|
| 67 | + | keywords = ["oxyhub_"] |
|
| 68 | + | regex = '''\b(?P<secret>oxyhub_[0-9a-f]{64})\b''' |
|
| 69 | + | ||
| 70 | + | [[rule]] |
|
| 71 | + | id = "oxygenhub-agent-grant" |
|
| 72 | + | description = "OxygenHub agent grant token" |
|
| 73 | + | keywords = ["oxyagent_"] |
|
| 74 | + | regex = '''\b(?P<secret>oxyagent_[0-9a-f]{64})\b''' |
|
| 75 | + | ||
| 76 | + | [[rule]] |
|
| 77 | + | id = "oxygenflow-job-token" |
|
| 78 | + | description = "OxygenFlow job token" |
|
| 79 | + | keywords = ["flowjob_"] |
|
| 80 | + | regex = '''\b(?P<secret>flowjob_[0-9a-f]{64})\b''' |
|
| 81 | + | ||
| 82 | + | [[rule]] |
|
| 83 | + | id = "oxygenflow-runner-credential" |
|
| 84 | + | description = "OxygenFlow runner credential" |
|
| 85 | + | keywords = ["flowrunner_"] |
|
| 86 | + | regex = '''\b(?P<secret>flowrunner_[0-9a-f]{64})\b''' |
|
| 87 | + | ||
| 88 | + | [[rule]] |
|
| 89 | + | id = "oxygenflow-registration-token" |
|
| 90 | + | description = "OxygenFlow runner registration token" |
|
| 91 | + | keywords = ["flowreg_"] |
|
| 92 | + | regex = '''\b(?P<secret>flowreg_[0-9a-f]{48})\b''' |
|
| 93 | + | ||
| 94 | + | [[rule]] |
|
| 95 | + | id = "slack-token" |
|
| 96 | + | description = "Slack token" |
|
| 97 | + | keywords = ["xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-"] |
|
| 98 | + | regex = '''\b(?P<secret>xox[baprs]-[A-Za-z0-9-]{10,})''' |
|
| 99 | + | ||
| 100 | + | [[rule]] |
|
| 101 | + | id = "stripe-live-key" |
|
| 102 | + | description = "Stripe live secret key" |
|
| 103 | + | keywords = ["sk_live_", "rk_live_"] |
|
| 104 | + | regex = '''\b(?P<secret>[rs]k_live_[A-Za-z0-9]{24,})\b''' |
|
| 105 | + | ||
| 106 | + | [[rule]] |
|
| 107 | + | id = "openssh-private-key" |
|
| 108 | + | description = "OpenSSH private key" |
|
| 109 | + | keywords = ["openssh private key"] |
|
| 110 | + | regex = '''(?P<secret>-----BEGIN[ ]OPENSSH[ ]PRIVATE[ ]KEY-----[\s\S]{40,}?-----END[ ]OPENSSH[ ]PRIVATE[ ]KEY-----)''' |
|
| 111 | + | ||
| 112 | + | [[rule]] |
|
| 113 | + | id = "private-key" |
|
| 114 | + | description = "PEM private key" |
|
| 115 | + | keywords = ["private key"] |
|
| 116 | + | regex = '''(?P<secret>-----BEGIN[ ](?:[A-Z0-9]+[ ])*PRIVATE[ ]KEY-----[\s\S]{40,}?-----END[ ](?:[A-Z0-9]+[ ])*PRIVATE[ ]KEY-----)''' |
added crates/hub/tests/push_protection.rs +345 −0
| 1 | + | //! Push protection (oxygenhub#268, hub spec §15.4): a push adding what |
|
| 2 | + | //! looks like a secret is refused with the file, line and fingerprint, and |
|
| 3 | + | //! nothing of it is stored; a maintainer's bypass, or the setting off, lets |
|
| 4 | + | //! it through; a large push is scanned only up to the budget. |
|
| 5 | + | //! |
|
| 6 | + | //! Every credential here is made up, in its documented example format, and |
|
| 7 | + | //! put together at run time so this file is not itself a finding. |
|
| 8 | + | ||
| 9 | + | use axum::body::Body; |
|
| 10 | + | use axum::http::{header, Request, StatusCode}; |
|
| 11 | + | use http_body_util::BodyExt; |
|
| 12 | + | use oxy_core::hash::ChangeHash; |
|
| 13 | + | use oxy_core::repo::Repo; |
|
| 14 | + | use oxygenhub::auth::random_hex; |
|
| 15 | + | use oxygenhub::config::Config; |
|
| 16 | + | use oxygenhub::db; |
|
| 17 | + | use oxygenhub::push_protection::fingerprint; |
|
| 18 | + | use oxygenhub::repos::RepoName; |
|
| 19 | + | use oxygenhub::Shared; |
|
| 20 | + | use std::path::Path; |
|
| 21 | + | use tower::ServiceExt; |
|
| 22 | + | ||
| 23 | + | fn hermetic() { |
|
| 24 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 25 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 26 | + | ONCE.call_once(|| { |
|
| 27 | + | let d = tempfile::tempdir().unwrap(); |
|
| 28 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 29 | + | DIR.set(d).ok(); |
|
| 30 | + | }); |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 34 | + | let p = root.join(rel); |
|
| 35 | + | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 36 | + | std::fs::write(p, content).unwrap(); |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | struct Hub { |
|
| 40 | + | app: Shared, |
|
| 41 | + | base: String, |
|
| 42 | + | _dir: tempfile::TempDir, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | async fn hub(scan_limit: Option<u64>) -> Hub { |
|
| 46 | + | hermetic(); |
|
| 47 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 48 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 49 | + | config.registration.setup_code = "test-setup".into(); |
|
| 50 | + | config.registration.mode = "open".into(); |
|
| 51 | + | if let Some(n) = scan_limit { |
|
| 52 | + | config.limits.scan_limit = oxygenhub::config::ByteSize(n); |
|
| 53 | + | } |
|
| 54 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 55 | + | let port = listener.local_addr().unwrap().port(); |
|
| 56 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 57 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 58 | + | let router = oxygenhub::router(app.clone()); |
|
| 59 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 60 | + | Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | struct Res { |
|
| 64 | + | status: StatusCode, |
|
| 65 | + | body: String, |
|
| 66 | + | cookie: Option<String>, |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
|
| 70 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 71 | + | if let Some(c) = cookie { |
|
| 72 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 73 | + | } |
|
| 74 | + | let body = match form { |
|
| 75 | + | Some(f) => { |
|
| 76 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 77 | + | Body::from(f.to_string()) |
|
| 78 | + | } |
|
| 79 | + | None => Body::empty(), |
|
| 80 | + | }; |
|
| 81 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 82 | + | let status = res.status(); |
|
| 83 | + | let cookie = res |
|
| 84 | + | .headers() |
|
| 85 | + | .get(header::SET_COOKIE) |
|
| 86 | + | .and_then(|v| v.to_str().ok()) |
|
| 87 | + | .and_then(|v| v.split(';').next()) |
|
| 88 | + | .and_then(|kv| kv.split_once('=')) |
|
| 89 | + | .map(|(_, v)| v.to_string()); |
|
| 90 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 91 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| 95 | + | send(app, "GET", path, cookie, None).await |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
|
| 99 | + | send(app, "POST", path, cookie, Some(form)).await |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | async fn register(app: &Shared, user: &str) -> String { |
|
| 103 | + | let r = post( |
|
| 104 | + | app, |
|
| 105 | + | "/register", |
|
| 106 | + | None, |
|
| 107 | + | &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 108 | + | ) |
|
| 109 | + | .await; |
|
| 110 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 111 | + | r.cookie.unwrap() |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | fn csrf_of(page: &str) -> String { |
|
| 115 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 116 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | /// A personal access token for `user`, marked as an agent's when `agent` |
|
| 120 | + | /// names one. |
|
| 121 | + | async fn token(app: &Shared, user: &str, agent: Option<&str>) -> String { |
|
| 122 | + | let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap(); |
|
| 123 | + | let (secret, hash) = oxygenhub::auth::new_token(); |
|
| 124 | + | let t = db::accounts::create_token(&app.db, me.id, agent.unwrap_or("laptop"), &hash, "repo:read,repo:write", "", None).await.unwrap(); |
|
| 125 | + | if agent.is_some() { |
|
| 126 | + | db::accounts::set_token_agent(&app.db, me.id, t.id, true).await.unwrap(); |
|
| 127 | + | } |
|
| 128 | + | secret |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | /// andy/demo, public, with a client checkout at `work` pushing as `tok` |
|
| 132 | + | /// and its first change pushed. |
|
| 133 | + | async fn demo(h: &Hub, work: &Path, tok: &str) -> (Repo, ChangeHash) { |
|
| 134 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 135 | + | h.app.repos.create(&repo, None).unwrap(); |
|
| 136 | + | db::repos::create(&h.app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 137 | + | let mut client = Repo::init(work).unwrap(); |
|
| 138 | + | client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
|
| 139 | + | client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 140 | + | client.config.set("remote.origin.token", tok).unwrap(); |
|
| 141 | + | client.save_config().unwrap(); |
|
| 142 | + | write(work, "README.md", b"one\ntwo\nthree\n"); |
|
| 143 | + | client.snapshot("t").unwrap(); |
|
| 144 | + | let base = client.commit("base", &[]).unwrap(); |
|
| 145 | + | // A push without secrets is unaffected. |
|
| 146 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 147 | + | (client, base) |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | fn hosted_heads(app: &Shared) -> Vec<ChangeHash> { |
|
| 151 | + | app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().bookmark_heads().unwrap() |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | /// A PEM private key made for this test and thrown away. |
|
| 155 | + | fn pem_key() -> String { |
|
| 156 | + | rcgen::KeyPair::generate().unwrap().serialize_pem() |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | /// What a file should be refused for: (rule, secret, line). |
|
| 160 | + | type Expected = Vec<(&'static str, String, usize)>; |
|
| 161 | + | ||
| 162 | + | /// Each kind of secret: (file, content, what it is refused for). |
|
| 163 | + | fn samples() -> Vec<(String, String, Expected)> { |
|
| 164 | + | let aws_id = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE"); |
|
| 165 | + | let aws_secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY"); |
|
| 166 | + | let pem = pem_key(); |
|
| 167 | + | let gcp_key = pem.replace('\n', "\\n"); |
|
| 168 | + | let azure_key = format!("{}==", random_hex(43).chars().map(|c| if c == 'a' { 'Z' } else { c }).collect::<String>()); |
|
| 169 | + | let ghp = format!("{}_{}", "ghp", random_hex(18)); |
|
| 170 | + | let pat = format!("{}_{}_{}{}", "github_pat", random_hex(11), random_hex(29), "x"); |
|
| 171 | + | let glpat = format!("{}-{}", "glpat", random_hex(10)); |
|
| 172 | + | let oxyhub = format!("{}_{}", "oxyhub", random_hex(32)); |
|
| 173 | + | let grant = format!("{}_{}", "oxyagent", random_hex(32)); |
|
| 174 | + | let job = format!("{}_{}", "flowjob", random_hex(32)); |
|
| 175 | + | let slack = format!("{}-{}-{}-{}", "xoxb", "123456789012", "1234567890123", random_hex(12)); |
|
| 176 | + | let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12)); |
|
| 177 | + | let ssh_body = (0..4).map(|_| random_hex(30)).collect::<Vec<_>>().join("\n"); |
|
| 178 | + | let ssh = format!("-----{} OPENSSH PRIVATE KEY-----\n{ssh_body}\n-----{} OPENSSH PRIVATE KEY-----", "BEGIN", "END"); |
|
| 179 | + | let head = "# settings\n\n"; |
|
| 180 | + | vec![ |
|
| 181 | + | ( |
|
| 182 | + | "aws/credentials".into(), |
|
| 183 | + | format!("[default]\naws_access_key_id = {aws_id}\naws_secret_access_key = {aws_secret}\n"), |
|
| 184 | + | vec![("aws-access-key-id", aws_id, 2), ("aws-secret-access-key", aws_secret, 3)], |
|
| 185 | + | ), |
|
| 186 | + | ( |
|
| 187 | + | "gcp/key.json".into(), |
|
| 188 | + | format!( |
|
| 189 | + | "{{\n \"type\": \"service_account\",\n \"project_id\": \"example\",\n \"private_key\": \"{gcp_key}\",\n \"client_email\": \"[email protected]\"\n}}\n" |
|
| 190 | + | ), |
|
| 191 | + | vec![("gcp-service-account", gcp_key, 4)], |
|
| 192 | + | ), |
|
| 193 | + | ( |
|
| 194 | + | "azure.env".into(), |
|
| 195 | + | format!("{head}AZURE=DefaultEndpointsProtocol=https;AccountName=example;AccountKey={azure_key};EndpointSuffix=core.windows.net\n"), |
|
| 196 | + | vec![("azure-storage-connection-string", azure_key, 3)], |
|
| 197 | + | ), |
|
| 198 | + | ("gh.env".into(), format!("{head}GH_TOKEN={ghp}\nGH_PAT={pat}\n"), vec![("github-token", ghp, 3), ("github-fine-grained-token", pat, 4)]), |
|
| 199 | + | ("gl.env".into(), format!("{head}GITLAB_TOKEN={glpat}\n"), vec![("gitlab-token", glpat, 3)]), |
|
| 200 | + | ( |
|
| 201 | + | "hub.env".into(), |
|
| 202 | + | format!("{head}OXY_TOKEN={oxyhub}\nGRANT={grant}\nJOB={job}\n"), |
|
| 203 | + | vec![("oxygenhub-token", oxyhub, 3), ("oxygenhub-agent-grant", grant, 4), ("oxygenflow-job-token", job, 5)], |
|
| 204 | + | ), |
|
| 205 | + | ("slack.env".into(), format!("{head}SLACK={slack}\n"), vec![("slack-token", slack, 3)]), |
|
| 206 | + | ("stripe.env".into(), format!("{head}STRIPE={stripe}\n"), vec![("stripe-live-key", stripe, 3)]), |
|
| 207 | + | ("keys/tls.key".into(), format!("{head}{pem}"), vec![("private-key", pem.trim_end().to_string(), 3)]), |
|
| 208 | + | ("keys/id_ed25519".into(), format!("{head}{ssh}\n"), vec![("openssh-private-key", ssh, 3)]), |
|
| 209 | + | ] |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 213 | + | async fn a_push_adding_a_secret_is_refused_until_allowed() { |
|
| 214 | + | let h = hub(None).await; |
|
| 215 | + | let app = &h.app; |
|
| 216 | + | let andy = register(app, "andy").await; |
|
| 217 | + | let tok = token(app, "andy", None).await; |
|
| 218 | + | let work = tempfile::tempdir().unwrap(); |
|
| 219 | + | let (mut client, base) = demo(&h, work.path(), &tok).await; |
|
| 220 | + | assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection, "on by default"); |
|
| 221 | + | ||
| 222 | + | // Each kind, alone, is refused with where it is and its fingerprint, |
|
| 223 | + | // the secret itself never shown; nothing is stored or moved. |
|
| 224 | + | for (file, content, expect) in samples() { |
|
| 225 | + | write(work.path(), &file, content.as_bytes()); |
|
| 226 | + | client.snapshot("t").unwrap(); |
|
| 227 | + | let h1 = client.commit(&format!("add {file}"), &[]).unwrap(); |
|
| 228 | + | let err = client.push("origin", false).err().unwrap_or_else(|| panic!("{file} was accepted")).to_string(); |
|
| 229 | + | assert!(err.contains("HTTP 422") && err.contains("push protection refused this push"), "{err}"); |
|
| 230 | + | assert!(err.contains("oxy amend") && err.contains(&format!("{}/andy/demo/settings#push-protection", h.base)), "{err}"); |
|
| 231 | + | assert!(!err.contains("hint:"), "no credential hint for a refusal: {err}"); |
|
| 232 | + | for (rule, secret, line) in &expect { |
|
| 233 | + | let fp = fingerprint(rule, secret.as_bytes()); |
|
| 234 | + | assert!(err.contains(&format!("{file}:{line}: ")) && err.contains(&format!("(fingerprint {fp})")), "{rule} in {file}: {err}"); |
|
| 235 | + | assert!(!err.contains(secret.as_str()), "{rule}: the secret is shown: {err}"); |
|
| 236 | + | } |
|
| 237 | + | assert_eq!(err.matches("(fingerprint ").count(), expect.len(), "{err}"); |
|
| 238 | + | assert_eq!(hosted_heads(app), vec![base], "nothing moved"); |
|
| 239 | + | let hosted = app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap(); |
|
| 240 | + | assert!(hosted.store().get_change(&h1).is_err(), "nothing stored"); |
|
| 241 | + | client.abandon(&h1).unwrap(); |
|
| 242 | + | let _ = std::fs::remove_file(work.path().join(&file)); |
|
| 243 | + | client.snapshot("t").unwrap(); |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | // A secret inserted into an existing file is placed at its line there. |
|
| 247 | + | let ghp = format!("{}_{}", "ghp", random_hex(18)); |
|
| 248 | + | write(work.path(), "README.md", format!("one\ntwo\n{ghp}\nthree\n").as_bytes()); |
|
| 249 | + | client.snapshot("t").unwrap(); |
|
| 250 | + | let edit = client.commit("token in the readme", &[]).unwrap(); |
|
| 251 | + | let err = client.push("origin", false).err().unwrap().to_string(); |
|
| 252 | + | let fp = fingerprint("github-token", ghp.as_bytes()); |
|
| 253 | + | assert!(err.contains(&format!("README.md:3: GitHub token (fingerprint {fp})")), "{err}"); |
|
| 254 | + | // The refusal is in the push log and among the findings in Settings. |
|
| 255 | + | let log = db::repos::pushes(&app.db, 1, 10).await.unwrap(); |
|
| 256 | + | assert!( |
|
| 257 | + | log.iter().any(|p| !p.ok && p.reference == "main" && p.reason.contains("push protection") && p.reason.contains("README.md:3")), |
|
| 258 | + | "{log:?}" |
|
| 259 | + | ); |
|
| 260 | + | let settings = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 261 | + | assert!(settings.body.contains("id=\"push-protection\"") && settings.body.contains(&fp), "{}", settings.body); |
|
| 262 | + | assert!(!settings.body.contains(&ghp), "the settings page shows no secret"); |
|
| 263 | + | ||
| 264 | + | // A writer cannot allow it. |
|
| 265 | + | let bob = register(app, "bob").await; |
|
| 266 | + | let csrf = csrf_of(&settings.body); |
|
| 267 | + | let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; |
|
| 268 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 269 | + | let bob_page = get(app, "/andy/demo", Some(&bob)).await; |
|
| 270 | + | let bob_csrf = csrf_of(&bob_page.body); |
|
| 271 | + | let r = post(app, "/andy/demo/settings/secret-bypass", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={fp}&reason=mine")).await; |
|
| 272 | + | assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain"); |
|
| 273 | + | assert!(db::push_protection::allowed(&app.db, 1).await.unwrap().is_empty()); |
|
| 274 | + | ||
| 275 | + | // A maintainer allows it, with a reason, audited; the push then lands. |
|
| 276 | + | let r = post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=")).await; |
|
| 277 | + | assert!(r.body.contains("Say why"), "a reason is needed"); |
|
| 278 | + | let r = |
|
| 279 | + | post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=a+revoked+test+token")) |
|
| 280 | + | .await; |
|
| 281 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 282 | + | assert!(r.body.contains(&format!("Fingerprint {fp} is allowed")) && r.body.contains("a revoked test token"), "{}", r.body); |
|
| 283 | + | let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
|
| 284 | + | assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&fp) && a.detail.contains("github-token")), "{audit:?}"); |
|
| 285 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 286 | + | assert_eq!(hosted_heads(app), vec![edit]); |
|
| 287 | + | ||
| 288 | + | // An agent's push is told what an agent should do. |
|
| 289 | + | let agent_tok = token(app, "andy", Some("Claude")).await; |
|
| 290 | + | client.config.set("remote.origin.token", &agent_tok).unwrap(); |
|
| 291 | + | client.save_config().unwrap(); |
|
| 292 | + | let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12)); |
|
| 293 | + | write(work.path(), "pay.env", format!("STRIPE={stripe}\n").as_bytes()); |
|
| 294 | + | client.snapshot("t").unwrap(); |
|
| 295 | + | client.commit("pay", &[]).unwrap(); |
|
| 296 | + | let err = client.push("origin", false).err().unwrap().to_string(); |
|
| 297 | + | assert!(err.contains("an agent's token (Claude)") && err.contains("do not try to get around the refusal"), "{err}"); |
|
| 298 | + | let settings = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 299 | + | assert!(settings.body.contains("via Claude"), "the finding names the agent"); |
|
| 300 | + | ||
| 301 | + | // With push protection off, the same push lands; the change is audited. |
|
| 302 | + | let csrf = csrf_of(&settings.body); |
|
| 303 | + | let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 304 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 305 | + | assert!(!db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection); |
|
| 306 | + | let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
|
| 307 | + | assert!(audit.iter().any(|a| a.action == "repo.push_protection" && a.detail == "off"), "{audit:?}"); |
|
| 308 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 309 | + | let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}&protect=on")).await; |
|
| 310 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 311 | + | assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection); |
|
| 312 | + | } |
|
| 313 | + | ||
| 314 | + | /// Past the scan budget the rest of a push goes unscanned: it is accepted, |
|
| 315 | + | /// and its push log entry says how much was not scanned. |
|
| 316 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 317 | + | async fn a_push_past_the_scan_budget_is_accepted_and_says_so() { |
|
| 318 | + | let h = hub(Some(1024)).await; |
|
| 319 | + | let app = &h.app; |
|
| 320 | + | register(app, "andy").await; |
|
| 321 | + | let tok = token(app, "andy", None).await; |
|
| 322 | + | let work = tempfile::tempdir().unwrap(); |
|
| 323 | + | let (client, _) = demo(&h, work.path(), &tok).await; |
|
| 324 | + | let filler: String = (0..64).map(|i| format!("line {i} of a long generated file\n")).collect(); |
|
| 325 | + | let stripe = format!("{}_{}_{}", "sk", "live", random_hex(12)); |
|
| 326 | + | write(work.path(), "big.txt", format!("{filler}STRIPE={stripe}\n").as_bytes()); |
|
| 327 | + | client.snapshot("t").unwrap(); |
|
| 328 | + | let big = client.commit("big", &[]).unwrap(); |
|
| 329 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 330 | + | assert!(hosted_heads(app).contains(&big)); |
|
| 331 | + | let log = db::repos::pushes(&app.db, 1, 10).await.unwrap(); |
|
| 332 | + | let mut said = false; |
|
| 333 | + | for _ in 0..50 { |
|
| 334 | + | said = db::repos::pushes(&app.db, 1, 10) |
|
| 335 | + | .await |
|
| 336 | + | .unwrap() |
|
| 337 | + | .iter() |
|
| 338 | + | .any(|p| p.ok && p.reason.contains("stopped at its size budget") && p.reason.contains("not scanned")); |
|
| 339 | + | if said { |
|
| 340 | + | break; |
|
| 341 | + | } |
|
| 342 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 343 | + | } |
|
| 344 | + | assert!(said, "{log:?}"); |
|
| 345 | + | } |
modified Cargo.lock +1 −0
| … | 2380 unchanged lines |
||
| 2381 | 2381 | "libc", |
|
| 2382 | 2382 | "lzma-rs", |
|
| 2383 | 2383 | "md-5 0.10.6", |
|
| 2384 | + | "memchr", |
|
| 2384 | 2385 | "oxy-core", |
|
| 2385 | 2386 | "oxy-server", |
|
| 2386 | 2387 | "oxy-strata", |
|
| … | 2487 unchanged lines |
modified crates/hub/Cargo.toml +2 −0
| … | 70 unchanged lines |
||
| 71 | 71 | zip.workspace = true |
|
| 72 | 72 | lettre.workspace = true |
|
| 73 | 73 | regex.workspace = true |
|
| 74 | + | # Push protection's keyword prefilter (oxygenhub#268); already in the tree through regex. |
|
| 75 | + | memchr = "2" |
|
| 74 | 76 | reqwest.workspace = true |
|
| 75 | 77 | # Seals assistant tokens at rest (hub spec §13.4); already in the tree through rustls. |
|
| 76 | 78 | ring = "0.17" |
|
| … | 17 unchanged lines |
modified crates/hub/src/config.rs +15 −1
| … | 226 unchanged lines |
||
| 227 | 227 | pub render_limit: ByteSize, |
|
| 228 | 228 | pub asset_limit: ByteSize, |
|
| 229 | 229 | pub push_body_limit: ByteSize, |
|
| 230 | + | /// Push protection's budget (oxygenhub#268): what one push may have |
|
| 231 | + | /// scanned, the largest file or inserted text scanned, and how long |
|
| 232 | + | /// the scan may take. Past them the rest goes unscanned. |
|
| 233 | + | pub scan_limit: ByteSize, |
|
| 234 | + | pub scan_file_limit: ByteSize, |
|
| 235 | + | pub scan_time_ms: u64, |
|
| 230 | 236 | } |
|
| 231 | 237 | ||
| 232 | 238 | #[derive(Clone, Debug, Deserialize)] |
|
| … | 65 unchanged lines |
||
| 298 | 304 | ||
| 299 | 305 | impl Default for Limits { |
|
| 300 | 306 | fn default() -> Self { |
|
| 301 | - | Limits { render_limit: ByteSize(2 << 20), asset_limit: ByteSize(512 << 20), push_body_limit: ByteSize(4 << 30) } |
|
| 307 | + | let scan = crate::push_protection::Budget::default(); |
|
| 308 | + | Limits { |
|
| 309 | + | render_limit: ByteSize(2 << 20), |
|
| 310 | + | asset_limit: ByteSize(512 << 20), |
|
| 311 | + | push_body_limit: ByteSize(4 << 30), |
|
| 312 | + | scan_limit: ByteSize(scan.bytes), |
|
| 313 | + | scan_file_limit: ByteSize(scan.file), |
|
| 314 | + | scan_time_ms: scan.time.as_millis() as u64, |
|
| 315 | + | } |
|
| 302 | 316 | } |
|
| 303 | 317 | } |
|
| 304 | 318 | ||
| … | 159 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 9 unchanged lines |
||
| 10 | 10 | pub mod notify; |
|
| 11 | 11 | pub mod projects; |
|
| 12 | 12 | pub mod proposals; |
|
| 13 | + | pub mod push_protection; |
|
| 13 | 14 | pub mod releases; |
|
| 14 | 15 | pub mod repos; |
|
| 15 | 16 | pub mod runners; |
|
| … | 152 unchanged lines |
modified crates/hub/src/db/repos.rs +4 −0
| … | 54 unchanged lines |
||
| 55 | 55 | /// only queued ones are cancelled. Off by default. |
|
| 56 | 56 | #[sqlx(default)] |
|
| 57 | 57 | pub flow_keep_running: bool, |
|
| 58 | + | /// Pushes that add possible secrets are refused (#268, migration |
|
| 59 | + | /// 0085). On by default. |
|
| 60 | + | #[sqlx(default)] |
|
| 61 | + | pub push_protection: bool, |
|
| 58 | 62 | /// Counts for the tabs; filled by `get` only. |
|
| 59 | 63 | #[sqlx(default)] |
|
| 60 | 64 | pub open_issues: i64, |
|
| … | 331 unchanged lines |
modified crates/hub/src/lib.rs +7 −1
| … | 29 unchanged lines |
||
| 30 | 30 | pub mod oidc; |
|
| 31 | 31 | pub mod protocol; |
|
| 32 | 32 | pub mod provenance; |
|
| 33 | + | pub mod push_protection; |
|
| 33 | 34 | pub mod render; |
|
| 34 | 35 | pub mod reports; |
|
| 35 | 36 | pub mod repos; |
|
| … | 81 unchanged lines |
||
| 117 | 118 | config.auth.push_tokens.clone(), |
|
| 118 | 119 | tokio::runtime::Handle::current(), |
|
| 119 | 120 | ) |
|
| 120 | - | .with_acceptor(hub_key.identity.clone(), keys::load_secret(&config.keys_dir())?.1); |
|
| 121 | + | .with_acceptor(hub_key.identity.clone(), keys::load_secret(&config.keys_dir())?.1) |
|
| 122 | + | .with_scan_budget(push_protection::Budget { |
|
| 123 | + | bytes: config.limits.scan_limit.0, |
|
| 124 | + | file: config.limits.scan_file_limit.0, |
|
| 125 | + | time: std::time::Duration::from_millis(config.limits.scan_time_ms), |
|
| 126 | + | }); |
|
| 121 | 127 | let slot = backend.app_slot(); |
|
| 122 | 128 | let server = oxy_server::Server::new(backend); |
|
| 123 | 129 | let mail = mail::load(&db, &config.email).await; |
|
| … | 228 unchanged lines |
modified crates/hub/src/protocol.rs +130 −9
| … | 26 unchanged lines |
||
| 27 | 27 | /// The hub's key, which countersigns when it accepted an update a key |
|
| 28 | 28 | /// it certified signed (oxygenhub#241 step 4c, Oxygen spec §14.7). |
|
| 29 | 29 | acceptor: Option<Arc<(oxy_core::identity::Identity, oxy_core::identity::SecretKey)>>, |
|
| 30 | + | /// Push protection's scan budget (#268, `[limits]`). |
|
| 31 | + | scan_budget: crate::push_protection::Budget, |
|
| 32 | + | /// Repositories whose push protection scan stopped early, by id: the |
|
| 33 | + | /// note goes on the push log entry of the push that moves references |
|
| 34 | + | /// (the parts of a long push before it move none). |
|
| 35 | + | scan_notes: Arc<std::sync::Mutex<std::collections::HashMap<i64, String>>>, |
|
| 30 | 36 | } |
|
| 31 | 37 | ||
| 32 | 38 | /// Who a request's credential resolved to. |
|
| … | 18 unchanged lines |
||
| 51 | 57 | site_tokens: Vec<String>, |
|
| 52 | 58 | runtime: tokio::runtime::Handle, |
|
| 53 | 59 | ) -> HubBackend { |
|
| 54 | - | HubBackend { repos, wikis, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)), acceptor: None } |
|
| 60 | + | HubBackend { |
|
| 61 | + | repos, |
|
| 62 | + | wikis, |
|
| 63 | + | pool, |
|
| 64 | + | site_tokens, |
|
| 65 | + | runtime, |
|
| 66 | + | app_hint: Arc::new(std::sync::Mutex::new(None)), |
|
| 67 | + | acceptor: None, |
|
| 68 | + | scan_budget: crate::push_protection::Budget::default(), |
|
| 69 | + | scan_notes: Arc::default(), |
|
| 70 | + | } |
|
| 55 | 71 | } |
|
| 56 | 72 | ||
| 73 | + | /// Scan pushes for secrets within `budget` (#268). |
|
| 74 | + | pub fn with_scan_budget(mut self, budget: crate::push_protection::Budget) -> Self { |
|
| 75 | + | self.scan_budget = budget; |
|
| 76 | + | self |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | /// Who pushes, for the push log: the account (id 0 for a site token) |
|
| 80 | + | /// and, for an agent's token, the agent's name. |
|
| 81 | + | fn pusher(&self, headers: &HeaderMap) -> (Option<(i64, String)>, String) { |
|
| 82 | + | match self.caller(headers) { |
|
| 83 | + | Ok(Caller::Token(account, token)) => { |
|
| 84 | + | (Some((account.id, account.username)), if token.agent { token.name.clone() } else { String::new() }) |
|
| 85 | + | } |
|
| 86 | + | Ok(Caller::Site) => (Some((0, "site token".to_string())), String::new()), |
|
| 87 | + | _ => (None, String::new()), |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | ||
| 57 | 91 | /// Countersign accepted updates by keys this hub certified with `identity`. |
|
| 58 | 92 | pub fn with_acceptor(mut self, identity: oxy_core::identity::Identity, secret: oxy_core::identity::SecretKey) -> Self { |
|
| 59 | 93 | self.acceptor = Some(Arc::new((identity, secret))); |
|
| … | 198 unchanged lines |
||
| 258 | 292 | let backfill = crate::tlog::add_log(&app, row, refs); |
|
| 259 | 293 | if !backfill.is_empty() { |
|
| 260 | 294 | self.block(crate::tlog::log(&app, backfill)); |
|
| 295 | + | } |
|
| 296 | + | } |
|
| 297 | + | ||
| 298 | + | /// Push protection (hub spec §15.4, #268): a push adding what looks |
|
| 299 | + | /// like a secret is refused, saying where, unless the repository turned |
|
| 300 | + | /// it off or a maintainer allowed that finding's fingerprint. |
|
| 301 | + | fn vet_push( |
|
| 302 | + | &self, |
|
| 303 | + | name: &str, |
|
| 304 | + | repo: &Repo, |
|
| 305 | + | bundle: &oxy_core::protocol::Bundle, |
|
| 306 | + | updates: &[oxy_core::protocol::RefUpdate], |
|
| 307 | + | headers: &HeaderMap, |
|
| 308 | + | ) -> Result<(), Failure> { |
|
| 309 | + | if bundle.changes.is_empty() { |
|
| 310 | + | return Ok(()); |
|
| 311 | + | } |
|
| 312 | + | let parsed = RepoName::parse(name).ok_or_else(not_found)?; |
|
| 313 | + | let row = self.row(&crate::wiki::parent_of(&parsed).0)?; |
|
| 314 | + | if !row.push_protection { |
|
| 315 | + | return Ok(()); |
|
| 316 | + | } |
|
| 317 | + | let scan = crate::push_protection::scan(bundle, self.scan_budget); |
|
| 318 | + | if let Some(note) = scan.note() { |
|
| 319 | + | tracing::info!("push to {name}: {note}"); |
|
| 320 | + | self.scan_notes.lock().unwrap().insert(row.id, note); |
|
| 321 | + | } |
|
| 322 | + | if scan.findings.is_empty() { |
|
| 323 | + | return Ok(()); |
|
| 261 | 324 | } |
|
| 325 | + | let allowed = self.block(db::push_protection::allowed(&self.pool, row.id)).map_err(internal)?; |
|
| 326 | + | let mut findings: Vec<_> = scan.findings.into_iter().filter(|f| !allowed.contains(&f.fingerprint)).collect(); |
|
| 327 | + | if findings.is_empty() { |
|
| 328 | + | return Ok(()); |
|
| 329 | + | } |
|
| 330 | + | crate::push_protection::locate(repo, bundle, &mut findings); |
|
| 331 | + | let (pusher, via) = self.pusher(headers); |
|
| 332 | + | let base = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()).map(|a| a.config.base_url().to_string()); |
|
| 333 | + | let settings = format!("{}/{}/{}/settings", base.unwrap_or_default(), row.owner, row.name); |
|
| 334 | + | let text = crate::push_protection::refusal(&findings, &settings, &via); |
|
| 335 | + | // Recorded for the maintainers (Settings › Push protection) and in |
|
| 336 | + | // the push log, as every refused push is. |
|
| 337 | + | let records: Vec<db::push_protection::NewFinding> = findings |
|
| 338 | + | .iter() |
|
| 339 | + | .map(|f| db::push_protection::NewFinding { |
|
| 340 | + | rule: &f.rule, |
|
| 341 | + | description: &f.description, |
|
| 342 | + | path: &f.path, |
|
| 343 | + | line: f.line as i64, |
|
| 344 | + | fingerprint: &f.fingerprint, |
|
| 345 | + | excerpt: &f.excerpt, |
|
| 346 | + | }) |
|
| 347 | + | .collect(); |
|
| 348 | + | let who = pusher.as_ref().map(|(id, n)| (*id, n.as_str())); |
|
| 349 | + | if let Err(e) = self.block(db::push_protection::record(&self.pool, row.id, who.map(|w| w.1).unwrap_or(""), &via, &records)) { |
|
| 350 | + | tracing::warn!("recording push protection findings for {name}: {e}"); |
|
| 351 | + | } |
|
| 352 | + | let reason = format!( |
|
| 353 | + | "push protection: {} possible secret{} ({}); see Settings › Push protection", |
|
| 354 | + | findings.len(), |
|
| 355 | + | if findings.len() == 1 { "" } else { "s" }, |
|
| 356 | + | findings.iter().take(3).map(|f| format!("{}:{}", f.path, f.line)).collect::<Vec<_>>().join(", ") |
|
| 357 | + | ); |
|
| 358 | + | let refs: Vec<String> = if updates.is_empty() { |
|
| 359 | + | vec![oxy_core::repo::OBJECTS_RESULT.to_string()] |
|
| 360 | + | } else { |
|
| 361 | + | updates.iter().map(|u| u.name.clone()).collect() |
|
| 362 | + | }; |
|
| 363 | + | let outcomes: Vec<db::repos::PushOutcome> = refs |
|
| 364 | + | .into_iter() |
|
| 365 | + | .map(|reference| db::repos::PushOutcome { |
|
| 366 | + | reference, |
|
| 367 | + | ok: false, |
|
| 368 | + | reason: reason.clone(), |
|
| 369 | + | old_state: String::new(), |
|
| 370 | + | new_state: String::new(), |
|
| 371 | + | heads: 0, |
|
| 372 | + | changes: 0, |
|
| 373 | + | }) |
|
| 374 | + | .collect(); |
|
| 375 | + | if let Err(e) = self.block(db::repos::record_push_via(&self.pool, row.id, who, &via, &outcomes)) { |
|
| 376 | + | tracing::warn!("recording refused push to {name}: {e}"); |
|
| 377 | + | } |
|
| 378 | + | tracing::info!("push to {name} refused by push protection: {} finding(s)", findings.len()); |
|
| 379 | + | Err(Failure(StatusCode::UNPROCESSABLE_ENTITY, text)) |
|
| 262 | 380 | } |
|
| 263 | 381 | ||
| 264 | 382 | fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) { |
|
| … | 4 unchanged lines |
||
| 269 | 387 | } |
|
| 270 | 388 | let Ok(row) = self.row(&parsed) else { return }; |
|
| 271 | 389 | let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 272 | - | let (pusher, via) = match self.caller(headers) { |
|
| 273 | - | Ok(Caller::Token(account, token)) => { |
|
| 274 | - | (Some((account.id, account.username)), if token.agent { token.name.clone() } else { String::new() }) |
|
| 275 | - | } |
|
| 276 | - | Ok(Caller::Site) => (Some((0, "site token".to_string())), String::new()), |
|
| 277 | - | _ => (None, String::new()), |
|
| 278 | - | }; |
|
| 390 | + | let (pusher, via) = self.pusher(headers); |
|
| 279 | 391 | let pool = self.pool.clone(); |
|
| 280 | 392 | // A part of a long push (objects alone) moves nothing: the push it |
|
| 281 | 393 | // belongs to is recorded when its last part names the references. |
|
| 282 | - | let results: Vec<db::repos::PushOutcome> = report |
|
| 394 | + | let mut results: Vec<db::repos::PushOutcome> = report |
|
| 283 | 395 | .results |
|
| 284 | 396 | .iter() |
|
| 285 | 397 | .filter(|r| r.name != oxy_core::repo::OBJECTS_RESULT) |
|
| … | 10 unchanged lines |
||
| 296 | 408 | } |
|
| 297 | 409 | }) |
|
| 298 | 410 | .collect(); |
|
| 411 | + | // Push protection's scan stopped early on this push or a part of it |
|
| 412 | + | // (#268): its accepted references say so in the log. |
|
| 413 | + | if !results.is_empty() { |
|
| 414 | + | if let Some(note) = self.scan_notes.lock().unwrap().remove(&row.id) { |
|
| 415 | + | for r in results.iter_mut().filter(|r| r.ok) { |
|
| 416 | + | r.reason = if r.reason.is_empty() { note.clone() } else { format!("{}; {note}", r.reason) }; |
|
| 417 | + | } |
|
| 418 | + | } |
|
| 419 | + | } |
|
| 299 | 420 | // Messages of the changes stored: `closes #N` when the default |
|
| 300 | 421 | // bookmark moved, and `#N` references on any bookmark (hub spec §8). |
|
| 301 | 422 | let bookmarks: Vec<String> = report |
|
| … | 166 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 301 unchanged lines |
||
| 302 | 302 | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 303 | 303 | .route("/{owner}/{name}/settings/flow-priority", post(repo_settings::flow_priority)) |
|
| 304 | 304 | .route("/{owner}/{name}/settings/agent-gates", post(repo_settings::agent_gates)) |
|
| 305 | + | .route("/{owner}/{name}/settings/push-protection", post(repo_settings::push_protection)) |
|
| 306 | + | .route("/{owner}/{name}/settings/secret-bypass", post(repo_settings::secret_bypass)) |
|
| 305 | 307 | .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede)) |
|
| 306 | 308 | .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion)) |
|
| 307 | 309 | .route("/{owner}/{name}/settings/backup", post(repo_settings::backup)) |
|
| … | 494 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +90 −0
| … | 195 unchanged lines |
||
| 196 | 196 | coverage: crate::coverage::Rules, |
|
| 197 | 197 | /// Its owner's backup, when one is set up (docs/backups.md §5). |
|
| 198 | 198 | backup_owner: Option<crate::backups::Setting>, |
|
| 199 | + | /// Push protection (#268): the findings of recent refused pushes not |
|
| 200 | + | /// yet allowed, and the fingerprints allowed. |
|
| 201 | + | push_findings: Vec<db::push_protection::FindingRow>, |
|
| 202 | + | secret_bypasses: Vec<db::push_protection::Bypass>, |
|
| 199 | 203 | } |
|
| 200 | 204 | ||
| 201 | 205 | impl RepoSettingsPage { |
|
| … | 47 unchanged lines |
||
| 249 | 253 | let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 250 | 254 | let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 251 | 255 | let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 256 | + | let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 257 | + | let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 252 | 258 | Ok(Page(RepoSettingsPage { |
|
| 259 | + | push_findings, |
|
| 260 | + | secret_bypasses, |
|
| 253 | 261 | backup_owner, |
|
| 254 | 262 | coverage, |
|
| 255 | 263 | storage, |
|
| … | 628 unchanged lines |
||
| 884 | 892 | } |
|
| 885 | 893 | ||
| 886 | 894 | #[derive(Deserialize)] |
|
| 895 | + | pub struct PushProtectionForm { |
|
| 896 | + | #[serde(rename = "_csrf")] |
|
| 897 | + | csrf_token: String, |
|
| 898 | + | /// `on` when the box is ticked. |
|
| 899 | + | #[serde(default)] |
|
| 900 | + | protect: String, |
|
| 901 | + | } |
|
| 902 | + | ||
| 903 | + | /// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether |
|
| 904 | + | /// pushes adding what looks like a secret are refused; for maintainers, |
|
| 905 | + | /// audited. |
|
| 906 | + | pub async fn push_protection( |
|
| 907 | + | State(app): State<Shared>, |
|
| 908 | + | viewer: Viewer, |
|
| 909 | + | Path((owner, name)): Path<(String, String)>, |
|
| 910 | + | Form(f): Form<PushProtectionForm>, |
|
| 911 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 912 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 913 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 914 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 915 | + | let on = f.protect == "on"; |
|
| 916 | + | db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 917 | + | accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" }) |
|
| 918 | + | .await |
|
| 919 | + | .ok(); |
|
| 920 | + | 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 | + | let notice = if on { |
|
| 922 | + | "Push protection is on: a push that adds what looks like a secret is refused, saying where." |
|
| 923 | + | } else { |
|
| 924 | + | "Push protection is off: pushes are no longer scanned for secrets here." |
|
| 925 | + | }; |
|
| 926 | + | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 927 | + | } |
|
| 928 | + | ||
| 929 | + | #[derive(Deserialize)] |
|
| 930 | + | pub struct SecretBypassForm { |
|
| 931 | + | #[serde(rename = "_csrf")] |
|
| 932 | + | csrf_token: String, |
|
| 933 | + | fingerprint: String, |
|
| 934 | + | #[serde(default)] |
|
| 935 | + | reason: String, |
|
| 936 | + | } |
|
| 937 | + | ||
| 938 | + | /// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one |
|
| 939 | + | /// push protection finding through, by its fingerprint, with a reason; for |
|
| 940 | + | /// maintainers, audited as `secret.bypass`. |
|
| 941 | + | pub async fn secret_bypass( |
|
| 942 | + | State(app): State<Shared>, |
|
| 943 | + | viewer: Viewer, |
|
| 944 | + | Path((owner, name)): Path<(String, String)>, |
|
| 945 | + | Form(f): Form<SecretBypassForm>, |
|
| 946 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 947 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 948 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 949 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 950 | + | let fingerprint = f.fingerprint.trim().to_ascii_lowercase(); |
|
| 951 | + | let reason = f.reason.trim(); |
|
| 952 | + | let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) { |
|
| 953 | + | Some("A fingerprint is the 16 hexadecimal digits a refused push printed.") |
|
| 954 | + | } else if reason.is_empty() { |
|
| 955 | + | Some("Say why this is not a secret (an example key, a test fixture): the reason is kept with the fingerprint and in the audit log.") |
|
| 956 | + | } else if reason.chars().count() > 500 { |
|
| 957 | + | Some("Keep the reason under 500 characters.") |
|
| 958 | + | } else { |
|
| 959 | + | None |
|
| 960 | + | }; |
|
| 961 | + | if let Some(e) = error { |
|
| 962 | + | return settings_page(&app, shell, row, role, None, Some(e.into())).await; |
|
| 963 | + | } |
|
| 964 | + | let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint) |
|
| 965 | + | .await |
|
| 966 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 967 | + | .unwrap_or_default(); |
|
| 968 | + | let by = viewer.account().map(|a| (a.id, a.username.as_str())); |
|
| 969 | + | db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 970 | + | let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") }; |
|
| 971 | + | accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok(); |
|
| 972 | + | let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it."); |
|
| 973 | + | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 974 | + | } |
|
| 975 | + | ||
| 976 | + | #[derive(Deserialize)] |
|
| 887 | 977 | pub struct SupersedeForm { |
|
| 888 | 978 | #[serde(rename = "_csrf")] |
|
| 889 | 979 | csrf_token: String, |
|
| … | 286 unchanged lines |
modified docs/spec.md +12 −0
| … | 657 unchanged lines |
||
| 658 | 658 | ||
| 659 | 659 | Object sizes are bounded by the wire protocol server's body limits (per push and per object); rendering is bounded by `render_limit` and by time budgets; archives are streamed; symlinks in trees are shown, never followed on the server; subrepository pins are displayed, never fetched by the hub. |
|
| 660 | 660 | ||
| 661 | + | **Push protection** (oxygenhub#268). A push that adds what looks like a credential is refused before any of it is stored, so the secret never enters the repository's history. The hub implements the protocol server's `vet_push` hook (Oxygen spec §16.4): after the push is decoded and before anything is stored, bound or moved, it scans what the push *adds*, the lines its changes insert and the new blobs that replace a file's content (blobs the hub already holds are not new), never history it holds. |
|
| 662 | + | ||
| 663 | + | - **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. |
|
| 664 | + | - **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. |
|
| 665 | + | - **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. |
|
| 666 | + | - **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. |
|
| 667 | + | - **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. |
|
| 668 | + | - **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). |
|
| 669 | + | ||
| 661 | 670 | ### 15.5 Keys and TLS |
|
| 662 | 671 | ||
| 663 | 672 | The hub delegate key lives in `<data>/keys/` with mode `0600`. Its identity object is published at `/.well-known/oxyhub-key` so policy holders can admit it. TLS: the hub serves HTTPS itself with the configured certificate, or plain HTTP behind a terminating proxy, or on the developer edition a self-signed certificate it generates at `init` (with `rcgen`) whose CA is downloadable from the trust page and usable with `OXY_CA`. |
|
| … | 43 unchanged lines |
||
| 707 | 716 | render_limit = "2MiB" |
|
| 708 | 717 | asset_limit = "512MiB" |
|
| 709 | 718 | push_body_limit = "4GiB" |
|
| 719 | + | scan_limit = "16MiB" # push protection (§15.4): scanned per push |
|
| 720 | + | scan_file_limit = "1MiB" # larger files and inserts are not scanned |
|
| 721 | + | scan_time_ms = 2000 |
|
| 710 | 722 | ||
| 711 | 723 | [email] |
|
| 712 | 724 | smtp = "smtp://…" # optional |
|
| … | 71 unchanged lines |
modified templates/repo_settings.html +36 −0
| … | 14 unchanged lines |
||
| 15 | 15 | <nav class="section-nav" aria-label="Sections"> |
|
| 16 | 16 | <a href="#general">General</a> |
|
| 17 | 17 | <a href="#landing">Landing requirements</a> |
|
| 18 | + | <a href="#push-protection">Push protection</a> |
|
| 18 | 19 | <a href="#git-mirror">Git mirror</a> |
|
| 19 | 20 | <a href="#checks">Required checks</a> |
|
| 20 | 21 | <a href="#flow">OxygenFlow</a> |
|
| … | 32 unchanged lines |
||
| 53 | 54 | </div> |
|
| 54 | 55 | <button type="submit">SAVE</button> |
|
| 55 | 56 | </form> |
|
| 57 | + | <section class="panel" id="push-protection"> |
|
| 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> |
|
| 60 | + | <form class="card" method="post" action="{{ repo.url }}/settings/push-protection"> |
|
| 61 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 62 | + | <label class="check"><input type="checkbox" name="protect"{% if row.push_protection %} checked{% endif %}> Refuse pushes that add possible secrets</label> |
|
| 63 | + | <button type="submit">SAVE</button> |
|
| 64 | + | </form> |
|
| 65 | + | <h3>Recent findings</h3> |
|
| 66 | + | {% if push_findings.is_empty() %}<p class="mute small">No refused push is waiting on a decision.</p>{% else %} |
|
| 67 | + | <p class="small mute">From refused pushes, newest first. Allow a finding only when it is not a real secret (an example key, a test fixture): a push adding that same value then passes.</p> |
|
| 68 | + | <table class="small"> |
|
| 69 | + | <tr><th>When</th><th>Who</th><th>Where</th><th>What</th><th>Fingerprint</th><th>Allow, because…</th></tr> |
|
| 70 | + | {% for f in push_findings %} |
|
| 71 | + | <tr><td><time title="{{ f.at_ms|when_i }}">{{ f.at_ms|ago_i }}</time></td><td>{% if f.pusher.is_empty() %}<span class="mute">anonymous</span>{% else %}{{ f.pusher }}{% endif %}{% if !f.via.is_empty() %} <span class="mute">via {{ f.via }}</span>{% endif %}</td><td class="px">{{ f.path }}:{{ f.line }}</td><td>{{ f.description }}<br><code class="mute">{{ f.excerpt }}</code></td><td><code>{{ f.fingerprint }}</code></td> |
|
| 72 | + | <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" placeholder="not a secret: …" required><button type="submit">allow</button></form></td></tr> |
|
| 73 | + | {% endfor %} |
|
| 74 | + | </table>{% endif %} |
|
| 75 | + | <form class="card" method="post" action="{{ repo.url }}/settings/secret-bypass"> |
|
| 76 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 77 | + | <div class="row"> |
|
| 78 | + | <div><label for="bypass-fingerprint">Fingerprint</label><input type="text" id="bypass-fingerprint" name="fingerprint" placeholder="16 hex digits from the refusal" required></div> |
|
| 79 | + | <div><label for="bypass-reason">Reason</label><input type="text" id="bypass-reason" name="reason" placeholder="not a secret: …" required></div> |
|
| 80 | + | </div> |
|
| 81 | + | <button type="submit">ALLOW</button> |
|
| 82 | + | </form> |
|
| 83 | + | {% if !secret_bypasses.is_empty() %} |
|
| 84 | + | <h3>Allowed</h3> |
|
| 85 | + | <table class="small"> |
|
| 86 | + | <tr><th>Fingerprint</th><th>Rule</th><th>Reason</th><th>By</th><th>When</th></tr> |
|
| 87 | + | {% 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> |
|
| 89 | + | {% endfor %} |
|
| 90 | + | </table>{% endif %} |
|
| 91 | + | </section> |
|
| 56 | 92 | <form class="card wide" id="git-mirror" method="post" action="{{ repo.url }}/settings/mirror"> |
|
| 57 | 93 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 58 | 94 | <h2>Git mirror</h2> |
|
| … | 213 unchanged lines |