use serde::{Deserialize, Serialize};
use crate::policy::PolicyError;
const RESERVED_HEADERS: &[&str] = &[
"host",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
];
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Secret {
pub id: String,
pub host: String,
pub header: String,
pub source: Source,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub plain_http: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub enum Source {
File(String),
Env(String),
Keychain {
service: String,
account: String,
},
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secret")
.field("id", &self.id)
.finish_non_exhaustive()
}
}
impl Secret {
#[must_use]
pub fn placeholder(&self) -> String {
format!("moat-secret:{}:placeholder", self.id)
}
#[must_use]
pub fn is_loopback(&self) -> bool {
let host = self.host.as_str();
host == "localhost"
|| host.ends_with(".localhost")
|| host
.parse::<core::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
}
pub(crate) fn check(secrets: &[Secret]) -> Result<(), PolicyError> {
for (i, secret) in secrets.iter().enumerate() {
let fail = |problem: String| PolicyError::Rule {
rule: format!("secrets.{}", secret.id),
problem,
};
check_one(secret).map_err(fail)?;
if let Some(earlier) = secrets[..i].iter().find(|s| s.id == secret.id) {
return Err(PolicyError::DuplicateId(format!("secrets.{}", earlier.id)));
}
if let Some(earlier) = secrets[..i]
.iter()
.find(|s| s.host == secret.host && s.header.eq_ignore_ascii_case(&secret.header))
{
return Err(fail(format!(
"secret `{}` already sets {} for {}",
earlier.id, secret.header, secret.host
)));
}
}
Ok(())
}
fn check_one(secret: &Secret) -> Result<(), String> {
let id = &secret.id;
if id.is_empty()
|| id.len() > 64
|| !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err("id must be 1 to 64 lowercase letters, digits or `-`".into());
}
if !is_host(&secret.host) {
return Err(format!(
"host `{}` must be one lowercase DNS name or IPv4 address, without port or pattern",
secret.host
));
}
let header = &secret.header;
if header.is_empty() || !header.bytes().all(is_token_byte) {
return Err(format!("header `{header}` is not an HTTP header name"));
}
if RESERVED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) {
return Err(format!(
"header `{header}` frames the request or connection; it cannot carry a secret"
));
}
match &secret.source {
Source::File(path) if !(crate::paths::is_absolute(path) || path.starts_with("~/")) => Err(
format!("source file `{path}` must be absolute or start with `~/`"),
),
Source::Env(name) if !is_env_name(name) => {
Err(format!("source env `{name}` is not a variable name"))
}
Source::Keychain { service, account }
if [service, account]
.iter()
.any(|s| s.is_empty() || s.chars().any(char::is_control)) =>
{
Err("source keychain needs a service and an account without control characters".into())
}
_ => Ok(()),
}
}
fn is_host(host: &str) -> bool {
host.len() <= 253
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
})
}
fn is_token_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
}
fn is_env_name(name: &str) -> bool {
let mut bytes = name.bytes();
bytes
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
&& bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
#[cfg(test)]
mod tests {
use crate::policy::{Policy, PolicyError};
use super::Source;
fn parse(secrets: &str) -> Result<Policy, PolicyError> {
Policy::parse(&format!("version: 1\nsecrets:\n{secrets}"))
}
fn problem(secrets: &str) -> String {
parse(secrets).unwrap_err().to_string()
}
const GH: &str = " - id: gh\n host: api.github.com\n header: Authorization\n";
#[test]
fn parses_every_source() {
let p = parse(&format!(
"{GH} source: {{ env: GITHUB_TOKEN }}\n \
- id: npm\n host: registry.npmjs.org\n header: Authorization\n \
source: {{ file: ~/.config/moat/npm }}\n \
- id: k\n host: example.com\n header: X-Api-Key\n \
source: {{ keychain: {{ service: moat, account: k }} }}\n"
))
.unwrap();
assert_eq!(p.secrets.len(), 3);
assert_eq!(p.secrets[0].source, Source::Env("GITHUB_TOKEN".into()));
assert_eq!(
p.secrets[1].source,
Source::File("~/.config/moat/npm".into())
);
assert!(
matches!(&p.secrets[2].source, Source::Keychain { service, .. } if service == "moat")
);
assert_eq!(p.secrets[0].placeholder(), "moat-secret:gh:placeholder");
assert!(Policy::parse("version: 1\n").unwrap().secrets.is_empty());
}
#[test]
fn debug_output_names_only_the_id() {
let policy = parse(&format!("{GH} source: {{ file: ~/.config/moat/gh }}\n")).unwrap();
let secret = &policy.secrets[0];
let brokered = crate::ir::BrokeredSecret {
id: secret.id.clone(),
host: secret.host.clone(),
header: secret.header.clone(),
source: secret.source.clone(),
plain_http: false,
};
for shown in [format!("{policy:?}"), format!("{brokered:?}")] {
assert!(shown.contains("\"gh\""), "{shown}");
for hidden in ["~/.config/moat/gh", "api.github.com", "Authorization"] {
assert!(!shown.contains(hidden), "{hidden} in {shown}");
}
}
}
#[test]
fn plain_http_is_off_unless_set() {
let off = parse(&format!("{GH} source: {{ env: T }}\n")).unwrap();
assert!(!off.secrets[0].plain_http);
let on = parse(&format!(
"{GH} source: {{ env: T }}\n plain_http: true\n"
))
.unwrap();
assert!(on.secrets[0].plain_http);
assert!(!on.secrets[0].is_loopback());
for (host, loopback) in [
("localhost", true),
("api.localhost", true),
("127.0.0.2", true),
("10.0.0.1", false),
("localhost.example", false),
] {
let mut s = on.secrets[0].clone();
s.host = host.into();
assert_eq!(s.is_loopback(), loopback, "{host}");
}
}
#[test]
fn rejects_bad_fields() {
let src = " source: { env: T }\n";
for (yaml, want) in [
(format!(" - id: Gh\n host: a.com\n header: A\n{src}"), "id must be"),
(format!(" - id: g:h\n host: a.com\n header: A\n{src}"), "id must be"),
(format!(" - id: g\n host: '*.a.com'\n header: A\n{src}"), "host `*.a.com`"),
(format!(" - id: g\n host: A.com\n header: A\n{src}"), "host `A.com`"),
(format!(" - id: g\n host: a.com:443\n header: A\n{src}"), "host `a.com:443`"),
(format!(" - id: g\n host: a.com\n header: 'A B'\n{src}"), "not an HTTP header"),
(format!(" - id: g\n host: a.com\n header: Host\n{src}"), "frames the request"),
(format!(" - id: g\n host: a.com\n header: content-length\n{src}"), "frames"),
(" - id: g\n host: a.com\n header: A\n source: { file: rel/t }\n".into(), "absolute"),
(" - id: g\n host: a.com\n header: A\n source: { env: 'A-B' }\n".into(), "variable name"),
(
" - id: g\n host: a.com\n header: A\n source: { keychain: { service: '', account: a } }\n".into(),
"keychain",
),
] {
let got = problem(&yaml);
assert!(got.contains(want), "{yaml}: {got}");
}
assert!(parse(&format!("{GH} source: {{ vault: x }}\n")).is_err());
assert!(parse(&format!("{GH} source: {{ env: T }}\n extra: 1\n")).is_err());
}
#[test]
fn rejects_duplicates() {
let two = format!("{GH} source: {{ env: A }}\n{GH} source: {{ env: B }}\n");
assert!(matches!(parse(&two), Err(PolicyError::DuplicateId(id)) if id == "secrets.gh"));
let same_header = format!(
"{GH} source: {{ env: A }}\n - id: gh2\n host: api.github.com\n header: authorization\n source: {{ env: B }}\n"
);
assert!(problem(&same_header).contains("already sets"));
}
}