use serde_json::Value;
use shared::{EnvVar, EnvVarValue};
pub const MASCARA: &str = "***";
pub fn chave_sensivel(chave: &str) -> bool {
let up = chave.to_ascii_uppercase();
if ["PASSWORD", "PASSWD", "SECRET", "TOKEN", "CREDENTIAL"]
.iter()
.any(|p| up.contains(p))
{
return true;
}
up.split(|c: char| !c.is_ascii_alphanumeric())
.any(|seg| matches!(seg, "PASS" | "PWD" | "KEY" | "APIKEY"))
}
pub fn mascarar_url(texto: &str) -> String {
let mut saida = String::with_capacity(texto.len());
let mut resto = texto;
while let Some(i) = resto.find("://") {
let (antes, depois) = resto.split_at(i + 3);
saida.push_str(antes);
let fim = depois
.find(|c: char| {
c == '/' || c == '?' || c == '#' || c.is_whitespace() || c == '"' || c == '\''
})
.unwrap_or(depois.len());
let (autoridade, cauda) = depois.split_at(fim);
match autoridade.rfind('@') {
Some(arroba) => {
let userinfo = &autoridade[..arroba];
match userinfo.find(':') {
Some(dois_pontos) => {
saida.push_str(&userinfo[..=dois_pontos]);
saida.push_str(MASCARA);
saida.push_str(&autoridade[arroba..]);
}
None => saida.push_str(autoridade),
}
}
None => saida.push_str(autoridade),
}
resto = cauda;
}
saida.push_str(resto);
saida
}
pub fn valor_env_para_agente(chave: &str, valor: &str) -> String {
if chave_sensivel(chave) && !valor.is_empty() {
MASCARA.to_string()
} else {
mascarar_url(valor)
}
}
fn eh_referencia(valor: &str) -> bool {
let v = valor.trim_matches(|c| c == '"' || c == '\'');
v.starts_with("${") || v.starts_with("secret:") || v.starts_with("<secret:")
}
pub fn mascarar_linhas(texto: &str) -> String {
let mut linhas: Vec<String> = Vec::new();
for linha in texto.split('\n') {
let sem_indent = linha.trim_start();
let indent = &linha[..linha.len() - sem_indent.len()];
let (traco, corpo) = match sem_indent.strip_prefix("- ") {
Some(r) => ("- ", r),
None => ("", sem_indent),
};
let separador = corpo.find(['=', ':']);
let mascarada = separador.and_then(|i| {
let chave = corpo[..i].trim();
let valor = corpo[i + 1..].trim();
let chave_ok = !chave.is_empty()
&& chave
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
if chave_ok && chave_sensivel(chave) && !valor.is_empty() && !eh_referencia(valor) {
let sep = &corpo[i..=i];
let espaco = if sep == ":" { " " } else { "" };
Some(format!("{indent}{traco}{chave}{sep}{espaco}{MASCARA}"))
} else {
None
}
});
linhas.push(mascarada.unwrap_or_else(|| mascarar_url(linha)));
}
linhas.join("\n")
}
pub fn redigir(valor: &mut Value) {
match valor {
Value::Object(mapa) => {
let chave = mapa.get("key").and_then(Value::as_str).map(str::to_owned);
if let Some(chave) = chave
&& let Some(Value::Object(v)) = mapa.get_mut("value")
&& let Some(Value::String(plain)) = v.get_mut("Plain")
{
*plain = valor_env_para_agente(&chave, plain);
}
if let Some(Value::String(url)) = mapa.get_mut("internal_url") {
*url = mascarar_url(url);
}
for filho in mapa.values_mut() {
redigir(filho);
}
}
Value::Array(itens) => itens.iter_mut().for_each(redigir),
_ => {}
}
}
pub fn redigir_resposta(resposta: &mut Value) {
for variante in ["ServiceBundleYaml"] {
if let Some(Value::Object(campos)) = resposta.get_mut(variante) {
for campo in ["yaml", "dotenv"] {
if let Some(Value::String(texto)) = campos.get_mut(campo) {
*texto = mascarar_linhas(texto);
}
}
}
}
redigir(resposta);
}
pub fn restaurar_envs(novas: &mut [EnvVar], atuais: &[EnvVar]) {
for nova in novas {
let EnvVarValue::Plain(v) = &mut nova.value else {
continue;
};
let Some(EnvVarValue::Plain(real)) =
atuais.iter().find(|e| e.key == nova.key).map(|e| &e.value)
else {
continue;
};
if v != real && *v == valor_env_para_agente(&nova.key, real) {
*v = real.clone();
}
}
}
pub fn sobrou_mascara(envs: &[EnvVar]) -> Option<&str> {
envs.iter().find_map(|e| match &e.value {
EnvVarValue::Plain(v) if v == MASCARA || v.contains(":***@") => Some(e.key.as_str()),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn plain(k: &str, v: &str) -> EnvVar {
EnvVar {
key: k.into(),
value: EnvVarValue::Plain(v.into()),
}
}
#[test]
fn chaves_sensiveis() {
for k in [
"POSTGRES_PASSWORD",
"RABBITMQ_PASS",
"JWT_SECRET",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"TOKEN_DE_AUTENTICACAO_ASAAS",
"MYSQLPASSWORD",
"api_key",
] {
assert!(chave_sensivel(k), "{k} deveria ser sensível");
}
for k in [
"MODE",
"RUST_LOG",
"APP_PORT",
"S3_BUCKET_NAME_1",
"KEYCLOAK_URL",
"RABBITMQ_USER",
] {
assert!(!chave_sensivel(k), "{k} não deveria ser sensível");
}
}
#[test]
fn url_mascara_so_a_senha() {
assert_eq!(
mascarar_url("postgresql://postgres:abc123@rp_db:5432/db"),
"postgresql://postgres:***@rp_db:5432/db"
);
assert_eq!(
mascarar_url("redis://:segredo@rp_cache:6379"),
"redis://:***@rp_cache:6379"
);
assert_eq!(
mascarar_url("https://github.com/a/b.git"),
"https://github.com/a/b.git"
);
assert_eq!(
mascarar_url("https://usuario@host/x"),
"https://usuario@host/x"
);
assert_eq!(
mascarar_url("a=amqp://u:p1@h1/ b=amqp://u:p2@h2/"),
"a=amqp://u:***@h1/ b=amqp://u:***@h2/"
);
}
#[test]
fn connection_info_nao_leva_senha() {
let mut v = json!({"ConnectionInfo": {"host": "rp_db", "internal_url": "postgresql://u:SENHA@rp_db:5432/db"}});
redigir_resposta(&mut v);
let texto = v.to_string();
assert!(!texto.contains("SENHA"), "{texto}");
assert!(texto.contains("rp_db"));
}
#[test]
fn env_plain_sensivel_e_mascarada_e_o_resto_fica() {
let mut v = json!({"Service": {"spec": {"env_vars": [
{"key": "POSTGRES_PASSWORD", "value": {"Plain": "SENHA"}},
{"key": "MODE", "value": {"Plain": "production"}},
{"key": "DATABASE_URL", "value": {"Plain": "postgres://u:SENHA@h/db"}},
{"key": "JWT_SECRET", "value": {"Secret": "JWT_SECRET"}},
]}}});
redigir_resposta(&mut v);
let envs = &v["Service"]["spec"]["env_vars"];
assert_eq!(envs[0]["value"]["Plain"], "***");
assert_eq!(envs[1]["value"]["Plain"], "production");
assert_eq!(envs[2]["value"]["Plain"], "postgres://u:***@h/db");
assert_eq!(
envs[3]["value"]["Secret"], "JWT_SECRET",
"referência a secret não muda"
);
assert!(!v.to_string().contains("SENHA"));
}
#[test]
fn dotenv_e_yaml_mascaram_so_chaves_sensiveis() {
let mut v = json!({"ServiceBundleYaml": {
"yaml": "env:\n POSTGRES_PASSWORD: secret:POSTGRES_PASSWORD\n JWT_SECRET: abc\n MODE: production\n",
"dotenv": "POSTGRES_PASSWORD=SENHA\nMODE=production\nREF=${X}\nTOKEN=${TOKEN}\n",
}});
redigir_resposta(&mut v);
let yaml = v["ServiceBundleYaml"]["yaml"].as_str().unwrap();
let dotenv = v["ServiceBundleYaml"]["dotenv"].as_str().unwrap();
assert!(
yaml.contains("POSTGRES_PASSWORD: secret:POSTGRES_PASSWORD"),
"{yaml}"
);
assert!(
yaml.contains("JWT_SECRET: ***") && yaml.contains("MODE: production"),
"{yaml}"
);
assert!(
dotenv.contains("POSTGRES_PASSWORD=***") && !dotenv.contains("SENHA"),
"{dotenv}"
);
assert!(
dotenv.contains("MODE=production") && dotenv.contains("TOKEN=${TOKEN}"),
"{dotenv}"
);
}
#[test]
fn reenviar_mascarado_restaura_o_valor_real() {
let atuais = vec![
plain("POSTGRES_PASSWORD", "SENHA"),
plain("DATABASE_URL", "postgres://u:SENHA@h/db"),
plain("MODE", "production"),
];
let mut novas = vec![
plain("POSTGRES_PASSWORD", "***"),
plain("DATABASE_URL", "postgres://u:***@h/db"),
plain("MODE", "development"), ];
restaurar_envs(&mut novas, &atuais);
assert_eq!(novas[0].value, EnvVarValue::Plain("SENHA".into()));
assert_eq!(
novas[1].value,
EnvVarValue::Plain("postgres://u:SENHA@h/db".into())
);
assert_eq!(novas[2].value, EnvVarValue::Plain("development".into()));
assert!(sobrou_mascara(&novas).is_none());
}
#[test]
fn mascara_sem_original_e_recusada() {
let mut novas = vec![plain("NOVA_SENHA", "***")];
restaurar_envs(&mut novas, &[]);
assert_eq!(sobrou_mascara(&novas), Some("NOVA_SENHA"));
let mut trocada = vec![plain("POSTGRES_PASSWORD", "outra")];
restaurar_envs(&mut trocada, &[plain("POSTGRES_PASSWORD", "SENHA")]);
assert_eq!(trocada[0].value, EnvVarValue::Plain("outra".into()));
}
}