use anyhow::{bail, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Probe {
pub secret: String,
pub command: String,
#[serde(default = "default_input")]
pub input: String,
#[serde(default, rename = "invalid-exit")]
pub invalid_exit: Vec<i32>,
#[serde(default = "default_timeout")]
pub timeout: u64,
}
fn default_input() -> String {
"{}".into()
}
fn default_timeout() -> u64 {
30
}
impl Probe {
pub fn validate(&self, name: &str) -> Result<()> {
if crate::render::split_reference(&self.secret).is_none() {
bail!(
"probe `{name}`: secret `{}` is not a `scheme://...` reference",
self.secret
);
}
if crate::render::shell_words(&self.command).is_empty() {
bail!("probe `{name}`: command is empty");
}
if self.timeout == 0 {
bail!("probe `{name}`: timeout must be at least one second");
}
if self.invalid_exit.contains(&0) {
bail!("probe `{name}`: invalid-exit may not contain 0, which means the secret is accepted");
}
Ok(())
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Ran {
Exited(i32),
Failed(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Accepted,
Rejected(i32),
Unchecked(String),
}
pub fn judge(probe: &Probe, ran: Ran) -> Verdict {
match ran {
Ran::Failed(why) => Verdict::Unchecked(why),
Ran::Exited(0) => Verdict::Accepted,
Ran::Exited(code) => {
if probe.invalid_exit.is_empty() || probe.invalid_exit.contains(&code) {
Verdict::Rejected(code)
} else {
Verdict::Unchecked(format!(
"the check exited {code}, which is not one of invalid-exit"
))
}
}
}
}
pub fn probe_all(
probes: &BTreeMap<String, Probe>,
fetch: &mut dyn FnMut(&str, &str) -> Result<String>,
run: &dyn Fn(&Probe, &str) -> Ran,
) -> Vec<(String, Verdict)> {
let mut out = Vec::new();
for (name, probe) in probes {
let verdict = match crate::render::split_reference(&probe.secret) {
None => Verdict::Unchecked("secret is not a `scheme://...` reference".into()),
Some((scheme, rest)) => match fetch(scheme, rest) {
Err(e) => Verdict::Unchecked(format!("could not read the secret: {e:#}")),
Ok(secret) => judge(probe, run(probe, &secret)),
},
};
out.push((name.clone(), verdict));
}
out
}
pub fn run_command(probe: &Probe, secret: &str) -> Ran {
let mut parts = crate::render::shell_words(&probe.command);
if parts.is_empty() {
return Ran::Failed("command is empty".into());
}
let bin = parts.remove(0);
let mut child = match Command::new(&bin)
.args(&parts)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(_) => return Ran::Failed(format!("failed to run `{bin}`; is it installed?")),
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(probe.input.replace("{}", secret).as_bytes());
}
let deadline = Instant::now() + Duration::from_secs(probe.timeout);
loop {
match child.try_wait() {
Ok(Some(status)) => {
return match status.code() {
Some(code) => Ran::Exited(code),
None => Ran::Failed("the check was terminated by a signal".into()),
}
}
Ok(None) => {}
Err(e) => return Ran::Failed(format!("could not wait for the check: {e}")),
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Ran::Failed(format!("timed out after {}s", probe.timeout));
}
std::thread::sleep(Duration::from_millis(20));
}
}
pub fn run(manifest: &crate::manifest::Manifest) -> Result<()> {
if manifest.probes.is_empty() {
println!("\x1b[33mnote\x1b[0m no [probes] declared; no secret was checked");
return Ok(());
}
let mut cache =
crate::render::SecretCache::with(crate::render::effective_providers(&manifest.providers));
let results = probe_all(
&manifest.probes,
&mut |scheme, rest| cache.read(scheme, rest),
&run_command,
);
report(&results)
}
fn report(results: &[(String, Verdict)]) -> Result<()> {
let accepted = results
.iter()
.filter(|(_, v)| *v == Verdict::Accepted)
.count();
println!("probed {} secret(s): {accepted} accepted", results.len());
let mut rejected = 0usize;
let mut unchecked = 0usize;
for (name, verdict) in results {
match verdict {
Verdict::Accepted => {}
Verdict::Rejected(code) => {
rejected += 1;
println!(" \x1b[31minvalid\x1b[0m {name} (the check exited {code})");
}
Verdict::Unchecked(why) => {
unchecked += 1;
println!(" \x1b[33munchecked\x1b[0m {name} ({why})");
}
}
}
if rejected == 0 && unchecked == 0 {
println!("\x1b[32mok\x1b[0m every probed secret is accepted");
return Ok(());
}
match (rejected, unchecked) {
(r, 0) => bail!("{r} secret(s) rejected"),
(0, u) => bail!("{u} secret(s) could not be checked"),
(r, u) => bail!("{r} secret(s) rejected, {u} could not be checked"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(src: &str) -> Probe {
toml::from_str(src).unwrap()
}
fn basic() -> Probe {
probe("secret = \"op://V/I/f\"\ncommand = \"check\"\n")
}
#[test]
fn zero_is_accepted_and_anything_else_is_rejected_by_default() {
assert_eq!(judge(&basic(), Ran::Exited(0)), Verdict::Accepted);
assert_eq!(judge(&basic(), Ran::Exited(1)), Verdict::Rejected(1));
assert_eq!(judge(&basic(), Ran::Exited(22)), Verdict::Rejected(22));
}
#[test]
fn only_the_declared_codes_mean_rejected() {
let p = probe("secret = \"op://V/I/f\"\ncommand = \"c\"\ninvalid-exit = [22]\n");
assert_eq!(judge(&p, Ran::Exited(22)), Verdict::Rejected(22));
assert!(matches!(judge(&p, Ran::Exited(7)), Verdict::Unchecked(_)));
assert_eq!(judge(&p, Ran::Exited(0)), Verdict::Accepted);
}
#[test]
fn a_command_that_did_not_run_is_unchecked() {
let v = judge(&basic(), Ran::Failed("timed out".into()));
assert_eq!(v, Verdict::Unchecked("timed out".into()));
}
#[test]
fn a_secret_that_cannot_be_read_is_unchecked_and_nothing_is_run() {
let mut probes = BTreeMap::new();
probes.insert("a".to_string(), basic());
let results = probe_all(
&probes,
&mut |_, _| anyhow::bail!("vault is locked"),
&|_, _| panic!("the check must not run without a secret"),
);
assert_eq!(results.len(), 1);
match &results[0].1 {
Verdict::Unchecked(why) => assert!(why.contains("vault is locked"), "{why}"),
other => panic!("{other:?}"),
}
}
#[test]
fn the_fetched_value_reaches_the_check() {
let mut probes = BTreeMap::new();
probes.insert("a".to_string(), basic());
let mut asked = Vec::new();
let results = probe_all(
&probes,
&mut |scheme, rest| {
asked.push(format!("{scheme}|{rest}"));
Ok("the-value".into())
},
&|_, secret| {
if secret == "the-value" {
Ran::Exited(0)
} else {
Ran::Exited(1)
}
},
);
assert_eq!(asked, vec!["op|V/I/f".to_string()]);
assert_eq!(results[0].1, Verdict::Accepted);
}
#[test]
fn a_secret_that_is_not_a_reference_is_rejected_when_validating() {
let p = probe("secret = \"just-a-token\"\ncommand = \"c\"\n");
assert!(p.validate("a").is_err());
}
#[test]
fn an_empty_command_and_a_zero_timeout_are_rejected() {
assert!(probe("secret = \"op://a/b\"\ncommand = \"\"\n")
.validate("a")
.is_err());
assert!(
probe("secret = \"op://a/b\"\ncommand = \"c\"\ntimeout = 0\n")
.validate("a")
.is_err()
);
}
#[test]
fn zero_may_not_be_an_invalid_exit() {
let p = probe("secret = \"op://a/b\"\ncommand = \"c\"\ninvalid-exit = [0]\n");
assert!(p.validate("a").is_err());
}
#[test]
fn a_misspelled_key_is_rejected() {
let r: Result<Probe, _> =
toml::from_str("secret = \"op://a/b\"\ncommand = \"c\"\ninvalid_exit = [22]\n");
assert!(r.is_err());
}
#[test]
fn the_secret_is_sent_on_stdin() {
let p = probe(
"secret = \"op://a/b\"\ncommand = \"grep -qx 'Bearer tok-123'\"\ninput = \"Bearer {}\"\n",
);
assert_eq!(run_command(&p, "tok-123"), Ran::Exited(0));
assert_eq!(run_command(&p, "tok-999"), Ran::Exited(1));
}
#[test]
fn a_missing_binary_is_unchecked_not_rejected() {
let p = probe("secret = \"op://a/b\"\ncommand = \"sennit-no-such-binary\"\n");
assert!(matches!(run_command(&p, "x"), Ran::Failed(_)));
}
#[test]
fn a_hung_check_is_stopped() {
let p = probe("secret = \"op://a/b\"\ncommand = \"sleep 30\"\ntimeout = 1\n");
let started = Instant::now();
assert!(matches!(run_command(&p, "x"), Ran::Failed(_)));
assert!(started.elapsed() < Duration::from_secs(10));
}
}