mod git;
mod range;
mod scan;
use std::path::Path;
use std::time::{Duration, Instant};
use openmoat_audit::KnownSecrets;
use openmoat_core::{Decision, Push, Verdict};
use git::{Git, Unchecked};
use crate::environment::Snapshot;
pub const SECRET_RULE: &str = "push-secret";
pub const RUNS_LATER_RULE: &str = "push-runs-later";
pub const UNCHECKED_RULE: &str = "push-unchecked";
const BUDGET: Duration = Duration::from_secs(10);
#[cfg(debug_assertions)]
const BUDGET_ENV: &str = "MOAT_TEST_PUSH_BUDGET_MS";
fn budget() -> Duration {
#[cfg(debug_assertions)]
if let Some(ms) = std::env::var(BUDGET_ENV)
.ok()
.and_then(|v| v.parse::<u64>().ok())
{
return Duration::from_millis(ms);
}
BUDGET
}
#[derive(Debug, Default)]
struct Findings {
secret: Option<String>,
runs_later: Vec<String>,
unchecked: Vec<String>,
}
pub fn check(
pushes: &[Push],
snapshot: &Snapshot,
known: &KnownSecrets,
tainted: bool,
decision: Decision,
) -> Decision {
if pushes.is_empty() || decision.verdict == Verdict::Deny {
return decision;
}
let Some(git) = snapshot.program("git") else {
return tighten(
decision,
Verdict::Ask,
UNCHECKED_RULE,
"what the push would publish was not checked: no git with an absolute path is in \
the search path `openmoat init` recorded"
.to_owned(),
);
};
let budget = budget();
let at = (Instant::now() + budget, budget);
let findings = inspect(pushes, &git, known, tainted, at);
if let Some(secret) = findings.secret {
return tighten(
decision,
Verdict::Deny,
SECRET_RULE,
format!(
"{secret}; a push publishes every commit it sends, so remove it from those \
commits first"
),
);
}
let mut decision = decision;
if tainted && !findings.runs_later.is_empty() {
decision = tighten(
decision,
Verdict::Ask,
RUNS_LATER_RULE,
format!(
"the session read untrusted content or a secret, and the push changes {}, \
which CI or a tool runs later with the repository's secrets",
findings.runs_later.join(", ")
),
);
}
for why in findings.unchecked {
decision = tighten(
decision,
Verdict::Ask,
UNCHECKED_RULE,
format!("what the push would publish was not checked: {why}"),
);
}
decision
}
fn inspect(
pushes: &[Push],
program: &Path,
known: &KnownSecrets,
tainted: bool,
(deadline, budget): (Instant, Duration),
) -> Findings {
let mut findings = Findings::default();
for push in pushes {
let spec = range::spec(&push.argv);
for dir in &push.dirs {
let Some(dir) = dir else {
note(
&mut findings.unchecked,
"it may run in a directory or repository that is not known".to_owned(),
);
continue;
};
let git = Git::new(program, dir, deadline, budget);
let scanned = range::revisions(&git, &spec).and_then(|revs| match revs {
Some(revs) => scan::scan(&git, &revs, known, tainted).map(Some),
None => Ok(None),
});
match scanned {
Ok(Some(found)) => {
if found.secret.is_some() {
findings.secret = found.secret;
return findings;
}
for file in found.runs_later {
if !findings.runs_later.contains(&file) {
findings.runs_later.push(file);
}
}
}
Ok(None) => {}
Err(Unchecked(why)) => note(&mut findings.unchecked, why),
}
}
}
findings
}
fn note(list: &mut Vec<String>, why: String) {
if !list.contains(&why) {
list.push(why);
}
}
fn tighten(mut decision: Decision, verdict: Verdict, rule: &str, reason: String) -> Decision {
if decision.verdict > verdict {
decision.context.push(reason);
return decision;
}
if decision.verdict < verdict {
let earlier: Vec<String> = std::mem::take(&mut decision.reasons);
decision.context.extend(earlier);
decision.rules.clear();
decision.verdict = verdict;
}
if !decision.rules.iter().any(|r| r == rule) {
decision.rules.push(rule.to_owned());
}
decision.reasons.push(reason);
decision
}
#[cfg(test)]
mod testrepo;
#[cfg(test)]
mod tests;