use super::model::{InfraState, ProjectInfra};
use crate::platform::secrets::is_enc;
use shared::{EnvVar, EnvVarValue, ServiceSource};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Certainty {
Definite,
Suspect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub certainty: Certainty,
pub project: String,
pub scope: String,
pub what: String,
}
const SENSITIVE_NAME_PARTS: &[&str] = &[
"PASSWORD",
"PASSWD",
"SECRET",
"TOKEN",
"API_KEY",
"APIKEY",
"PRIVATE_KEY",
"ACCESS_KEY",
"CREDENTIAL",
"JWT",
"SALT",
"_DSN",
"AUTH_KEY",
"ENCRYPTION_KEY",
"SIGNING_KEY",
];
pub(crate) fn name_looks_sensitive(key: &str) -> bool {
let up = key.to_uppercase();
let word = |w: &str| {
up == w
|| up.ends_with(&format!("_{w}"))
|| up.starts_with(&format!("{w}_"))
|| up.contains(&format!("_{w}_"))
};
SENSITIVE_NAME_PARTS.iter().any(|p| up.contains(p))
|| up == "KEY"
|| up.ends_with("_KEY")
|| word("PASS")
|| word("PWD")
}
fn url_with_credentials(value: &str) -> bool {
let Some(rest) = value.split_once("://").map(|(_, r)| r) else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
match authority.split_once('@') {
Some((userinfo, _)) => userinfo.contains(':') && !userinfo.ends_with(':'),
None => false,
}
}
fn is_reference(value: &str) -> bool {
let v = value.trim();
v.starts_with("${") && v.ends_with('}') && v.matches("${").count() == 1
}
fn check_env(out: &mut Vec<Finding>, project: &str, scope: &str, vars: &[EnvVar]) {
for v in vars {
let EnvVarValue::Plain(value) = &v.value else {
continue; };
if value.is_empty() || is_reference(value) {
continue;
}
let reason = if name_looks_sensitive(&v.key) {
Some(format!("env {}", v.key))
} else if url_with_credentials(value) {
Some(format!("env {} (URL com usuário e senha)", v.key))
} else {
None
};
if let Some(what) = reason {
out.push(Finding {
certainty: Certainty::Suspect,
project: project.into(),
scope: scope.into(),
what,
});
}
}
}
fn compose_suspect_lines(content: &str) -> Vec<String> {
let mut keys = vec![];
for raw in content.lines() {
let line = raw.trim().trim_start_matches('-').trim();
if line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once([':', '=']) else {
continue;
};
let (k, v) = (
k.trim().trim_matches('"'),
v.trim().trim_matches(['"', '\'']),
);
if v.is_empty() || v.starts_with("${") || v.starts_with('$') {
continue;
}
if name_looks_sensitive(k) && !k.contains(' ') {
keys.push(k.to_string());
}
}
keys.sort();
keys.dedup();
keys
}
fn audit_project(out: &mut Vec<Finding>, p: &ProjectInfra) {
let name = p.name.as_str();
let push = |out: &mut Vec<Finding>, c, scope: &str, what: String| {
out.push(Finding {
certainty: c,
project: name.into(),
scope: scope.into(),
what,
})
};
check_env(out, name, "projeto", &p.env_vars);
for s in &p.secrets {
if !is_enc(&s.value) {
push(
out,
Certainty::Definite,
"projeto",
format!("secret {} sem cifra", s.key),
);
}
}
for m in &p.managed_databases {
push(
out,
Certainty::Definite,
"projeto",
format!("senha do banco {}", m.name),
);
}
for svc in &p.services {
let scope = format!("serviço {}", svc.spec.name);
if svc.webhook_token.as_deref().is_some_and(|t| !is_enc(t)) {
push(
out,
Certainty::Definite,
&scope,
"token de webhook sem cifra".into(),
);
}
check_env(out, name, &scope, &svc.spec.env_vars);
for sec in &svc.secrets {
if !is_enc(&sec.value) {
push(
out,
Certainty::Definite,
&scope,
format!("secret {} sem cifra", sec.key),
);
}
}
if let ServiceSource::Compose(c) = &svc.spec.source {
for k in compose_suspect_lines(&c.content) {
push(out, Certainty::Suspect, &scope, format!("compose: {k}"));
}
}
}
for j in &p.jobs {
let scope = format!("job {}", j.name);
check_env(out, name, &scope, &j.env_vars);
for k in compose_suspect_lines(&j.compose) {
push(out, Certainty::Suspect, &scope, format!("compose: {k}"));
}
}
}
pub fn plaintext_findings(st: &InfraState) -> Vec<Finding> {
let mut out = vec![];
for prov in &st.providers {
if prov
.oauth_client_secret_enc
.as_deref()
.is_some_and(|v| !is_enc(v))
{
out.push(Finding {
certainty: Certainty::Definite,
project: String::new(),
scope: format!("provider {}", prov.name),
what: "client_secret sem cifra".into(),
});
}
}
for p in &st.projects {
audit_project(&mut out, p);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::infra::model::{ManagedDatabaseInfra, SecretInfra};
use chrono::Utc;
#[test]
fn pass_e_pwd_como_palavra_inteira_sao_segredo_mas_passenger_nao() {
for k in [
"RABBITMQ_DEFAULT_PASS",
"RABBITMQ_PASS",
"PASS",
"DB_PWD",
"PASS_FILE",
"MY_PASS_X",
"rabbitmq_pass",
] {
assert!(name_looks_sensitive(k), "{k} deveria ser sensível");
}
for k in [
"COMPASS",
"PASSENGER_COUNT",
"BYPASS",
"MODE",
"RABBITMQ_USER",
"DEFAULT_PASSIVE",
] {
assert!(!name_looks_sensitive(k), "{k} não deveria ser sensível");
}
}
fn project() -> ProjectInfra {
ProjectInfra {
id: "p".into(),
name: "App".into(),
description: None,
env_vars: vec![],
env_comments: vec![],
network_name: None,
created_at: Utc::now(),
services: vec![],
jobs: vec![],
secrets: vec![],
managed_databases: vec![],
shared_access: vec![],
}
}
fn plain(k: &str, v: &str) -> EnvVar {
EnvVar {
key: k.into(),
value: EnvVarValue::Plain(v.into()),
}
}
fn state(p: ProjectInfra) -> InfraState {
InfraState {
projects: vec![p],
..Default::default()
}
}
#[test]
fn estado_limpo_nao_tem_achados() {
let mut p = project();
p.env_vars = vec![
plain("NODE_ENV", "production"),
plain("PORT", "3000"),
plain("API_URL", "https://api.exemplo.com/v1"),
EnvVar {
key: "DB_PASSWORD".into(),
value: EnvVarValue::Secret("DB_PASSWORD".into()),
},
plain("TOKEN_FROM_OUTSIDE", "${TOKEN}"),
plain("EMPTY_PASSWORD", ""),
];
p.secrets = vec![SecretInfra {
id: "s".into(),
key: "K".into(),
value: "ENC[age:abc]".into(),
}];
assert_eq!(plaintext_findings(&state(p)), vec![]);
}
#[test]
fn acha_env_suspeita_por_nome_e_por_url_com_senha() {
let mut p = project();
p.env_vars = vec![
plain("STRIPE_SECRET_KEY", "sk_live_x"),
plain("DATABASE_URL", "postgres://app:hunter2@db:5432/app"),
plain("DATABASE_URL_SEM_SENHA", "postgres://app@db:5432/app"),
plain("SESSION_KEY", "abc"),
];
let f = plaintext_findings(&state(p));
let whats: Vec<_> = f.iter().map(|x| x.what.as_str()).collect();
assert_eq!(f.len(), 3, "{whats:?}");
assert!(whats.contains(&"env STRIPE_SECRET_KEY"));
assert!(whats.iter().any(|w| w.starts_with("env DATABASE_URL (URL")));
assert!(whats.contains(&"env SESSION_KEY"));
assert!(f.iter().all(|x| x.certainty == Certainty::Suspect));
}
#[test]
fn o_achado_nunca_contem_o_valor() {
let mut p = project();
p.env_vars = vec![plain("API_TOKEN", "valor-ultra-secreto")];
p.managed_databases = vec![ManagedDatabaseInfra {
id: "m".into(),
server_service_id: "s".into(),
name: "loja".into(),
password: "senha-do-banco".into(),
env_var: "DATABASE_URL".into(),
connection_limit: None,
statement_timeout_ms: None,
created_at: Utc::now(),
}];
let f = plaintext_findings(&state(p));
let tudo = format!("{f:?}");
assert!(!tudo.contains("valor-ultra-secreto") && !tudo.contains("senha-do-banco"));
assert!(
f.iter()
.any(|x| x.certainty == Certainty::Definite && x.what == "senha do banco loja")
);
}
#[test]
fn secret_e_webhook_sem_cifra_sao_certezas() {
let mut p = project();
p.secrets = vec![SecretInfra {
id: "s".into(),
key: "K".into(),
value: "em-claro".into(),
}];
let f = plaintext_findings(&state(p));
assert_eq!(f.len(), 1);
assert_eq!(f[0].certainty, Certainty::Definite);
}
#[test]
fn linhas_de_compose_com_valor_literal_sao_suspeitas() {
let compose = "services:\n db:\n environment:\n POSTGRES_PASSWORD: hunter2\n POSTGRES_USER: app\n - API_TOKEN=abc\n OK_PASSWORD: ${DB_PASSWORD}\n # SENHA_TOKEN: comentado\n";
let k = compose_suspect_lines(compose);
assert_eq!(k, vec!["API_TOKEN", "POSTGRES_PASSWORD"]);
}
}