paperless-cli 2.1.0

Rust TUI and LLM-friendly client for Paperless-ngx
Documentation
use std::fmt;
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use serde::Serialize;

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Critical,
    High,
    Medium,
    Low,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct SecurityFinding {
    pub severity: Severity,
    pub title: String,
    pub detail: String,
    pub remediation: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SecurityAgentProfile {
    pub name: String,
    pub model: String,
    pub operating_guide: String,
}

impl SecurityAgentProfile {
    pub fn security_reviewer() -> Self {
        Self {
            name: "security-reviewer".to_string(),
            model: "gpt-5.4".to_string(),
            operating_guide:
                "Scope, scan, review, classify, and report. Check auth first and redact secrets."
                    .to_string(),
        }
    }
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct AuditState {
    pub base_url: Option<String>,
    pub config_permissions_restricted: bool,
    pub last_download_path: Option<String>,
}

impl AuditState {
    pub fn new(
        base_url: Option<String>,
        config_permissions_restricted: bool,
        last_download_path: Option<String>,
    ) -> Self {
        Self {
            base_url,
            config_permissions_restricted,
            last_download_path,
        }
    }
}

pub type SharedAuditState = Arc<Mutex<AuditState>>;

pub struct SecurityAuditor {
    profile: SecurityAgentProfile,
    interval: Duration,
}

impl fmt::Debug for SecurityAuditor {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("SecurityAuditor")
            .field("profile", &self.profile)
            .field("interval", &self.interval)
            .finish()
    }
}

impl SecurityAuditor {
    pub fn new(profile: SecurityAgentProfile, interval: Duration) -> Self {
        Self { profile, interval }
    }

    pub fn profile(&self) -> &SecurityAgentProfile {
        &self.profile
    }

    pub fn spawn(&self, shared_state: SharedAuditState) -> Receiver<Vec<SecurityFinding>> {
        let (sender, receiver) = mpsc::channel();
        let profile = self.profile.clone();
        let interval = self.interval;

        thread::spawn(move || loop {
            let state = shared_state.lock().map(|guard| guard.clone());
            let findings = match state {
                Ok(state) => review_state(&profile, &state),
                Err(_) => vec![SecurityFinding {
                    severity: Severity::High,
                    title: "Security monitor failed".to_string(),
                    detail: "The security reviewer lost access to shared state.".to_string(),
                    remediation: "Recreate the shared audit state and restart the TUI.".to_string(),
                }],
            };

            if sender.send(findings).is_err() {
                break;
            }

            thread::sleep(interval);
        });

        receiver
    }

    pub fn review_once(&self, state: &AuditState) -> Vec<SecurityFinding> {
        review_state(&self.profile, state)
    }
}

fn review_state(profile: &SecurityAgentProfile, state: &AuditState) -> Vec<SecurityFinding> {
    let mut findings = Vec::new();

    if profile.model != "gpt-5.4" {
        findings.push(SecurityFinding {
            severity: Severity::High,
            title: "Security reviewer downgraded".to_string(),
            detail: format!(
                "The configured security agent model is `{}` instead of `gpt-5.4`.",
                profile.model
            ),
            remediation: "Restore the security agent profile to gpt-5.4.".to_string(),
        });
    }

    if let Some(url) = &state.base_url {
        if url.starts_with("http://")
            && !url.contains("127.0.0.1")
            && !url.contains("localhost")
            && !url.contains("[::1]")
        {
            findings.push(SecurityFinding {
                severity: Severity::Medium,
                title: "Paperless API is using plain HTTP".to_string(),
                detail: format!("Traffic to `{url}` may expose tokens in transit."),
                remediation: "Use HTTPS for remote Paperless servers.".to_string(),
            });
        }
    }

    if !state.config_permissions_restricted {
        findings.push(SecurityFinding {
            severity: Severity::High,
            title: "Config permissions are too broad".to_string(),
            detail: "The config file is readable by users other than the owner.".to_string(),
            remediation: "Rewrite the config file and enforce 0600 permissions.".to_string(),
        });
    }

    if let Some(path) = &state.last_download_path {
        if path.contains("..") {
            findings.push(SecurityFinding {
                severity: Severity::High,
                title: "Suspicious download path observed".to_string(),
                detail: format!("The last download path `{path}` contains parent traversal."),
                remediation: "Reject the filename and re-download into a sanitized path."
                    .to_string(),
            });
        }
    }

    findings
}