251 lines
8.9 KiB
Rust
251 lines
8.9 KiB
Rust
//! `control-map` — the deterministic control → scan lookup table (LUT).
|
|
//!
|
|
//! The "transcribing" layer: it maps each compliance control to the static-scan
|
|
//! step(s) that check it, or marks it as needing custom tooling, or as not
|
|
//! code-checkable at all. The map is **authored and human-reviewed** — no LLM
|
|
//! decides coverage. The LLM only enters later, downstream, to triage/ground the
|
|
//! *tool's* findings (that lives in the agent, not here).
|
|
//!
|
|
//! This crate is intentionally tiny and standalone: types + an embedded JSON LUT
|
|
//! + query helpers.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Coverage bucket for a control under static (SAST-family) scanning.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Coverage {
|
|
/// An existing tool's scan surfaces findings for this control.
|
|
Covered,
|
|
/// Code-checkable, but no existing tool digs it out — we must write tooling.
|
|
NeedsTooling,
|
|
/// Process / document control — out of static-scan scope.
|
|
NotCodeCheckable,
|
|
}
|
|
|
|
/// One tool binding: a scan step that (at least partially) checks a control.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ScanBinding {
|
|
/// Tool name, e.g. `"semgrep"`, `"gitleaks"`, `"syft"`, `"osv"`.
|
|
pub tool: String,
|
|
/// Scan family, e.g. `"sast"`, `"secret_detection"`, `"sbom"`, `"cve"`.
|
|
pub scan_type: String,
|
|
/// CWEs whose findings map to this control (used to attach findings back).
|
|
#[serde(default)]
|
|
pub cwe: Vec<String>,
|
|
/// Optional specific rule ids this control keys on.
|
|
#[serde(default)]
|
|
pub rules: Vec<String>,
|
|
}
|
|
|
|
/// One control's entry in the LUT.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ControlEntry {
|
|
/// Control id, e.g. `"cra-ai-8"`.
|
|
pub control: String,
|
|
/// Human-readable title (for the reviewable view).
|
|
#[serde(default)]
|
|
pub title: String,
|
|
/// Coverage bucket.
|
|
pub status: Coverage,
|
|
/// Tool bindings (empty unless `status == Covered`).
|
|
#[serde(default)]
|
|
pub scans: Vec<ScanBinding>,
|
|
/// Reviewer note — why it needs tooling / isn't code-checkable.
|
|
#[serde(default)]
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
/// The control → scan lookup table for one framework.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ControlMap {
|
|
pub version: String,
|
|
pub framework: String,
|
|
pub controls: Vec<ControlEntry>,
|
|
}
|
|
|
|
const CRA_MAP_JSON: &str = include_str!("../data/cra_control_map.json");
|
|
|
|
/// Whether an authored rule id `bound` matches a scanner's emitted rule id
|
|
/// `actual`. semgrep prefixes local-rule check_ids with a path
|
|
/// (`tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled`), so match the final
|
|
/// id segment rather than requiring exact equality.
|
|
fn rule_id_matches(bound: &str, actual: &str) -> bool {
|
|
actual == bound || actual.ends_with(&format!(".{bound}"))
|
|
}
|
|
|
|
impl ControlMap {
|
|
/// Load the built-in CRA control map (the embedded, authored LUT).
|
|
pub fn cra() -> Result<Self, MapError> {
|
|
Ok(serde_json::from_str(CRA_MAP_JSON)?)
|
|
}
|
|
|
|
/// The coverage entry for a control id, if present.
|
|
pub fn coverage(&self, control_id: &str) -> Option<&ControlEntry> {
|
|
self.controls.iter().find(|c| c.control == control_id)
|
|
}
|
|
|
|
/// Controls whose bindings include the given `tool` + `cwe` — used to attach a
|
|
/// raw tool finding back to the control(s) it's evidence for.
|
|
pub fn controls_for(&self, tool: &str, cwe: &str) -> Vec<&ControlEntry> {
|
|
self.controls_for_finding(tool, Some(cwe), None)
|
|
}
|
|
|
|
/// Controls a tool finding is evidence for, matched by CWE and/or the specific
|
|
/// rule id that fired. Off-the-shelf findings bind by CWE; our custom detectors
|
|
/// bind by rule id (precise — a broad CWE would over-attribute and then the
|
|
/// grounded judge could drop a genuine finding as a control false positive).
|
|
pub fn controls_for_finding(
|
|
&self,
|
|
tool: &str,
|
|
cwe: Option<&str>,
|
|
rule_id: Option<&str>,
|
|
) -> Vec<&ControlEntry> {
|
|
self.controls
|
|
.iter()
|
|
.filter(|c| {
|
|
c.scans.iter().any(|s| {
|
|
s.tool == tool
|
|
&& (cwe.is_some_and(|w| s.cwe.iter().any(|x| x == w))
|
|
|| rule_id
|
|
.is_some_and(|r| s.rules.iter().any(|b| rule_id_matches(b, r))))
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Count of controls in each coverage bucket.
|
|
pub fn summary(&self) -> CoverageSummary {
|
|
let mut s = CoverageSummary::default();
|
|
for c in &self.controls {
|
|
match c.status {
|
|
Coverage::Covered => s.covered += 1,
|
|
Coverage::NeedsTooling => s.needs_tooling += 1,
|
|
Coverage::NotCodeCheckable => s.not_code_checkable += 1,
|
|
}
|
|
}
|
|
s
|
|
}
|
|
}
|
|
|
|
/// Coverage bucket counts.
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub struct CoverageSummary {
|
|
pub covered: usize,
|
|
pub needs_tooling: usize,
|
|
pub not_code_checkable: usize,
|
|
}
|
|
|
|
impl CoverageSummary {
|
|
pub fn total(&self) -> usize {
|
|
self.covered + self.needs_tooling + self.not_code_checkable
|
|
}
|
|
}
|
|
|
|
/// Errors loading a control map.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum MapError {
|
|
#[error("failed to parse control map: {0}")]
|
|
Parse(#[from] serde_json::Error),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn cra_map_loads_all_40_controls() {
|
|
let map = ControlMap::cra().expect("CRA map should parse");
|
|
assert_eq!(map.framework, "cra");
|
|
assert_eq!(map.controls.len(), 40);
|
|
assert_eq!(map.summary().total(), 40);
|
|
}
|
|
|
|
#[test]
|
|
fn hardcoded_password_control_is_tool_covered() {
|
|
let map = ControlMap::cra().unwrap();
|
|
let c = map.coverage("cra-ai-8").expect("cra-ai-8 present");
|
|
assert_eq!(c.status, Coverage::Covered);
|
|
assert!(c.scans.iter().any(|s| s.tool == "semgrep"));
|
|
assert!(c.scans.iter().any(|s| s.tool == "gitleaks"));
|
|
}
|
|
|
|
#[test]
|
|
fn finding_attaches_back_to_control_via_tool_and_cwe() {
|
|
let map = ControlMap::cra().unwrap();
|
|
let hits = map.controls_for("semgrep", "CWE-798");
|
|
assert!(hits.iter().any(|c| c.control == "cra-ai-8"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_bucket_is_represented() {
|
|
let s = ControlMap::cra().unwrap().summary();
|
|
assert!(s.covered > 0);
|
|
assert!(s.needs_tooling > 0);
|
|
assert!(s.not_code_checkable > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn rule_id_matching_handles_semgrep_path_prefix() {
|
|
let bound = "cra-ai-1-flask-debug-enabled";
|
|
assert!(rule_id_matches(bound, bound)); // exact
|
|
assert!(rule_id_matches(
|
|
bound,
|
|
"tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled"
|
|
)); // semgrep path prefix
|
|
assert!(!rule_id_matches(
|
|
bound,
|
|
"cra-ai-1-flask-debug-enabled-extra"
|
|
)); // not a suffix segment
|
|
assert!(!rule_id_matches(
|
|
bound,
|
|
"python.lang.security.exec-detected"
|
|
)); // unrelated
|
|
}
|
|
|
|
#[test]
|
|
fn custom_rule_finding_attaches_to_control_by_rule_id() {
|
|
let map = ControlMap::cra().unwrap();
|
|
// cra-ai-1 is now tool-covered by custom rules.
|
|
assert_eq!(map.coverage("cra-ai-1").unwrap().status, Coverage::Covered);
|
|
// A prefixed check_id still maps back to cra-ai-1 by rule id.
|
|
let hits =
|
|
map.controls_for_finding("semgrep", None, Some("tmp.x.cra-ai-1-tls-verify-disabled"));
|
|
assert!(hits.iter().any(|c| c.control == "cra-ai-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn coverage_reflects_the_b_track_split() {
|
|
let s = ControlMap::cra().unwrap().summary();
|
|
// 9 already tool-covered + B1's 4 custom-semgrep controls.
|
|
assert_eq!(s.covered, 13);
|
|
// The 8 grounded surface controls stay needs_tooling until live-tuned.
|
|
assert_eq!(s.needs_tooling, 8);
|
|
// B3 marked the 4 pure-architectural controls not code-checkable.
|
|
assert_eq!(s.not_code_checkable, 19);
|
|
}
|
|
|
|
#[test]
|
|
fn architectural_controls_are_not_code_checkable() {
|
|
let map = ControlMap::cra().unwrap();
|
|
for id in ["cra-ai-2", "cra-ai-3", "cra-ai-4", "cra-ai-5"] {
|
|
let c = map.coverage(id).unwrap();
|
|
assert_eq!(c.status, Coverage::NotCodeCheckable, "{id}");
|
|
assert!(c.scans.is_empty(), "{id} should carry no scan bindings");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn custom_rule_controls_do_not_bind_by_broad_cwe() {
|
|
let map = ControlMap::cra().unwrap();
|
|
// cra-ai-1 rules emit CWE-489 in metadata, but the LUT binds by rule id
|
|
// only (cwe: []) — so a stray CWE-489 finding must NOT attach to it.
|
|
assert!(map.controls_for("semgrep", "CWE-489").is_empty());
|
|
// The CWE path for off-the-shelf findings is unchanged.
|
|
assert!(map
|
|
.controls_for("semgrep", "CWE-798")
|
|
.iter()
|
|
.any(|c| c.control == "cra-ai-8"));
|
|
}
|
|
}
|