OXYGENHUB

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