Skip to main content

paperless_cli/
security.rs

1use std::fmt;
2use std::sync::mpsc::{self, Receiver};
3use std::sync::{Arc, Mutex};
4use std::thread;
5use std::time::Duration;
6
7use serde::Serialize;
8
9#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Severity {
12    Critical,
13    High,
14    Medium,
15    Low,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
19pub struct SecurityFinding {
20    pub severity: Severity,
21    pub title: String,
22    pub detail: String,
23    pub remediation: String,
24}
25
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct SecurityAgentProfile {
28    pub name: String,
29    pub model: String,
30    pub operating_guide: String,
31}
32
33impl SecurityAgentProfile {
34    pub fn security_reviewer() -> Self {
35        Self {
36            name: "security-reviewer".to_string(),
37            model: "gpt-5.4".to_string(),
38            operating_guide:
39                "Scope, scan, review, classify, and report. Check auth first and redact secrets."
40                    .to_string(),
41        }
42    }
43}
44
45#[derive(Clone, Debug, Default, Eq, PartialEq)]
46pub struct AuditState {
47    pub base_url: Option<String>,
48    pub config_permissions_restricted: bool,
49    pub last_download_path: Option<String>,
50}
51
52impl AuditState {
53    pub fn new(
54        base_url: Option<String>,
55        config_permissions_restricted: bool,
56        last_download_path: Option<String>,
57    ) -> Self {
58        Self {
59            base_url,
60            config_permissions_restricted,
61            last_download_path,
62        }
63    }
64}
65
66pub type SharedAuditState = Arc<Mutex<AuditState>>;
67
68pub struct SecurityAuditor {
69    profile: SecurityAgentProfile,
70    interval: Duration,
71}
72
73impl fmt::Debug for SecurityAuditor {
74    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75        formatter
76            .debug_struct("SecurityAuditor")
77            .field("profile", &self.profile)
78            .field("interval", &self.interval)
79            .finish()
80    }
81}
82
83impl SecurityAuditor {
84    pub fn new(profile: SecurityAgentProfile, interval: Duration) -> Self {
85        Self { profile, interval }
86    }
87
88    pub fn profile(&self) -> &SecurityAgentProfile {
89        &self.profile
90    }
91
92    pub fn spawn(&self, shared_state: SharedAuditState) -> Receiver<Vec<SecurityFinding>> {
93        let (sender, receiver) = mpsc::channel();
94        let profile = self.profile.clone();
95        let interval = self.interval;
96
97        thread::spawn(move || loop {
98            let state = shared_state.lock().map(|guard| guard.clone());
99            let findings = match state {
100                Ok(state) => review_state(&profile, &state),
101                Err(_) => vec![SecurityFinding {
102                    severity: Severity::High,
103                    title: "Security monitor failed".to_string(),
104                    detail: "The security reviewer lost access to shared state.".to_string(),
105                    remediation: "Recreate the shared audit state and restart the TUI.".to_string(),
106                }],
107            };
108
109            if sender.send(findings).is_err() {
110                break;
111            }
112
113            thread::sleep(interval);
114        });
115
116        receiver
117    }
118
119    pub fn review_once(&self, state: &AuditState) -> Vec<SecurityFinding> {
120        review_state(&self.profile, state)
121    }
122}
123
124fn review_state(profile: &SecurityAgentProfile, state: &AuditState) -> Vec<SecurityFinding> {
125    let mut findings = Vec::new();
126
127    if profile.model != "gpt-5.4" {
128        findings.push(SecurityFinding {
129            severity: Severity::High,
130            title: "Security reviewer downgraded".to_string(),
131            detail: format!(
132                "The configured security agent model is `{}` instead of `gpt-5.4`.",
133                profile.model
134            ),
135            remediation: "Restore the security agent profile to gpt-5.4.".to_string(),
136        });
137    }
138
139    if let Some(url) = &state.base_url {
140        if url.starts_with("http://")
141            && !url.contains("127.0.0.1")
142            && !url.contains("localhost")
143            && !url.contains("[::1]")
144        {
145            findings.push(SecurityFinding {
146                severity: Severity::Medium,
147                title: "Paperless API is using plain HTTP".to_string(),
148                detail: format!("Traffic to `{url}` may expose tokens in transit."),
149                remediation: "Use HTTPS for remote Paperless servers.".to_string(),
150            });
151        }
152    }
153
154    if !state.config_permissions_restricted {
155        findings.push(SecurityFinding {
156            severity: Severity::High,
157            title: "Config permissions are too broad".to_string(),
158            detail: "The config file is readable by users other than the owner.".to_string(),
159            remediation: "Rewrite the config file and enforce 0600 permissions.".to_string(),
160        });
161    }
162
163    if let Some(path) = &state.last_download_path {
164        if path.contains("..") {
165            findings.push(SecurityFinding {
166                severity: Severity::High,
167                title: "Suspicious download path observed".to_string(),
168                detail: format!("The last download path `{path}` contains parent traversal."),
169                remediation: "Reject the filename and re-download into a sanitized path."
170                    .to_string(),
171            });
172        }
173    }
174
175    findings
176}