Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-policy/src/gates/mod.rs

//! The gates.
//!
//! Each one is a [`Gate`](crate::Gate) impl over a slice of
//! [`PolicyConfig`](crate::PolicyConfig). None of them shells out, forks, or
//! reads a file; a gate is a pure function of the push context and its own
//! configuration, which is what makes them testable at all and what makes them
//! runnable on a machine that is PID 1 with no filesystem.

mod ai_trailers;
mod encrypted;
mod limits;
mod protection;
mod push_options;
mod refname;
mod signed;

pub use ai_trailers::AiTrailerGate;
pub use encrypted::{check_pushed, EncryptedGate, PushedObjects};
pub use limits::LimitsGate;
pub use protection::BranchProtectionGate;
pub use push_options::PushOptionsGate;
pub use refname::RefNameGate;
pub use signed::SignedCommitsGate;

use crate::config::SignerScope;
use crate::context::{PushContext, RefUpdate};
use crate::error::Result;
use crate::signature::{self, SignatureCheck};
use crate::Verdict;

/// Verify every commit this update introduces.
///
/// Shared by [`SignedCommitsGate`] and by [`BranchProtectionGate`]'s
/// `require_signature`, because two copies of "which commits count and which
/// keys count" is precisely the pair that drifts (one implementation): the
/// protection rule would keep checking while the standalone gate quietly
/// stopped, and both would be green.
///
/// `checked` carries commits already verified earlier in this push, so a commit
/// reachable from two pushed branches is verified once.
pub(crate) fn verify_update_signatures(
    ctx: &PushContext<'_>,
    update: &RefUpdate,
    scope: SignerScope,
    namespace: &str,
    budget: usize,
    checked: &mut std::collections::HashSet<gix_hash::ObjectId>,
    gate: &str,
) -> Result<Verdict> {
    let Some(new_tip) = update.new else {
        return Ok(Verdict::Allow); // a delete adds no commits
    };

    let keys = match scope {
        SignerScope::AnyRegisteredKey => ctx.keys.all_keys(),
        SignerScope::PusherKeysOnly => ctx.keys.keys_for(&ctx.principal.id),
    };

    let introduced = ctx.new_object_set();

    // ── the tag chain, before the commits under it (GUN-003) ───────────────
    //
    // A ref may point at an annotated tag object, `refs/heads/*` included:
    // nothing in git says a branch names a commit, and `verify_connectivity`
    // peels one happily. `introduced_commits` used to `continue` on the tag
    // and end with an EMPTY commit list, which the loop below then iterated
    // zero times and answered `Allow` for. `git tag -a v1 <unsigned-commit> &&
    // git push origin refs/tags/v1` — two stock git commands — was the whole
    // exploit.
    //
    // Peeling alone is not the whole answer. A tag object is a signable git
    // object in its own right, and a `require_signature` pattern matching
    // `refs/tags/*` would otherwise accept an unsigned tag as long as what it
    // pointed at was signed. So every tag this push introduced is checked too,
    // against the same key set and the same namespace.
    let (peeled, tags) = crate::ancestry::peel_to_commit(ctx.objects, &new_tip)?;
    for tag in tags {
        if !introduced.contains(&tag) || !checked.insert(tag) {
            continue;
        }
        match signature::check_tag(ctx.objects, &tag, &keys, namespace)? {
            SignatureCheck::Verified(verified) => {
                tracing::debug!(
                    gate,
                    tag = %tag,
                    key = %verified.fingerprint,
                    "tag signature verified"
                );
            }
            SignatureCheck::Problem(problem) => {
                return Ok(Verdict::reject_ref(
                    update.name.clone(),
                    format!("tag {tag}: {problem}"),
                ));
            }
        }
    }
    let Some(new_tip) = peeled else {
        // A tag of a tree or of a blob. There are no commits under it to
        // check, and its own signature has just been checked; nothing is being
        // waved through, the loop below simply has nothing to walk.
        return Ok(Verdict::Allow);
    };

    // ── what this update ADDS, which is not the same as what the pack sent ──
    //
    // GUN-013. `introduced_commits` walks only the pack's own new-object set,
    // so a commit that is ALREADY in the repository is never signature-checked
    // however the reference comes to point at it. With GUN-003 that made two
    // ordinary pushes enough to land unsigned code on a protected branch: push
    // the commit under a tag (no check), then move the branch onto it (the
    // commit is no longer new, so still no check).
    //
    // For an UPDATE the honest set is the range `old..new` — every commit the
    // move makes reachable — whether or not those commits arrived in this
    // pack. `old` is peeled for the same reason `new` is: it may be a tag.
    //
    // For a CREATE there is no `old` and therefore no range. The set stays the
    // pack's, because the alternative is "everything reachable from the new
    // tip", which on a first push of a real repository is the whole history
    // and on a branch created at an existing commit is the whole history
    // again. That is a residual: `refs/heads/x` created at an unsigned commit
    // the repository already holds is not checked. Closing it needs the
    // repository's other ref tips as stop points, which `PushContext` does not
    // carry, and that is a seam change rather than a line.
    //
    // Cost: on a fast-forward the walk stops at `old`, so it is O(commits
    // added) exactly as before. On a force-push over unrelated history it
    // walks further, bounded by `budget` — and an exhausted budget is an
    // error, which every gate turns into a refusal.
    let commits = match update.old {
        Some(old) => {
            let (old_peeled, _) = crate::ancestry::peel_to_commit(ctx.objects, &old)?;
            match old_peeled {
                Some(old) => crate::ancestry::commits_between(ctx.objects, &new_tip, &old, budget)?,
                // The old tip was a tag of a tree or a blob: no commit to
                // start the range at, so fall back to the pack's set rather
                // than walking the repository.
                None => {
                    crate::ancestry::introduced_commits(ctx.objects, &new_tip, &introduced, budget)?
                }
            }
        }
        None => crate::ancestry::introduced_commits(ctx.objects, &new_tip, &introduced, budget)?,
    };

    for commit in commits {
        if !checked.insert(commit) {
            continue;
        }
        match signature::check_commit(ctx.objects, &commit, &keys, namespace)? {
            SignatureCheck::Verified(verified) => {
                tracing::debug!(
                    gate,
                    commit = %commit,
                    key = %verified.fingerprint,
                    "commit signature verified"
                );
            }
            SignatureCheck::Problem(problem) => {
                return Ok(Verdict::reject_ref(
                    update.name.clone(),
                    format!("commit {commit}: {problem}"),
                ));
            }
        }
    }
    Ok(Verdict::Allow)
}