Skip to main content

brainos_backends/
security.rs

1//! Security-posture audit — the executor behind `brain security audit` and the
2//! `security.audit` native capability (Issue 140).
3//!
4//! A deterministic, offline pass over the *loaded config* that emits
5//! severity-ranked findings about the running posture — network exposure,
6//! authentication, egress, secret handling, the execution sandbox surface, and
7//! at-rest encryption. Truthful by construction: it reads only `config`, never
8//! the network or an LLM, so every finding is a literal consequence of what the
9//! config declares.
10//!
11//! The same pure [`audit`] function backs both surfaces: the CLI calls it
12//! directly and prints [`render`]; [`ConfigSecurityAuditor`] wraps it to satisfy
13//! the [`cortex::actions::SecurityAuditBackend`] trait the chat tool-loop
14//! dispatches `security.audit` through.
15
16use brain::BrainConfig;
17use serde::Serialize;
18
19/// The capability this backend declares: a read-only, offline security-posture
20/// audit. Always wired — pure config inspection, no I/O.
21pub fn capabilities(_config: &BrainConfig) -> Vec<intent::ToolDescriptor> {
22    use crate::capabilities::{backend, native, read_only, usage};
23    vec![native(
24        "security",
25        "audit",
26        backend("security"),
27        read_only(),
28        usage(
29            "The user asks about the security posture, hardening, exposure, or whether the configuration is safe.",
30            "For changing settings — this only reports; it never edits config.",
31            &["None — offline config inspection."],
32            "free / local config inspection",
33            "\"Audit my security setup\" / \"Is anything exposed?\"",
34        ),
35    )]
36}
37
38/// Finding severity, ordered most-to-least serious. Derived `Ord` follows
39/// declaration order, so sorting ascending puts the worst findings first.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum Severity {
43    Critical,
44    High,
45    Medium,
46    Low,
47    Info,
48}
49
50impl Severity {
51    fn label(self) -> &'static str {
52        match self {
53            Severity::Critical => "CRITICAL",
54            Severity::High => "HIGH",
55            Severity::Medium => "MEDIUM",
56            Severity::Low => "LOW",
57            Severity::Info => "INFO",
58        }
59    }
60}
61
62/// One security finding — a stable `id`, a one-line `title`, the `detail`
63/// explaining why it matters here, and a concrete `remediation`.
64#[derive(Debug, Clone, Serialize)]
65pub struct Finding {
66    pub severity: Severity,
67    pub id: &'static str,
68    pub title: String,
69    pub detail: String,
70    pub remediation: String,
71}
72
73// ─── the audit ──────────────────────────────────────────────────────────────
74
75/// Inspect `config` and produce the security findings, sorted worst-first.
76/// Pure — no I/O, no LLM — so it is deterministic and unit-testable.
77pub fn audit(config: &BrainConfig) -> Vec<Finding> {
78    let mut f = Vec::new();
79    let mut add = |severity, id, title: String, detail: String, remediation: String| {
80        f.push(Finding {
81            severity,
82            id,
83            title,
84            detail,
85            remediation,
86        });
87    };
88
89    let http = &config.adapters.http;
90    let a = &config.adapters;
91    let any_listener = http.enabled || a.ws.enabled || a.mcp.enabled || a.grpc.enabled;
92
93    // ── Network exposure ──────────────────────────────────────────────────
94    if http.enabled && !is_loopback_host(&http.host) {
95        add(
96            Severity::High,
97            "adapter-exposed",
98            format!(
99                "HTTP adapter bound to a non-loopback address ({})",
100                http.host
101            ),
102            "The HTTP API is reachable beyond localhost, so anyone who can route \
103             to this host can reach it."
104                .to_string(),
105            "Bind `adapters.http.host` to 127.0.0.1 unless remote access is \
106             intended; if it is, ensure API keys, rate limiting, and TLS \
107             termination are all in place."
108                .to_string(),
109        );
110    }
111
112    // ── Authentication ────────────────────────────────────────────────────
113    if any_listener && config.access.api_keys.is_empty() {
114        let exposed = http.enabled && !is_loopback_host(&http.host);
115        add(
116            if exposed {
117                Severity::High
118            } else {
119                Severity::Medium
120            },
121            "no-api-keys",
122            "Adapters are enabled with no API keys configured".to_string(),
123            "With `access.api_keys` empty the adapters fail closed (clients are \
124             denied), so the surface is locked rather than open — but it is also \
125             unusable, and a future key added without other controls would be the \
126             only thing standing between the network and the API."
127                .to_string(),
128            "Add scoped entries to `access.api_keys` (least privilege: prefer \
129             `read`/`write` over `admin`) before exposing any adapter."
130                .to_string(),
131        );
132    }
133    for key in &config.access.api_keys {
134        if key.key.trim().is_empty() {
135            add(
136                Severity::High,
137                "empty-api-key",
138                format!("API key '{}' has an empty secret", key.name),
139                "An empty key string authenticates trivially.".to_string(),
140                "Set a long, random secret (or remove the entry).".to_string(),
141            );
142        } else if key.key.trim().len() < 16 {
143            add(
144                Severity::Medium,
145                "weak-api-key",
146                format!(
147                    "API key '{}' is short ({} chars)",
148                    key.name,
149                    key.key.trim().len()
150                ),
151                "Short keys are guessable and weak against brute force.".to_string(),
152                "Use at least 32 random characters.".to_string(),
153            );
154        }
155        if key.has_permission("admin") {
156            add(
157                Severity::Info,
158                "admin-api-key",
159                format!("API key '{}' grants the `admin` scope", key.name),
160                "`admin` implicitly grants every scope (read/write/export).".to_string(),
161                "Grant the narrowest scopes a client actually needs.".to_string(),
162            );
163        }
164    }
165    if any_listener && !config.access.rate_limit.enabled {
166        add(
167            Severity::Medium,
168            "rate-limit-off",
169            "Per-client rate limiting is disabled while adapters are enabled".to_string(),
170            "Without rate limiting an abusive or compromised client can hammer the \
171             API (brute force, resource exhaustion) unthrottled."
172                .to_string(),
173            "Set `access.rate_limit.enabled = true`.".to_string(),
174        );
175    }
176    if http.enabled && http.cors {
177        add(
178            Severity::Medium,
179            "cors-enabled",
180            "CORS is enabled on the HTTP adapter".to_string(),
181            "Permissive CORS lets a browser page on any origin call the API with \
182             the visitor's ambient credentials."
183                .to_string(),
184            "Disable `adapters.http.cors` unless a trusted browser front-end needs \
185             it; if it does, scope the allowed origins."
186                .to_string(),
187        );
188    }
189    if http.enabled && !is_loopback_host(&http.host) && !http.sse_redact_previews {
190        add(
191            Severity::Low,
192            "sse-previews-exposed",
193            "SSE event previews are not redacted on an exposed HTTP adapter".to_string(),
194            "An observer with only `read` scope sees full LLM responses and \
195             notification bodies on the `/v1/events` stream."
196                .to_string(),
197            "Set `adapters.http.sse_redact_previews = true` for shared deployments.".to_string(),
198        );
199    }
200
201    // ── At-rest encryption ────────────────────────────────────────────────
202    if !config.encryption.enabled {
203        add(
204            Severity::Medium,
205            "encryption-disabled",
206            "At-rest encryption is disabled".to_string(),
207            "Stored memory (facts, episodes) is written to disk unencrypted; anyone \
208             who can read `~/.brain` can read everything Brain knows."
209                .to_string(),
210            "Set `encryption.enabled = true` to encrypt the store at rest.".to_string(),
211        );
212    }
213
214    // ── Secret handling ───────────────────────────────────────────────────
215    secret_findings(config, &mut add);
216
217    // ── Execution sandbox surface ─────────────────────────────────────────
218    exec_findings(config, &mut add);
219
220    // ── Filesystem read surface ───────────────────────────────────────────
221    for p in &config.security.allowed_paths {
222        if let Some(what) = path_is_broad(p) {
223            add(
224                Severity::High,
225                "broad-allowed-path",
226                format!("Filesystem read is allowed to touch {what} ('{p}')"),
227                "Read-only file grounding can be pointed at sensitive locations far \
228                 outside the user's project space."
229                    .to_string(),
230                "Restrict `security.allowed_paths` to the specific project roots \
231                 Brain should read (e.g. ~/code)."
232                    .to_string(),
233            );
234        }
235    }
236
237    // ── Egress / capability surface (informational) ──────────────────────
238    if a.terminal.enabled {
239        add(
240            Severity::Low,
241            "terminal-bridge",
242            "The terminal bridge is enabled".to_string(),
243            "AI agents can open interactive PTY sessions and run commands through \
244             the bridge."
245                .to_string(),
246            "Disable `adapters.terminal.enabled` if PTY access is not needed.".to_string(),
247        );
248    }
249    if config.actions.web_search.enabled {
250        add(
251            Severity::Info,
252            "web-egress",
253            "Outbound web search/fetch is enabled".to_string(),
254            "Brain can make outbound HTTP requests on the user's behalf.".to_string(),
255            "Leave enabled if external lookups are wanted; disable \
256             `actions.web_search.enabled` to keep Brain fully offline."
257                .to_string(),
258        );
259    }
260    if config.actions.messaging.enabled {
261        add(
262            Severity::Info,
263            "messaging-egress",
264            "Outbound messaging is enabled".to_string(),
265            "Brain can deliver messages to configured external channels.".to_string(),
266            "Disable `actions.messaging.enabled` to prevent outbound delivery.".to_string(),
267        );
268    }
269
270    f.sort_by(|x, y| x.severity.cmp(&y.severity).then(x.id.cmp(y.id)));
271    f
272}
273
274/// Findings about secrets stored inline in YAML (which is typically backed up,
275/// version-controlled, and replicated) rather than in a chmod-0600 file.
276fn secret_findings(
277    config: &BrainConfig,
278    add: &mut impl FnMut(Severity, &'static str, String, String, String),
279) {
280    // Legacy single-provider key.
281    #[allow(deprecated)]
282    let legacy_key = config.llm.api_key.trim();
283    #[allow(deprecated)]
284    let legacy_has_file = config.llm.api_key_file.is_some();
285    if !legacy_key.is_empty() && !legacy_has_file {
286        add(
287            Severity::High,
288            "plaintext-llm-key",
289            "`llm.api_key` is stored in plaintext in the config".to_string(),
290            "The YAML config is commonly backed up and replicated, carrying the \
291             secret with it."
292                .to_string(),
293            "Move it to `llm.api_key_file` (a chmod-0600 file) or the credential \
294             vault."
295                .to_string(),
296        );
297    }
298    for p in &config.llm.providers {
299        if !p.api_key.trim().is_empty() && p.api_key_file.is_none() {
300            add(
301                Severity::High,
302                "plaintext-provider-key",
303                format!("Provider '{}' stores its api_key in plaintext", p.name),
304                "The YAML config is commonly backed up and replicated, carrying the \
305                 secret with it."
306                    .to_string(),
307                format!(
308                    "Set `llm.providers[].api_key_file` for '{}' instead.",
309                    p.name
310                ),
311            );
312        }
313    }
314    if !config.actions.web_search.api_key.trim().is_empty() {
315        add(
316            Severity::Medium,
317            "plaintext-search-key",
318            "`actions.web_search.api_key` is stored in plaintext".to_string(),
319            "The search-provider key lives in the backed-up config.".to_string(),
320            "Provide it via the `BRAIN_*` environment instead of YAML where \
321             possible."
322                .to_string(),
323        );
324    }
325    for r in &config.channel.relays {
326        if !r.api_key.trim().is_empty() {
327            add(
328                Severity::Medium,
329                "plaintext-relay-key",
330                format!("Relay '{}' stores its api_key in plaintext", r.label),
331                "The relay bearer token lives in the backed-up config.".to_string(),
332                "Avoid committing the config, or rotate the token if it has been.".to_string(),
333            );
334        }
335    }
336}
337
338/// Findings about the command-execution sandbox surface — which binaries the
339/// allowlist permits.
340fn exec_findings(
341    config: &BrainConfig,
342    add: &mut impl FnMut(Severity, &'static str, String, String, String),
343) {
344    let mut shell = Vec::new();
345    let mut arbitrary = Vec::new();
346    for entry in &config.security.exec_allowlist {
347        let name = command_basename(entry);
348        if SHELL_OR_PRIV.contains(&name) {
349            shell.push(entry.clone());
350        } else if ARBITRARY_CODE.contains(&name) {
351            arbitrary.push(entry.clone());
352        }
353    }
354    if !shell.is_empty() {
355        add(
356            Severity::High,
357            "exec-shell-allowed",
358            format!(
359                "Exec allowlist permits a shell / privilege tool: {}",
360                shell.join(", ")
361            ),
362            "A shell (or sudo/su) on the allowlist enables the shell-wrapped \
363             execution tier, where the per-binary allowlist is bypassed for the \
364             wrapped command — effectively arbitrary command execution (rlimits, \
365             network deny, and the forbidden list still apply)."
366                .to_string(),
367            "Remove shell/privilege entries from `security.exec_allowlist` unless \
368             the shell tier is genuinely required."
369                .to_string(),
370        );
371    }
372    if !arbitrary.is_empty() {
373        add(
374            Severity::Medium,
375            "exec-interpreter-allowed",
376            format!(
377                "Exec allowlist permits interpreters/tools that can run arbitrary code: {}",
378                arbitrary.join(", ")
379            ),
380            "Language interpreters and tools like find/awk/xargs can execute \
381             arbitrary code or spawn child processes, widening the sandbox surface."
382                .to_string(),
383            "Drop entries the assistant does not need from \
384             `security.exec_allowlist`."
385                .to_string(),
386        );
387    }
388}
389
390// ─── classification tables + helpers ─────────────────────────────────────────
391
392/// Shells and privilege-escalation tools — their presence sidesteps the
393/// per-binary allowlist via the shell-wrapped execution tier.
394const SHELL_OR_PRIV: &[&str] = &[
395    "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "eval", "exec", "sudo", "su", "doas",
396];
397
398/// Interpreters and tools that can run arbitrary code or spawn children.
399const ARBITRARY_CODE: &[&str] = &[
400    "python",
401    "python3",
402    "ruby",
403    "perl",
404    "node",
405    "npm",
406    "npx",
407    "php",
408    "lua",
409    "rscript",
410    "osascript",
411    "env",
412    "xargs",
413    "awk",
414    "gawk",
415    "sed",
416    "find",
417];
418
419/// The bare command name of an allowlist entry, lowercased — so `/bin/SH` and
420/// `sh` both match the tables.
421fn command_basename(entry: &str) -> &str {
422    entry.trim().rsplit(['/', '\\']).next().unwrap_or("").trim()
423}
424
425/// True when `host` is a loopback / not-network-exposed bind address.
426fn is_loopback_host(host: &str) -> bool {
427    matches!(
428        host.trim(),
429        "" | "localhost" | "127.0.0.1" | "::1" | "[::1]"
430    )
431}
432
433/// If `p` resolves to an over-broad root, return a human description of it;
434/// otherwise `None`. `$HOME`/`~` are treated as the home root.
435fn path_is_broad(p: &str) -> Option<&'static str> {
436    let t = p.trim().trim_end_matches('/');
437    let t = if t.is_empty() { "/" } else { t };
438    if t.contains("..") {
439        return Some("a path with `..` traversal");
440    }
441    match t {
442        "" | "~" | "$HOME" => Some("the entire home directory"),
443        "/" => Some("the filesystem root"),
444        "/etc" => Some("system configuration (/etc)"),
445        "/Users" | "/home" => Some("all user home directories"),
446        "/root" => Some("the root account's home"),
447        "/var" | "/usr" | "/private" | "/System" => Some("a system directory"),
448        _ => None,
449    }
450}
451
452// ─── render ───────────────────────────────────────────────────────────────
453
454/// Render the findings as the human-readable report both surfaces print.
455pub fn render(findings: &[Finding]) -> String {
456    if findings.is_empty() {
457        return "Security audit — no findings. Posture looks clean.\n".to_string();
458    }
459    let count = |s: Severity| findings.iter().filter(|x| x.severity == s).count();
460    let mut out = format!(
461        "Security audit — {} finding{} ({} critical, {} high, {} medium, {} low, {} info)\n\n",
462        findings.len(),
463        if findings.len() == 1 { "" } else { "s" },
464        count(Severity::Critical),
465        count(Severity::High),
466        count(Severity::Medium),
467        count(Severity::Low),
468        count(Severity::Info),
469    );
470    for fnd in findings {
471        out.push_str(&format!("  [{:<8}] {}\n", fnd.severity.label(), fnd.title));
472        out.push_str(&format!("             {}\n", fnd.detail));
473        out.push_str(&format!("             → {}\n\n", fnd.remediation));
474    }
475    out
476}
477
478// ─── cortex backend wiring ──────────────────────────────────────────────────
479
480/// The [`cortex::actions::SecurityAuditBackend`] implementation. Holds a
481/// snapshot of the config taken at boot and re-runs the pure [`audit`] on each
482/// dispatch, returning the rendered report. The chat tool-loop dispatches
483/// `security.audit` through this; the CLI calls [`audit`]/[`render`] directly.
484pub struct ConfigSecurityAuditor {
485    config: BrainConfig,
486}
487
488impl ConfigSecurityAuditor {
489    pub fn new(config: BrainConfig) -> Self {
490        Self { config }
491    }
492}
493
494#[async_trait::async_trait]
495impl cortex::actions::SecurityAuditBackend for ConfigSecurityAuditor {
496    async fn audit(&self) -> Result<String, cortex::actions::ActionError> {
497        Ok(render(&audit(&self.config)))
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    fn clean_config() -> BrainConfig {
506        let mut c = BrainConfig::default();
507        // A deliberately tightened posture so the baseline is "no findings",
508        // letting each test reintroduce exactly one issue.
509        c.encryption.enabled = true;
510        c.adapters.http.host = "127.0.0.1".to_string();
511        c.adapters.http.cors = false;
512        c.adapters.http.enabled = true;
513        c.adapters.ws.enabled = false;
514        c.adapters.mcp.enabled = false;
515        c.adapters.grpc.enabled = false;
516        c.adapters.terminal.enabled = false;
517        c.access.rate_limit.enabled = true;
518        c.access.api_keys = vec![brain::config::ApiKeyConfig {
519            key: "0123456789abcdef0123456789abcdef".to_string(),
520            name: "local".to_string(),
521            permissions: vec!["read".to_string()],
522            agent_id: None,
523        }];
524        c.security.exec_allowlist = vec!["ls".to_string(), "git".to_string()];
525        c.security.allowed_paths = vec!["~/code".to_string()];
526        c.actions.web_search.enabled = false;
527        c.actions.web_search.api_key = String::new();
528        c.actions.messaging.enabled = false;
529        #[allow(deprecated)]
530        {
531            c.llm.api_key = String::new();
532        }
533        c.llm.providers = vec![];
534        c.channel.relays = vec![];
535        c
536    }
537
538    fn ids(findings: &[Finding]) -> Vec<&'static str> {
539        findings.iter().map(|f| f.id).collect()
540    }
541
542    #[test]
543    fn clean_config_has_no_findings() {
544        let findings = audit(&clean_config());
545        assert!(
546            findings.is_empty(),
547            "unexpected findings: {:?}",
548            ids(&findings)
549        );
550        assert!(render(&findings).contains("no findings"));
551    }
552
553    #[test]
554    fn default_config_flags_expected_posture() {
555        // The shipped defaults are convenient, not hardened: at-rest encryption
556        // off, CORS on, and `sh` on the exec allowlist. The audit surfaces them.
557        let findings = audit(&BrainConfig::default());
558        let got = ids(&findings);
559        assert!(got.contains(&"encryption-disabled"), "{got:?}");
560        assert!(got.contains(&"cors-enabled"), "{got:?}");
561        assert!(got.contains(&"exec-shell-allowed"), "{got:?}");
562    }
563
564    #[test]
565    fn non_loopback_bind_is_high() {
566        let mut c = clean_config();
567        c.adapters.http.host = "0.0.0.0".to_string();
568        let findings = audit(&c);
569        let exposed = findings.iter().find(|f| f.id == "adapter-exposed").unwrap();
570        assert_eq!(exposed.severity, Severity::High);
571        // ::1 and localhost stay clean.
572        c.adapters.http.host = "::1".to_string();
573        assert!(!ids(&audit(&c)).contains(&"adapter-exposed"));
574    }
575
576    #[test]
577    fn missing_keys_escalate_when_exposed() {
578        let mut c = clean_config();
579        c.access.api_keys.clear();
580        // Loopback + no keys → Medium.
581        let medium = audit(&c)
582            .into_iter()
583            .find(|f| f.id == "no-api-keys")
584            .unwrap();
585        assert_eq!(medium.severity, Severity::Medium);
586        // Exposed + no keys → High.
587        c.adapters.http.host = "0.0.0.0".to_string();
588        let high = audit(&c)
589            .into_iter()
590            .find(|f| f.id == "no-api-keys")
591            .unwrap();
592        assert_eq!(high.severity, Severity::High);
593    }
594
595    #[test]
596    fn weak_and_empty_keys_flagged() {
597        let mut c = clean_config();
598        c.access.api_keys[0].key = "short".to_string();
599        assert!(ids(&audit(&c)).contains(&"weak-api-key"));
600        c.access.api_keys[0].key = "  ".to_string();
601        assert!(ids(&audit(&c)).contains(&"empty-api-key"));
602    }
603
604    #[test]
605    fn plaintext_secrets_flagged() {
606        let mut c = clean_config();
607        c.llm.providers = vec![brain::config::ProviderEntry {
608            name: "groq".to_string(),
609            kind: "groq".to_string(),
610            base_url: String::new(),
611            api_key: "sk-secret-token".to_string(),
612            api_key_file: None,
613            model: "llama".to_string(),
614            preferred_models: vec![],
615        }];
616        assert!(ids(&audit(&c)).contains(&"plaintext-provider-key"));
617        // A file-backed key is fine.
618        c.llm.providers[0].api_key_file = Some("/run/secrets/groq".into());
619        assert!(!ids(&audit(&c)).contains(&"plaintext-provider-key"));
620    }
621
622    #[test]
623    fn risky_exec_entries_classified() {
624        let mut c = clean_config();
625        c.security.exec_allowlist = vec!["ls".into(), "/bin/bash".into(), "python3".into()];
626        let findings = audit(&c);
627        let got = ids(&findings);
628        assert!(got.contains(&"exec-shell-allowed"), "{got:?}");
629        assert!(got.contains(&"exec-interpreter-allowed"), "{got:?}");
630        // `/bin/bash` matches by basename.
631        let shell = findings
632            .iter()
633            .find(|f| f.id == "exec-shell-allowed")
634            .unwrap();
635        assert!(shell.title.contains("/bin/bash"));
636    }
637
638    #[test]
639    fn broad_allowed_paths_flagged() {
640        let mut c = clean_config();
641        c.security.allowed_paths = vec!["/".into()];
642        assert!(ids(&audit(&c)).contains(&"broad-allowed-path"));
643        c.security.allowed_paths = vec!["~".into()];
644        assert!(ids(&audit(&c)).contains(&"broad-allowed-path"));
645        c.security.allowed_paths = vec!["~/projects/../../etc".into()];
646        assert!(ids(&audit(&c)).contains(&"broad-allowed-path"));
647    }
648
649    #[test]
650    fn findings_sorted_worst_first() {
651        // High (exposed) should precede Medium (cors) should precede Info.
652        let mut c = clean_config();
653        c.adapters.http.host = "0.0.0.0".to_string();
654        c.adapters.http.cors = true;
655        c.actions.web_search.enabled = true;
656        let findings = audit(&c);
657        let severities: Vec<Severity> = findings.iter().map(|f| f.severity).collect();
658        let mut sorted = severities.clone();
659        sorted.sort();
660        assert_eq!(severities, sorted, "findings must be worst-first");
661    }
662
663    #[test]
664    fn is_loopback_classification() {
665        assert!(is_loopback_host("127.0.0.1"));
666        assert!(is_loopback_host("localhost"));
667        assert!(is_loopback_host("::1"));
668        assert!(is_loopback_host(""));
669        assert!(!is_loopback_host("0.0.0.0"));
670        assert!(!is_loopback_host("192.168.1.10"));
671    }
672}