sdd-layer 0.18.0

Spec-Driven Development CLI and agent harness
use regex::Regex;
use std::fmt;

pub const REDACTED_MARKER: &str = "[REDACTED]";

pub trait RedactedDisplay {
    fn redacted_display(&self) -> &'static str {
        REDACTED_MARKER
    }
}

#[derive(Clone, Eq, PartialEq)]
pub struct Secret<T> {
    _value: T,
}

impl<T> Secret<T> {
    pub fn new(value: T) -> Self {
        Self { _value: value }
    }
}

impl<T> RedactedDisplay for Secret<T> {}

impl<T> fmt::Display for Secret<T> {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.redacted_display())
    }
}

impl<T> fmt::Debug for Secret<T> {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.redacted_display())
    }
}

/// Redige segredos conhecidos. **Seguro para a trilha de auditoria** — NÃO
/// redige e-mail/PII (a auditoria precisa do e-mail do aprovador).
pub fn redact_text(input: &str) -> String {
    let output = redact_pem_blocks(input);
    let output = redact_bearer_tokens(&output);
    let output = redact_url_credentials(&output);
    let output = redact_keyed_values(&output);
    redact_token_shapes(&output)
}

/// `redact_text` + redação de PII (e-mail). Use em conteúdo de fonte NÃO
/// confiável (ex.: campos de demanda externa, saída do provider). NÃO use na
/// auditoria de aprovações.
// Consumido pela geração de etapa / entrada não confiável (SDD-OAD-009/011).
#[allow(dead_code)]
pub fn redact_pii(input: &str) -> String {
    redact_emails(&redact_text(input))
}

fn redact_bearer_tokens(input: &str) -> String {
    static_regex(r"(?i)(\bauthorization\s*[:=]\s*bearer\s+)([A-Za-z0-9._~+/=-]{6,})")
        .replace_all(input, format!("$1{REDACTED_MARKER}"))
        .into_owned()
}

fn redact_keyed_values(input: &str) -> String {
    static_regex(
        r#"(?i)(["']?[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|token|secret|password|credential)[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(["']?)[^"',\s}]+(["']?)"#,
    )
    .replace_all(input, format!("$1$2{REDACTED_MARKER}$3"))
    .into_owned()
}

fn redact_token_shapes(input: &str) -> String {
    static_regex(
        r"(?x)
        \b(?:
            sk-ant-[A-Za-z0-9._-]{6,}
          | sk-[A-Za-z0-9._-]{6,}
          | AIza[A-Za-z0-9._-]{6,}
          | xox[abprs]-[A-Za-z0-9-]{6,}
          | gh[posru]_[A-Za-z0-9]{20,}
          | github_pat_[A-Za-z0-9_]{20,}
          | glpat-[A-Za-z0-9_-]{16,}
          | AKIA[A-Z0-9]{12,}
          | eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}
        )\b",
    )
    .replace_all(input, REDACTED_MARKER)
    .into_owned()
}

fn redact_url_credentials(input: &str) -> String {
    static_regex(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@")
        .replace_all(input, format!("$1{REDACTED_MARKER}@"))
        .into_owned()
}

fn redact_pem_blocks(input: &str) -> String {
    static_regex(r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----")
        .replace_all(input, REDACTED_MARKER)
        .into_owned()
}

fn redact_emails(input: &str) -> String {
    static_regex(r"(?i)\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
        .replace_all(input, REDACTED_MARKER)
        .into_owned()
}

fn static_regex(pattern: &str) -> Regex {
    Regex::new(pattern).expect("redaction regex must be valid")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn secret_display_and_debug_are_redacted() {
        let secret = Secret::new(String::from("sk-test-secret"));

        let display = format!("{secret}");
        let debug = format!("{secret:?}");
        let event = json!({ "token": secret.to_string() }).to_string();

        assert_eq!(display, REDACTED_MARKER);
        assert_eq!(debug, REDACTED_MARKER);
        assert!(event.contains(REDACTED_MARKER));
        assert!(!display.contains("sk-test-secret"));
        assert!(!debug.contains("sk-test-secret"));
        assert!(!event.contains("sk-test-secret"));
    }

    #[test]
    fn text_scrubber_redacts_known_token_shapes_and_keyed_values() {
        let rendered = redact_text(
            r#"OPENAI_API_KEY=sk-test-secret token: "sk-ant-secret-value" Authorization: Bearer sk-live-secret"#,
        );

        assert!(rendered.contains("OPENAI_API_KEY=[REDACTED]"));
        assert!(rendered.contains("token: \"[REDACTED]\""));
        assert!(rendered.contains("Authorization: Bearer [REDACTED]"));
        assert!(!rendered.contains("sk-test-secret"));
        assert!(!rendered.contains("sk-ant-secret-value"));
        assert!(!rendered.contains("sk-live-secret"));
    }

    #[test]
    fn redacts_provider_token_formats() {
        let input = concat!(
            "slack=xoxb-123456789012-abcdefabcdef ",
            "gh=ghp_0123456789012345678901234567890123 ",
            "gl=glpat-ABCDEFGHIJKLMNOPQRST ",
            "aws=AKIAIOSFODNN7EXAMPLE ",
            "jwt=eyJhbGciOiJI.eyJzdWIiOiE.SflKxwRJSMabc"
        );
        let out = redact_text(input);
        for leaked in [
            "xoxb-123456789012",
            "ghp_0123456789012345678901234567890123",
            "glpat-ABCDEFGHIJKLMNOPQRST",
            "AKIAIOSFODNN7EXAMPLE",
            "eyJzdWIiOiE",
        ] {
            assert!(!out.contains(leaked), "vazou {leaked} em: {out}");
        }
    }

    #[test]
    fn redacts_url_credentials_and_pem() {
        let url = redact_text("clone https://user:s3cretpw@git.example.com/repo.git");
        assert!(!url.contains("s3cretpw"), "{url}");
        assert!(url.contains("https://[REDACTED]@git.example.com"));

        let pem = redact_text("k:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretline\n-----END RSA PRIVATE KEY-----\nfim");
        assert!(!pem.contains("MIIEsecretline"), "{pem}");
        assert!(pem.contains("fim"));
    }

    #[test]
    fn email_is_pii_not_secret() {
        let input = "autor: alan@example.com aprovou";
        // Auditoria preserva o e-mail do autor.
        assert!(redact_text(input).contains("alan@example.com"));
        // Conteúdo não confiável redige PII.
        assert!(!redact_pii(input).contains("alan@example.com"));
        assert!(redact_pii(input).contains(REDACTED_MARKER));
    }
}