feat(controls): B1 — custom semgrep detectors for 4 CRA controls
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
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0ef2cd1b23
commit
563d8afb2a
@@ -1,4 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use compliance_core::models::{Finding, ScanType, Severity};
|
||||
use compliance_core::traits::{ScanOutput, Scanner};
|
||||
@@ -6,6 +6,30 @@ 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 {
|
||||
@@ -19,30 +43,26 @@ impl Scanner for SemgrepScanner {
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(600),
|
||||
tokio::process::Command::new("semgrep")
|
||||
.args([
|
||||
"--config=auto",
|
||||
"--json",
|
||||
"--quiet",
|
||||
"--max-memory",
|
||||
"500",
|
||||
"--jobs",
|
||||
"1",
|
||||
])
|
||||
.arg(repo_path)
|
||||
.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),
|
||||
})?;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user