pub mod agent_frontmatter;
pub mod bash_patterns;
pub mod frontmatter;
pub mod malicious_urls;
pub mod obfuscation;
pub mod package_install;
pub mod pii;
pub mod prompt;
pub mod script_mixing;
pub mod secrets;
pub mod semgrep;
pub mod shared;
pub mod shellcheck;
pub mod typescript;
use crate::config::Config;
use crate::finding::ScanResult;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
use walkdir::WalkDir;
pub const EXTERNAL_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
pub trait Scanner: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn is_available(&self) -> bool;
fn scan(&self, path: &Path, config: &Config) -> ScanResult;
}
pub fn skill_scanners() -> Vec<Box<dyn Scanner>> {
vec![
Box::new(prompt::PromptScanner),
Box::new(bash_patterns::BashPatternScanner),
Box::new(typescript::TypeScriptScanner),
Box::new(package_install::PackageInstallScanner),
Box::new(malicious_urls::MaliciousUrlsScanner),
Box::new(obfuscation::ObfuscationScanner),
Box::new(pii::PiiScanner),
Box::new(script_mixing::ScriptMixingScanner),
Box::new(frontmatter::FrontmatterScanner),
Box::new(shellcheck::ShellCheckScanner),
Box::new(secrets::SecretsScanner),
Box::new(semgrep::SemgrepScanner),
]
}
pub fn agent_scanners() -> Vec<Box<dyn Scanner>> {
vec![
Box::new(prompt::PromptScanner),
Box::new(bash_patterns::BashPatternScanner),
Box::new(typescript::TypeScriptScanner),
Box::new(package_install::PackageInstallScanner),
Box::new(malicious_urls::MaliciousUrlsScanner),
Box::new(obfuscation::ObfuscationScanner),
Box::new(pii::PiiScanner),
Box::new(script_mixing::ScriptMixingScanner),
Box::new(agent_frontmatter::AgentFrontmatterScanner),
Box::new(shellcheck::ShellCheckScanner),
Box::new(secrets::SecretsScanner),
Box::new(semgrep::SemgrepScanner),
]
}
pub fn collect_files(path: &Path, extensions: &[&str]) -> Vec<PathBuf> {
let mut files = Vec::new();
for entry in WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let entry_path = entry.path();
if !entry_path.starts_with(path) {
continue;
}
if let Some(ext) = entry_path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.contains(&ext_str.as_str()) {
files.push(entry_path.to_path_buf());
}
}
}
files.sort();
files
}
pub fn which_exists(cmd: &str) -> bool {
std::env::var_os("PATH")
.map(|path| {
std::env::split_paths(&path).any(|dir| {
let candidate = dir.join(cmd);
if !candidate.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(&candidate)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
true
}
})
})
.unwrap_or(false)
}
pub fn run_with_timeout(
mut cmd: Command,
timeout: Duration,
scanner_name: &str,
start: Instant,
) -> Result<Output, ScanResult> {
let mut child = match cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
return Err(ScanResult::error(
scanner_name,
format!("Failed to run {}: {}", scanner_name, e),
start.elapsed().as_millis() as u64,
));
}
};
let poll_interval = Duration::from_millis(100);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(ScanResult {
scanner_name: scanner_name.to_string(),
findings: vec![],
files_scanned: 0,
skipped: true,
skip_reason: Some(format!(
"{} timed out after {}s",
scanner_name,
timeout.as_secs()
)),
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
});
}
std::thread::sleep(poll_interval);
}
Err(e) => {
return Err(ScanResult::error(
scanner_name,
format!("Failed to wait for {}: {}", scanner_name, e),
start.elapsed().as_millis() as u64,
));
}
}
}
child.wait_with_output().map_err(|e| {
ScanResult::error(
scanner_name,
format!("Failed to read {} output: {}", scanner_name, e),
start.elapsed().as_millis() as u64,
)
})
}
pub const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
pub fn read_file_limited(path: &Path) -> Result<String, String> {
use std::io::Read;
let path_meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
if !path_meta.file_type().is_file() {
return Err("not a regular file — skipping to prevent stream exhaustion".to_string());
}
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let meta = file
.metadata()
.map_err(|e| format!("cannot stat open file: {e}"))?;
let size = meta.len();
if size > MAX_FILE_SIZE_BYTES {
return Err(format!(
"file too large ({size} bytes); maximum is {MAX_FILE_SIZE_BYTES} \
bytes — skipping to prevent memory exhaustion"
));
}
let mut buf = Vec::with_capacity(size as usize);
file.take(MAX_FILE_SIZE_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|e| e.to_string())?;
if buf.len() as u64 > MAX_FILE_SIZE_BYTES {
return Err(format!(
"file exceeded {MAX_FILE_SIZE_BYTES} bytes during read — skipping"
));
}
String::from_utf8(buf).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn read_file_limited_accepts_regular_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("skill.md");
std::fs::write(&path, "hello world\n").unwrap();
let result = read_file_limited(&path);
assert_eq!(result.unwrap(), "hello world\n");
}
#[test]
fn read_file_limited_rejects_nonexistent_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does_not_exist.md");
let result = read_file_limited(&path);
assert!(
result.is_err(),
"must return Err for a path that does not exist"
);
}
#[test]
fn read_file_limited_rejects_oversized_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.md");
let oversized = vec![b'a'; (MAX_FILE_SIZE_BYTES + 1) as usize];
std::fs::write(&path, &oversized).unwrap();
let result = read_file_limited(&path);
assert!(
result.is_err(),
"must return Err for a file exceeding MAX_FILE_SIZE_BYTES"
);
let msg = result.unwrap_err();
assert!(
msg.contains("file too large") || msg.contains("exceeded"),
"error message should mention size limit, got: {msg}"
);
}
#[test]
fn read_file_limited_utf8_error_returns_err() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("binary.bin");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&[0xFF, 0xFE, 0x00, 0x01]).unwrap();
let result = read_file_limited(&path);
assert!(
result.is_err(),
"must return Err for non-UTF-8 file content"
);
}
#[cfg(unix)]
fn make_fifo(path: &std::path::Path) -> bool {
std::process::Command::new("mkfifo")
.arg(path)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
#[cfg(unix)]
fn read_file_limited_rejects_fifo_without_blocking() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.fifo");
if !make_fifo(&path) {
return;
}
let result = read_file_limited(&path);
assert!(
result.is_err(),
"must return Err for a FIFO — not block waiting for a writer"
);
let msg = result.unwrap_err();
assert!(
msg.contains("not a regular file"),
"error message should identify the special-file rejection, got: {msg}"
);
}
}
pub fn is_suppressed_inline(line: &str) -> bool {
static RE_INLINE_SUPPRESS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"(?i)\s*#\s*(scan|audit|oxidized-agentic-audit):ignore\s*$").unwrap()
});
RE_INLINE_SUPPRESS.is_match(line)
}
pub struct RuleInfo {
pub id: &'static str,
pub severity: &'static str,
pub scanner: &'static str,
pub message: &'static str,
pub remediation: &'static str,
}
pub fn all_rules() -> Vec<RuleInfo> {
let mut rules = Vec::new();
rules.extend(bash_patterns::rules());
rules.extend(typescript::rules());
rules.extend(prompt::rules());
rules.extend(package_install::rules());
rules.extend(malicious_urls::rules());
rules.extend(obfuscation::rules());
rules.extend(pii::rules());
rules.extend(script_mixing::rules());
rules.extend(frontmatter::rules());
rules.extend(shellcheck::rules());
rules.extend(secrets::rules());
rules.extend(semgrep::rules());
rules
}
pub fn all_unique_rules() -> Vec<RuleInfo> {
let mut rules = all_rules();
rules.extend(agent_frontmatter::rules());
rules
}
pub fn all_agent_rules() -> Vec<RuleInfo> {
let mut rules = Vec::new();
rules.extend(bash_patterns::rules());
rules.extend(typescript::rules());
rules.extend(prompt::rules());
rules.extend(package_install::rules());
rules.extend(malicious_urls::rules());
rules.extend(obfuscation::rules());
rules.extend(pii::rules());
rules.extend(script_mixing::rules());
rules.extend(agent_frontmatter::rules());
rules.extend(shellcheck::rules());
rules.extend(secrets::rules());
rules.extend(semgrep::rules());
rules
}