use std::fs;
use std::io::Write as _;
use anyhow::{Context as _, Result, bail};
use openmoat_core::{Secret, SecretSource};
use similar::{Algorithm, udiff::unified_diff};
use crate::cli::SecretAddArgs;
use crate::context::read_policy;
use crate::exit::Code;
use crate::home::{Home, write_private};
use crate::integrity;
use crate::render::Deferred;
const GITHUB: (&str, &str, &str) = ("api.github.com", "Authorization", "GITHUB_TOKEN");
pub fn add(args: &SecretAddArgs) -> Result<Code> {
if !crate::terminal::interactive() {
bail!("`moat secret add` must be run by a person in a terminal, not from a hook or script");
}
let secret = secret(args)?;
let home = Home::locate()?;
let path = home.policy_path();
if !path.is_file() {
bail!("no policy at {}; run `moat init`", path.display());
}
integrity::refuse_drift(&home, "add a secret")?;
if home
.load_policy()?
.secrets
.iter()
.any(|s| s.id == secret.id)
{
bail!(
"the policy already brokers a secret `{}`; `moat edit` changes it",
secret.id
);
}
let before = read_policy(&path)?;
let mut after = with_entry(&before, &secret)?;
let mut policy = lint_draft(&home, &after)?;
let mut source_denied = None;
if openmoat_core::lint::exposed_source(&policy, &secret).is_some()
&& let Some((id, denied)) = with_source_deny(&after, &secret)?
{
policy = lint_draft(&home, &denied)?;
after = denied;
source_denied = Some(id);
}
let mut out = Deferred::default();
for warning in openmoat_core::lint::warnings(&policy) {
writeln!(out, "warning: {warning}")?;
}
let diff = unified_diff(
Algorithm::Myers,
&before,
&after,
3,
Some(("policy.yaml (installed)", "policy.yaml (with the secret)")),
);
write!(out, "{diff}")?;
writeln!(
out,
"The agent will hold `{}` instead of the value; OpenMoat puts the value in the \
{} header of requests to {} only.",
secret.placeholder(),
secret.header,
secret.host
)?;
if let Some(id) = &source_denied {
writeln!(
out,
"The deny rule `{id}` keeps the agent from reading the value at its source."
)?;
}
if !super::edit::confirm(&mut out, "Apply? [y/N] ")? {
writeln!(out, "the policy was not changed")?;
out.finish()?;
return Ok(Code::Ok);
}
super::edit::apply(&home, &before, &after, &mut out)?;
out.finish()?;
super::sandbox::sync()?;
println!("Start a new agent session to use it.");
Ok(Code::Ok)
}
fn secret(args: &SecretAddArgs) -> Result<Secret> {
let preset = (args.id == "gh").then_some(GITHUB);
let host = args
.host
.clone()
.or(preset.map(|p| p.0.to_owned()))
.context("give --host: the one host whose requests get the secret")?;
let header = args
.header
.clone()
.unwrap_or_else(|| preset.map_or("Authorization", |p| p.1).to_owned());
let source = match (&args.env, &args.file) {
(Some(name), None) => SecretSource::Env(name.clone()),
(None, Some(file)) => SecretSource::File(file.clone()),
(None, None) => match preset {
Some(p) => SecretSource::Env(p.2.to_owned()),
None => bail!("give --env NAME or --file PATH: where OpenMoat reads the value"),
},
(Some(_), Some(_)) => bail!("give --env or --file, not both"),
};
Ok(Secret {
id: args.id.clone(),
host,
header,
source,
plain_http: false,
})
}
fn lint_draft(home: &Home, text: &str) -> Result<openmoat_core::Policy> {
let draft = home.root().join("policy.secret.yaml");
write_private(&draft, text.as_bytes())?;
let linted = home.load_policy_at(&draft);
let _ = fs::remove_file(&draft);
linted.context("the policy with the new secret does not lint")
}
fn with_source_deny(policy: &str, secret: &Secret) -> Result<Option<(String, String)>> {
let (kind, value) = match &secret.source {
SecretSource::Env(name) => ("env.read", name),
SecretSource::File(path) => ("fs.read", path),
SecretSource::Keychain { .. } => return Ok(None),
};
let id = format!("secret-{}", secret.id);
let lines: Vec<&str> = policy.lines().collect();
let taken = lines
.iter()
.any(|l| l.trim_start().trim_start_matches("- ").trim() == format!("id: {id}"));
let Some(at) = lines
.iter()
.position(|l| l.split('#').next().unwrap_or("").trim_end() == "deny:")
else {
return Ok(None);
};
if taken {
return Ok(None);
}
let indent = lines[at + 1..]
.iter()
.find(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
.filter(|l| l.trim_start().starts_with('-'))
.map_or(" ", |l| &l[..l.len() - l.trim_start().len()]);
let quoted = serde_json::to_string(value)?;
let group = format!(
"{indent}- id: {id}\n{indent} reason: the source of the brokered secret `{}`\n\
{indent} {kind}: [{quoted}]\n",
secret.id
);
let mut out = String::new();
for (n, line) in lines.iter().enumerate() {
out.push_str(line);
out.push('\n');
if n == at {
out.push_str(&group);
}
}
Ok(Some((id, out)))
}
fn with_entry(policy: &str, secret: &Secret) -> Result<String> {
let entry = openmoat_core::yaml::to_string(&[secret])?;
let lines: Vec<&str> = policy.lines().collect();
let Some(at) = lines.iter().position(|l| l.starts_with("secrets:")) else {
let mut out = policy.trim_end_matches('\n').to_owned();
out.push_str("\nsecrets:\n");
out.push_str(&indented(&entry, " "));
return Ok(out);
};
let rest = lines[at]["secrets:".len()..]
.split('#')
.next()
.unwrap_or("")
.trim();
let indent = match rest {
"" => lines[at + 1..]
.iter()
.map(|l| l.trim_end())
.find(|l| !l.trim_start().is_empty() && !l.trim_start().starts_with('#'))
.filter(|l| l.trim_start().starts_with('-'))
.map_or(" ", |l| &l[..l.len() - l.trim_start().len()]),
"[]" => " ",
_ => bail!("the policy's `secrets:` is not a block list; add the secret with `moat edit`"),
};
let mut out = String::new();
for line in &lines[..at] {
out.push_str(line);
out.push('\n');
}
out.push_str("secrets:\n");
out.push_str(&indented(&entry, indent));
for line in &lines[at + 1..] {
out.push_str(line);
out.push('\n');
}
Ok(out)
}
fn indented(text: &str, indent: &str) -> String {
let mut out = String::new();
for line in text.lines() {
out.push_str(indent);
out.push_str(line);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use openmoat_core::Policy;
use super::*;
fn gh() -> Secret {
let args = SecretAddArgs {
id: "gh".into(),
host: None,
header: None,
env: None,
file: None,
};
secret(&args).unwrap()
}
fn secrets(policy: &str) -> Vec<Secret> {
Policy::parse(policy).unwrap().secrets
}
#[test]
fn gh_alone_is_the_github_token() {
let s = gh();
assert_eq!(
(s.host.as_str(), s.header.as_str()),
("api.github.com", "Authorization")
);
assert_eq!(s.source, SecretSource::Env("GITHUB_TOKEN".into()));
let other = SecretAddArgs {
id: "api".into(),
host: None,
header: None,
env: Some("API_KEY".into()),
file: None,
};
assert!(secret(&other).is_err(), "another id needs its host");
}
#[test]
fn a_policy_without_secrets_gets_a_list() {
let out = with_entry("version: 1\n# end\n", &gh()).unwrap();
assert!(
out.starts_with("version: 1\n# end\nsecrets:\n - id: gh\n"),
"{out}"
);
assert_eq!(secrets(&out), [gh()]);
}
#[test]
fn an_existing_list_keeps_its_entries_and_indent() {
let other = "- id: x\n host: x.test\n header: X-Key\n source: { env: X_KEY }\n";
let policy = format!("version: 1\nsecrets:\n{other}deny: []\n");
let out = with_entry(&policy, &gh()).unwrap();
let ids: Vec<String> = secrets(&out).into_iter().map(|s| s.id).collect();
assert_eq!(ids, ["gh", "x"]);
assert!(out.ends_with("deny: []\n"), "{out}");
let empty = with_entry("version: 1\nsecrets: [] # none yet\n", &gh()).unwrap();
assert_eq!(secrets(&empty), [gh()]);
let flow =
"version: 1\nsecrets: [{ id: x, host: x.test, header: X, source: { env: X } }]\n";
assert!(with_entry(flow, &gh()).is_err());
}
fn other(id: &str, env: Option<&str>, file: Option<&str>) -> Secret {
secret(&SecretAddArgs {
id: id.into(),
host: Some("api.example.com".into()),
header: None,
env: env.map(Into::into),
file: file.map(Into::into),
})
.unwrap()
}
#[test]
fn an_exposed_source_gets_a_deny_rule_and_a_covered_one_none() {
use openmoat_core::DEFAULT_POLICY;
use openmoat_core::lint::exposed_source;
for (s, kind) in [
(
other("ex", None, Some("~/.config/example/token")),
"fs.read",
),
(other("db", Some("DATABASE_URL"), None), "env.read"),
] {
let with = with_entry(DEFAULT_POLICY, &s).unwrap();
assert!(exposed_source(&Policy::parse(&with).unwrap(), &s).is_some());
let (id, denied) = with_source_deny(&with, &s).unwrap().expect("a deny rule");
assert_eq!(id, format!("secret-{}", s.id));
assert!(denied.contains(&format!(" {kind}: [")), "{denied}");
let policy = Policy::parse(&denied).expect("still lints");
assert_eq!(exposed_source(&policy, &s), None);
assert!(
with_source_deny(&denied, &s).unwrap().is_none(),
"the id is taken"
);
}
for s in [
other("stripe", Some("STRIPE_API_KEY"), None),
other("ssh", None, Some("~/.ssh/deploy_token")),
] {
let with = with_entry(DEFAULT_POLICY, &s).unwrap();
assert_eq!(exposed_source(&Policy::parse(&with).unwrap(), &s), None);
}
}
}