use std::path::Path;
use anyhow::Result;
use crate::finding::{Category, Finding, Severity};
use crate::scanner::{ScanContext, Scanner};
pub struct PermissionsScanner;
impl Scanner for PermissionsScanner {
fn name(&self) -> &'static str {
"permissions"
}
fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
check_permissions(&ctx.root, &mut findings);
Ok(findings)
}
}
fn check_permissions(root: &Path, findings: &mut Vec<Finding>) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let sensitive_files = [
(".credentials.json", Severity::Critical, 0o077),
("credentials.json", Severity::Critical, 0o077),
("history.jsonl", Severity::High, 0o044),
("settings.json", Severity::Medium, 0o022),
];
for (name, severity, bad_mask) in &sensitive_files {
let path = root.join(name);
if !path.exists() {
continue;
}
if let Ok(meta) = std::fs::metadata(&path) {
let mode = meta.permissions().mode();
if mode & bad_mask != 0 {
let actual = format!("{:o}", mode & 0o777);
let action = if bad_mask & 0o022 != 0 {
"read or write"
} else {
"read"
};
findings.push(
Finding::new(
*severity,
Category::FilePermissions,
format!("Insecure permissions on {}", name),
format!(
"'{}' has permissions {:o} — other users on this system \
can {} this file, which may expose credentials or allow tampering.",
path.display(),
mode & 0o777,
action,
),
&path,
format!("Run: chmod 600 \"{}\"", path.display()),
)
.with_evidence(format!("mode={}", actual)),
);
}
}
}
if let Ok(meta) = std::fs::metadata(root) {
let mode = meta.permissions().mode();
if mode & 0o007 != 0 {
findings.push(
Finding::new(
Severity::High,
Category::FilePermissions,
"Installation directory is world-accessible",
format!(
"The directory '{}' has permissions {:o}. Any user on this \
system can list or read files inside it.",
root.display(),
mode & 0o777
),
root,
format!("Run: chmod 700 \"{}\"", root.display()),
)
.with_evidence(format!("mode={:o}", mode & 0o777)),
);
}
}
let backups = root.join("backups");
if backups.exists() {
if let Ok(meta) = std::fs::metadata(&backups) {
let mode = meta.permissions().mode();
if mode & 0o044 != 0 {
findings.push(
Finding::new(
Severity::High,
Category::FilePermissions,
"Backups directory is readable by others",
format!(
"'{}' contains backup files and has permissions {:o}. \
Backups may contain credentials or sensitive history.",
backups.display(),
mode & 0o777
),
&backups,
format!("Run: chmod 700 \"{}\"", backups.display()),
)
.with_evidence(format!("mode={:o}", mode & 0o777)),
);
}
}
}
}
#[cfg(not(unix))]
{
let _ = root;
let _ = findings;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
mod unix_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn make_file_with_mode(dir: &TempDir, name: &str, mode: u32) -> std::path::PathBuf {
let path = dir.path().join(name);
std::fs::write(&path, b"test").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
path
}
#[test]
fn detects_world_readable_credentials() {
let dir = tempfile::tempdir().unwrap();
make_file_with_mode(&dir, ".credentials.json", 0o644);
let mut findings = Vec::new();
check_permissions(dir.path(), &mut findings);
assert!(
findings.iter().any(|f| f.severity == Severity::Critical),
"should flag world-readable credentials"
);
}
#[test]
fn no_finding_for_secure_credentials() {
let dir = tempfile::tempdir().unwrap();
make_file_with_mode(&dir, ".credentials.json", 0o600);
let mut findings = Vec::new();
check_permissions(dir.path(), &mut findings);
assert!(
!findings.iter().any(|f| f.title.contains("credentials")),
"should not flag 600 credentials file"
);
}
#[test]
fn detects_world_readable_history() {
let dir = tempfile::tempdir().unwrap();
make_file_with_mode(&dir, "history.jsonl", 0o644);
let mut findings = Vec::new();
check_permissions(dir.path(), &mut findings);
assert!(
findings.iter().any(|f| f.title.contains("history")),
"should flag world-readable history"
);
}
#[test]
fn no_finding_when_files_absent() {
let dir = tempfile::tempdir().unwrap();
let mut findings = Vec::new();
check_permissions(dir.path(), &mut findings);
assert!(!findings.iter().any(|f| f.title.contains("credentials")),);
}
}
#[test]
fn scanner_returns_ok() {
let dir = tempfile::tempdir().unwrap();
let ctx = ScanContext {
root: dir.path().to_path_buf(),
framework: crate::paths::FrameworkHint::Unknown,
};
let scanner = PermissionsScanner;
assert!(scanner.scan(&ctx).is_ok());
}
}