Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m3s
CI / Security Audit (push) Successful in 1m38s
CI / Tests (push) Successful in 4m44s
CI / Detect Changes (push) Successful in 2s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Failing after 2s
131 lines
4.0 KiB
Rust
131 lines
4.0 KiB
Rust
use std::path::Path;
|
|
|
|
use compliance_core::models::{Finding, ScanType, Severity};
|
|
use compliance_core::traits::{ScanOutput, Scanner};
|
|
use compliance_core::CoreError;
|
|
|
|
use crate::pipeline::dedup;
|
|
|
|
pub struct GitleaksScanner;
|
|
|
|
impl Scanner for GitleaksScanner {
|
|
fn name(&self) -> &str {
|
|
"gitleaks"
|
|
}
|
|
|
|
fn scan_type(&self) -> ScanType {
|
|
ScanType::SecretDetection
|
|
}
|
|
|
|
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
|
let output = tokio::process::Command::new("gitleaks")
|
|
.args([
|
|
"detect",
|
|
"--source",
|
|
".",
|
|
"--report-format",
|
|
"json",
|
|
"--report-path",
|
|
"/dev/stdout",
|
|
"--no-banner",
|
|
"--exit-code",
|
|
"0",
|
|
])
|
|
.current_dir(repo_path)
|
|
.output()
|
|
.await
|
|
.map_err(|e| CoreError::Scanner {
|
|
scanner: "gitleaks".to_string(),
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
if output.stdout.is_empty() {
|
|
return Ok(ScanOutput::default());
|
|
}
|
|
|
|
let results: Vec<GitleaksResult> =
|
|
serde_json::from_slice(&output.stdout).unwrap_or_default();
|
|
|
|
let findings = results
|
|
.into_iter()
|
|
.filter(|r| !is_allowlisted(&r.file))
|
|
.map(|r| {
|
|
let severity = match r.rule_id.as_str() {
|
|
s if s.contains("private-key") => Severity::Critical,
|
|
s if s.contains("token") || s.contains("password") || s.contains("secret") => {
|
|
Severity::High
|
|
}
|
|
s if s.contains("api-key") => Severity::High,
|
|
_ => Severity::Medium,
|
|
};
|
|
|
|
let fingerprint = dedup::compute_fingerprint(&[
|
|
repo_id,
|
|
&r.rule_id,
|
|
&r.file,
|
|
&r.start_line.to_string(),
|
|
]);
|
|
|
|
let title = format!("Secret detected: {}", r.description);
|
|
let description = format!(
|
|
"Potential secret ({}) found in {}:{}. Match: {}",
|
|
r.rule_id,
|
|
r.file,
|
|
r.start_line,
|
|
r.r#match.chars().take(80).collect::<String>(),
|
|
);
|
|
|
|
let mut finding = Finding::new(
|
|
repo_id.to_string(),
|
|
fingerprint,
|
|
"gitleaks".to_string(),
|
|
ScanType::SecretDetection,
|
|
title,
|
|
description,
|
|
severity,
|
|
);
|
|
finding.rule_id = Some(r.rule_id);
|
|
finding.file_path = Some(r.file);
|
|
finding.line_number = Some(r.start_line);
|
|
finding.code_snippet = Some(r.r#match);
|
|
finding
|
|
})
|
|
.collect();
|
|
|
|
Ok(ScanOutput {
|
|
findings,
|
|
sbom_entries: Vec::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Skip files that commonly contain example/placeholder secrets
|
|
fn is_allowlisted(file_path: &str) -> bool {
|
|
let lower = file_path.to_lowercase();
|
|
lower.ends_with(".env.example")
|
|
|| lower.ends_with(".env.sample")
|
|
|| lower.ends_with(".env.template")
|
|
|| lower.contains("/test/")
|
|
|| lower.contains("/tests/")
|
|
|| lower.contains("/fixtures/")
|
|
|| lower.contains("/testdata/")
|
|
|| lower.contains("mock")
|
|
|| lower.ends_with("_test.go")
|
|
|| lower.ends_with(".test.ts")
|
|
|| lower.ends_with(".test.js")
|
|
|| lower.ends_with(".spec.ts")
|
|
|| lower.ends_with(".spec.js")
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct GitleaksResult {
|
|
description: String,
|
|
#[serde(rename = "RuleID")]
|
|
rule_id: String,
|
|
file: String,
|
|
start_line: u32,
|
|
#[serde(rename = "Match")]
|
|
r#match: String,
|
|
}
|