use crate::config::Config;
use crate::finding::{Finding, ScanResult, Severity};
use crate::scanners::{collect_files, is_suppressed_inline, read_file_limited, RuleInfo, Scanner};
use regex::Regex;
use std::path::Path;
use std::sync::LazyLock;
use std::time::Instant;
static RE_NPM_INSTALL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bnpm\s+(install|i|add)\b").unwrap());
static RE_BUN_ADD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bbun\s+(add|install)\b").unwrap());
static RE_PIP_INSTALL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bpip3?\s+install\b").unwrap());
static RE_YARN_ADD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\byarn\s+(add|install)\b").unwrap());
static RE_PNPM_ADD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bpnpm\s+(add|install|i)\b").unwrap());
static RE_LATEST: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"@latest\b").unwrap());
static RE_HAS_REGISTRY: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)--registry[=\s]").unwrap());
static RE_HAS_INDEX_URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)(--index-url\s|-i\s)").unwrap());
static RE_REGISTRY_URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)--registry[=\s](https?://\S+)").unwrap());
static RE_REGISTRY_HOST: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)https?://(?:[^@/?#\s]+@)?([^/?#:\s]+)").unwrap());
fn make_snippet(line: &str) -> String {
let trimmed = line.trim();
if trimmed.len() > 120 {
let cut = trimmed
.char_indices()
.nth(117)
.map(|(i, _)| i)
.unwrap_or(trimmed.len());
format!("{}...", &trimmed[..cut])
} else {
trimmed.to_string()
}
}
#[allow(clippy::too_many_arguments)]
fn emit(
findings: &mut Vec<Finding>,
id: &str,
severity: Severity,
message: &str,
remediation: &str,
file: &std::path::Path,
line_num: usize,
line: &str,
) {
findings.push(Finding {
rule_id: id.to_string(),
message: message.to_string(),
severity,
file: Some(file.to_path_buf()),
line: Some(line_num),
column: None,
scanner: "package_install".to_string(),
snippet: Some(make_snippet(line)),
suppressed: false,
suppression_reason: None,
remediation: Some(remediation.to_string()),
});
}
pub struct PackageInstallScanner;
impl Scanner for PackageInstallScanner {
fn name(&self) -> &'static str {
"package_install"
}
fn description(&self) -> &'static str {
"Package install audit — detects unregistered/unpinned installs"
}
fn is_available(&self) -> bool {
true
}
fn scan(&self, path: &Path, config: &Config) -> ScanResult {
let start = Instant::now();
let files = collect_files(path, &["sh", "bash", "zsh"]);
let mut findings = Vec::new();
let allowed_registries: Vec<&str> = config
.allowlist
.registries
.iter()
.filter(|r| !r.is_empty())
.map(String::as_str)
.collect();
for file in &files {
let content = match read_file_limited(file) {
Ok(c) => c,
Err(_) => continue,
};
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1;
let trimmed = line.trim();
if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
continue;
}
if is_suppressed_inline(line) {
continue;
}
if RE_NPM_INSTALL.is_match(line) && !RE_HAS_REGISTRY.is_match(line) {
emit(
&mut findings,
"pkg/F1-npm",
Severity::Warning,
"npm install without --registry — may pull from unexpected source",
"Specify --registry explicitly: npm install --registry https://registry.npmjs.org",
file,
line_num,
line,
);
}
if RE_BUN_ADD.is_match(line) && !RE_HAS_REGISTRY.is_match(line) {
emit(
&mut findings,
"pkg/F1-bun",
Severity::Warning,
"bun add without --registry — may pull from unexpected source",
"Specify --registry explicitly",
file,
line_num,
line,
);
}
if RE_PIP_INSTALL.is_match(line) && !RE_HAS_INDEX_URL.is_match(line) {
emit(
&mut findings,
"pkg/F1-pip",
Severity::Warning,
"pip install without --index-url — may pull from unexpected source",
"Specify --index-url explicitly: pip install --index-url https://pypi.org/simple/",
file,
line_num,
line,
);
}
if RE_YARN_ADD.is_match(line) && !RE_HAS_REGISTRY.is_match(line) {
emit(
&mut findings,
"pkg/F1-yarn",
Severity::Warning,
"yarn add without --registry — may pull from unexpected source",
"Specify --registry explicitly: yarn add --registry https://registry.npmjs.org <pkg>",
file,
line_num,
line,
);
}
if RE_PNPM_ADD.is_match(line) && !RE_HAS_REGISTRY.is_match(line) {
emit(
&mut findings,
"pkg/F1-pnpm",
Severity::Warning,
"pnpm add/install without --registry — may pull from unexpected source",
"Specify --registry explicitly: pnpm add --registry https://registry.npmjs.org <pkg>",
file,
line_num,
line,
);
}
if RE_LATEST.is_match(line) {
emit(
&mut findings,
"pkg/F2-unpinned",
Severity::Warning,
"@latest install — unpinned, supply chain risk on future runs",
"Pin to an exact version: @1.2.3",
file,
line_num,
line,
);
}
if let Some(caps) = RE_REGISTRY_URL.captures(line) {
if let Some(url) = caps.get(1) {
let url_str = url.as_str();
let host = RE_REGISTRY_HOST
.captures(url_str)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_lowercase())
.unwrap_or_default();
let is_allowed = !host.is_empty()
&& allowed_registries.iter().any(|entry| {
host == *entry
|| host
.strip_suffix(entry)
.is_some_and(|prefix| prefix.ends_with('.'))
});
if !is_allowed {
emit(
&mut findings,
"pkg/F3-registry",
Severity::Warning,
&format!("Registry URL not in allowlist: {}", url_str),
"Add registry to oxidized-agentic-audit.toml [allowlist.registries] or use an approved registry",
file,
line_num,
line,
);
}
}
}
}
}
ScanResult {
scanner_name: "package_install".to_string(),
findings,
files_scanned: files.len(),
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
}
}
}
pub fn rules() -> Vec<RuleInfo> {
vec![
RuleInfo {
id: "pkg/F1-npm",
severity: "warning",
scanner: "package_install",
message: "npm install without --registry — may pull from unexpected source",
remediation: "Specify --registry explicitly: npm install --registry https://registry.npmjs.org",
},
RuleInfo {
id: "pkg/F1-bun",
severity: "warning",
scanner: "package_install",
message: "bun add without --registry — may pull from unexpected source",
remediation: "Specify --registry explicitly",
},
RuleInfo {
id: "pkg/F1-pip",
severity: "warning",
scanner: "package_install",
message: "pip install without --index-url — may pull from unexpected source",
remediation: "Specify --index-url explicitly: pip install --index-url https://pypi.org/simple/",
},
RuleInfo {
id: "pkg/F1-yarn",
severity: "warning",
scanner: "package_install",
message: "yarn add without --registry — may pull from unexpected source",
remediation: "Specify --registry explicitly: yarn add --registry https://registry.npmjs.org <pkg>",
},
RuleInfo {
id: "pkg/F1-pnpm",
severity: "warning",
scanner: "package_install",
message: "pnpm add/install without --registry — may pull from unexpected source",
remediation: "Specify --registry explicitly: pnpm add --registry https://registry.npmjs.org <pkg>",
},
RuleInfo {
id: "pkg/F2-unpinned",
severity: "warning",
scanner: "package_install",
message: "@latest install — unpinned, supply chain risk on future runs",
remediation: "Pin to an exact version: @1.2.3",
},
RuleInfo {
id: "pkg/F3-registry",
severity: "warning",
scanner: "package_install",
message: "Registry URL not in allowlist",
remediation: "Add registry to oxidized-agentic-audit.toml [allowlist.registries] or use an approved registry",
},
]
}