use anyhow::Result;
use super::style::*;
use crate::{cli::SecurityCommand, config};
pub async fn cmd_security(sub: SecurityCommand) -> Result<()> {
match sub {
#[cfg(unix)]
SecurityCommand::Audit(args) => cmd_security_audit(args).await,
#[cfg(not(unix))]
SecurityCommand::Audit(_) => {
anyhow::bail!("security audit is only supported on Unix-like systems");
}
}
}
#[cfg(unix)]
async fn cmd_security_audit(args: crate::cli::SecurityAuditArgs) -> Result<()> {
banner(&format!("rsclaw security audit v{}", option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev")));
let base = crate::config::loader::base_dir();
let mut issues: Vec<String> = Vec::new();
let mut fixed = 0usize;
for cfg_path in [
base.join("rsclaw.json5"),
] {
if !cfg_path.exists() {
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let meta = std::fs::metadata(&cfg_path)?;
let mode = meta.permissions().mode();
if mode & 0o044 != 0 {
let issue = format!(
"config {} is world/group-readable (mode {:04o})",
cfg_path.display(),
mode & 0o777
);
if args.fix {
std::fs::set_permissions(
&cfg_path,
std::fs::Permissions::from_mode(mode & !0o077),
)?;
ok(&format!("fixed: {issue}"));
fixed += 1;
} else {
issues.push(issue);
}
}
}
#[cfg(windows)]
{
let cfg_dir = cfg_path.parent().unwrap_or(std::path::Path::new("."));
if let Some(public) = std::env::var_os("PUBLIC") {
if cfg_dir.starts_with(std::path::Path::new(&public)) {
issues.push(format!(
"config {} is under the Public folder -- move to %LOCALAPPDATA%",
cfg_path.display()
));
}
}
}
}
for cfg_path in [base.join("rsclaw.json5")] {
if !cfg_path.exists() {
continue;
}
let raw = std::fs::read_to_string(&cfg_path)?;
for (lineno, line) in raw.lines().enumerate() {
let lower = line.to_lowercase();
if (lower.contains("api_key") || lower.contains("apikey") || lower.contains("token"))
&& line.contains('=')
&& !line.contains("${")
&& !line.trim_start().starts_with('#')
{
let rhs = line.split_once('=').map_or("", |(_, v)| v).trim();
if rhs.is_empty() || rhs == "\"\"" || rhs == "''" {
continue;
}
issues.push(format!(
"{}:{} may contain a plaintext secret: {}",
cfg_path.display(),
lineno + 1,
&line[..line.len().min(60)]
));
}
}
}
if issues.is_empty() && fixed == 0 {
ok("no issues found");
} else {
if fixed > 0 {
ok(&format!("fixed {} issue(s)", bold(&fixed.to_string())));
}
if !issues.is_empty() {
err_msg(&format!("{} issue(s) found:", issues.len()));
for issue in &issues {
println!(" - {}", red(issue));
}
if !args.fix {
println!();
println!(" {}", dim("Run with --fix to auto-correct where possible."));
}
}
}
if args.deep {
#[cfg(unix)]
{
let state = config::loader::base_dir();
if state.is_dir() {
scan_dir_permissions(&state, 0, 4, args.fix, &mut issues, &mut fixed)?;
}
}
#[cfg(windows)]
{
println!(" {} deep permission scan skipped (Windows uses ACL, not mode bits)", dim("[--]"));
}
}
Ok(())
}
#[cfg(unix)]
pub fn scan_dir_permissions(
dir: &std::path::Path,
depth: usize,
max_depth: usize,
fix: bool,
issues: &mut Vec<String>,
fixed: &mut usize,
) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
if depth > max_depth {
return Ok(());
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(()),
};
for entry in entries.filter_map(|e| e.ok()) {
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let path = entry.path();
let mode = meta.permissions().mode();
if meta.is_file() && mode & 0o044 != 0 {
let issue = format!(
"state file {} is group/world-readable (mode {:04o})",
path.display(),
mode & 0o777
);
if fix {
if std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode & !0o077))
.is_ok()
{
ok(&format!("fixed: {issue}"));
*fixed += 1;
}
} else {
issues.push(issue);
}
} else if meta.is_dir() {
scan_dir_permissions(&path, depth + 1, max_depth, fix, issues, fixed)?;
}
}
Ok(())
}