paperless_cli/
security.rs1use 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}