1use brain::BrainConfig;
17use serde::Serialize;
18
19pub 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#[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#[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
73pub 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 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 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 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_findings(config, &mut add);
216
217 exec_findings(config, &mut add);
219
220 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 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
274fn secret_findings(
277 config: &BrainConfig,
278 add: &mut impl FnMut(Severity, &'static str, String, String, String),
279) {
280 #[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
338fn 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
390const SHELL_OR_PRIV: &[&str] = &[
395 "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "eval", "exec", "sudo", "su", "doas",
396];
397
398const 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
419fn command_basename(entry: &str) -> &str {
422 entry.trim().rsplit(['/', '\\']).next().unwrap_or("").trim()
423}
424
425fn is_loopback_host(host: &str) -> bool {
427 matches!(
428 host.trim(),
429 "" | "localhost" | "127.0.0.1" | "::1" | "[::1]"
430 )
431}
432
433fn 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
452pub 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
478pub 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 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 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 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 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 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 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 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 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}