CI / Check (push) Skipped
CI / Check (pull_request) Successful in 5m50s
CI / Detect Changes (pull_request) Skipped
CI / Deploy Agent (pull_request) Skipped
CI / Deploy Dashboard (pull_request) Skipped
CI / Deploy Docs (pull_request) Skipped
CI / Deploy MCP (pull_request) Skipped
Covers the pattern-expressible slice of the needs_tooling bucket that no off-the-shelf ruleset digs out, keeping detection deterministic (LLM only FP-filters downstream, never detects): - cra-ai-1 Secure-by-Default: flask/django debug, TLS verify=False, CORS '*' - cra-ai-7 Strong auth: password/secret hashed with md5/sha1 (metavar-gated) - cra-ai-10 Session mgmt: Secure/HttpOnly = false cookies (py + express) - cra-ai-14 Data at rest: ECB/DES/3DES + node createCipher Rules ship in the binary (include_str!) and stage to a temp file at scan time, added as a second --config alongside --config=auto (no deploy/volume change). Wiring: control-map gains controls_for_finding (match by CWE and/or rule id); custom controls bind by rule id with cwe:[] so a broad CWE can't over-attribute and let the judge FP-drop a genuine finding. rule_id_matches tolerates semgrep's path prefix on local check_ids. Triage now maps by rule id too (a custom finding carries no LUT CWE). LUT: cra-ai-1,7,10,14 needs_tooling->covered (covered 9->13). Validated: all 9 rules fire on positive fixtures, 0 on clean. ControlCheckSpec gains Serialize/Deserialize (unrelated-safe; already used by the index cache). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
288 lines
9.4 KiB
Rust
288 lines
9.4 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use compliance_core::models::{Finding, ScanType, Severity};
|
|
use compliance_core::traits::{ScanOutput, Scanner};
|
|
use compliance_core::CoreError;
|
|
|
|
use crate::pipeline::dedup;
|
|
|
|
/// Custom CRA-control detectors bundled into the binary and staged to a temp file
|
|
/// at scan time so semgrep can `--config` them alongside the auto ruleset. These
|
|
/// cover controls no off-the-shelf rule digs out (secure defaults, weak password
|
|
/// hashing, insecure session cookies, weak data-at-rest ciphers); each rule id is
|
|
/// keyed back to its control by the `control-map` LUT.
|
|
const CRA_RULES: &str = include_str!("../../rules/cra_semgrep.yaml");
|
|
|
|
/// Write the bundled CRA rules to a stable temp path (atomic: unique tmp +
|
|
/// rename). Returns `None` on failure — the scan then runs with auto rules only.
|
|
async fn stage_cra_rules() -> Option<PathBuf> {
|
|
let dir = std::env::temp_dir();
|
|
let path = dir.join("compliance-cra-semgrep.yaml");
|
|
let tmp = dir.join(format!("compliance-cra-semgrep.{}.tmp", std::process::id()));
|
|
if let Err(e) = tokio::fs::write(&tmp, CRA_RULES).await {
|
|
tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only");
|
|
return None;
|
|
}
|
|
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
|
|
tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only");
|
|
return None;
|
|
}
|
|
Some(path)
|
|
}
|
|
|
|
pub struct SemgrepScanner;
|
|
|
|
impl Scanner for SemgrepScanner {
|
|
fn name(&self) -> &str {
|
|
"semgrep"
|
|
}
|
|
|
|
fn scan_type(&self) -> ScanType {
|
|
ScanType::Sast
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
|
let cra_rules = stage_cra_rules().await;
|
|
let mut command = tokio::process::Command::new("semgrep");
|
|
command.arg("--config=auto");
|
|
if let Some(path) = &cra_rules {
|
|
command.arg(format!("--config={}", path.display()));
|
|
}
|
|
command
|
|
.args(["--json", "--quiet", "--max-memory", "500", "--jobs", "1"])
|
|
.arg(repo_path);
|
|
|
|
let output = tokio::time::timeout(std::time::Duration::from_secs(600), command.output())
|
|
.await
|
|
.map_err(|_| CoreError::Scanner {
|
|
scanner: "semgrep".to_string(),
|
|
source: "timed out after 10 minutes".into(),
|
|
})?
|
|
.map_err(|e| CoreError::Scanner {
|
|
scanner: "semgrep".to_string(),
|
|
source: Box::new(e),
|
|
})?;
|
|
|
|
if !output.status.success() && output.stdout.is_empty() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
tracing::warn!("Semgrep exited with {}: {stderr}", output.status);
|
|
return Ok(ScanOutput::default());
|
|
}
|
|
|
|
let result: SemgrepOutput = serde_json::from_slice(&output.stdout)?;
|
|
let findings = result
|
|
.results
|
|
.into_iter()
|
|
.map(|r| {
|
|
let severity = match r.extra.severity.as_str() {
|
|
"ERROR" => Severity::High,
|
|
"WARNING" => Severity::Medium,
|
|
"INFO" => Severity::Low,
|
|
_ => Severity::Info,
|
|
};
|
|
|
|
let fingerprint = dedup::compute_fingerprint(&[
|
|
repo_id,
|
|
&r.check_id,
|
|
&r.path,
|
|
&r.start.line.to_string(),
|
|
]);
|
|
|
|
let mut finding = Finding::new(
|
|
repo_id.to_string(),
|
|
fingerprint,
|
|
"semgrep".to_string(),
|
|
ScanType::Sast,
|
|
r.extra.message.clone(),
|
|
r.extra.message,
|
|
severity,
|
|
);
|
|
finding.rule_id = Some(r.check_id);
|
|
finding.file_path = Some(r.path);
|
|
finding.line_number = Some(r.start.line);
|
|
finding.code_snippet = Some(r.extra.lines);
|
|
finding.cwe = r.extra.metadata.as_ref().and_then(extract_cwe);
|
|
finding
|
|
})
|
|
.collect();
|
|
|
|
Ok(ScanOutput {
|
|
findings,
|
|
sbom_entries: Vec::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SemgrepOutput {
|
|
results: Vec<SemgrepResult>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SemgrepResult {
|
|
check_id: String,
|
|
path: String,
|
|
start: SemgrepPosition,
|
|
extra: SemgrepExtra,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SemgrepPosition {
|
|
line: u32,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SemgrepExtra {
|
|
message: String,
|
|
severity: String,
|
|
lines: String,
|
|
#[serde(default)]
|
|
metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// semgrep emits `metadata.cwe` as a list of strings like
|
|
/// `"CWE-798: Use of Hard-coded Credentials"` (occasionally a bare string). Take
|
|
/// the first entry and normalise it to just the `CWE-NNN` id.
|
|
fn extract_cwe(metadata: &serde_json::Value) -> Option<String> {
|
|
let raw = metadata.get("cwe")?;
|
|
let text = match raw {
|
|
serde_json::Value::Array(items) => items.first()?.as_str()?,
|
|
serde_json::Value::String(s) => s.as_str(),
|
|
_ => return None,
|
|
};
|
|
let id = text.split(':').next().unwrap_or(text).trim();
|
|
(!id.is_empty()).then(|| id.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn extract_cwe_handles_list_and_normalises() {
|
|
let md = serde_json::json!({"cwe": ["CWE-798: Use of Hard-coded Credentials"]});
|
|
assert_eq!(extract_cwe(&md).as_deref(), Some("CWE-798"));
|
|
let bare = serde_json::json!({"cwe": "CWE-89"});
|
|
assert_eq!(extract_cwe(&bare).as_deref(), Some("CWE-89"));
|
|
let none = serde_json::json!({"severity": "ERROR"});
|
|
assert_eq!(extract_cwe(&none), None);
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_semgrep_output() {
|
|
let json = r#"{
|
|
"results": [
|
|
{
|
|
"check_id": "python.lang.security.audit.exec-detected",
|
|
"path": "src/main.py",
|
|
"start": {"line": 15},
|
|
"extra": {
|
|
"message": "Detected use of exec()",
|
|
"severity": "ERROR",
|
|
"lines": "exec(user_input)",
|
|
"metadata": {"cwe": "CWE-78"}
|
|
}
|
|
}
|
|
]
|
|
}"#;
|
|
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
|
assert_eq!(output.results.len(), 1);
|
|
|
|
let r = &output.results[0];
|
|
assert_eq!(r.check_id, "python.lang.security.audit.exec-detected");
|
|
assert_eq!(r.path, "src/main.py");
|
|
assert_eq!(r.start.line, 15);
|
|
assert_eq!(r.extra.message, "Detected use of exec()");
|
|
assert_eq!(r.extra.severity, "ERROR");
|
|
assert_eq!(r.extra.lines, "exec(user_input)");
|
|
assert_eq!(
|
|
r.extra
|
|
.metadata
|
|
.as_ref()
|
|
.unwrap()
|
|
.get("cwe")
|
|
.unwrap()
|
|
.as_str(),
|
|
Some("CWE-78")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_semgrep_empty_results() {
|
|
let json = r#"{"results": []}"#;
|
|
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
|
assert!(output.results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_semgrep_no_metadata() {
|
|
let json = r#"{
|
|
"results": [
|
|
{
|
|
"check_id": "rule-1",
|
|
"path": "app.py",
|
|
"start": {"line": 1},
|
|
"extra": {
|
|
"message": "found something",
|
|
"severity": "WARNING",
|
|
"lines": "import os"
|
|
}
|
|
}
|
|
]
|
|
}"#;
|
|
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
|
assert!(output.results[0].extra.metadata.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn semgrep_severity_mapping() {
|
|
let cases = vec![
|
|
("ERROR", "High"),
|
|
("WARNING", "Medium"),
|
|
("INFO", "Low"),
|
|
("UNKNOWN", "Info"),
|
|
];
|
|
for (input, expected) in cases {
|
|
let result = match input {
|
|
"ERROR" => "High",
|
|
"WARNING" => "Medium",
|
|
"INFO" => "Low",
|
|
_ => "Info",
|
|
};
|
|
assert_eq!(result, expected, "Severity for '{input}'");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_semgrep_multiple_results() {
|
|
let json = r#"{
|
|
"results": [
|
|
{
|
|
"check_id": "rule-a",
|
|
"path": "a.py",
|
|
"start": {"line": 1},
|
|
"extra": {
|
|
"message": "msg a",
|
|
"severity": "ERROR",
|
|
"lines": "line a"
|
|
}
|
|
},
|
|
{
|
|
"check_id": "rule-b",
|
|
"path": "b.py",
|
|
"start": {"line": 99},
|
|
"extra": {
|
|
"message": "msg b",
|
|
"severity": "INFO",
|
|
"lines": "line b"
|
|
}
|
|
}
|
|
]
|
|
}"#;
|
|
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
|
assert_eq!(output.results.len(), 2);
|
|
assert_eq!(output.results[1].start.line, 99);
|
|
}
|
|
}
|