Skip to content

Commit 399a339

Browse files
datasecurity: add dd-sds scanning module with config parsing and scan tests
1 parent 0c58656 commit 399a339

8 files changed

Lines changed: 2102 additions & 121 deletions

File tree

pkg/collector/sharedlibrary/rustchecks/Cargo.lock

Lines changed: 1680 additions & 78 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/collector/sharedlibrary/rustchecks/checks/datasecurity/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ edition = "2024"
66
[dependencies]
77
anyhow = "1.0.100"
88
core = { path = "../../core" }
9+
dd_sds = { package = "dd-sensitive-data-scanner", version = "=0.1.0-20260715-fcd8f9716b61", default-features = false, features = ["dd-sds"] }
910
libc = "0.2.182"
1011
serde = { version = "1", features = ["derive"] }
1112
serde_json = "1"
1213

14+
[dev-dependencies]
15+
serde_yaml = "0.9.34"
16+
1317
[lib]
1418
crate-type = ["cdylib"]

pkg/collector/sharedlibrary/rustchecks/checks/datasecurity/src/check.rs

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,43 @@ use core::*;
33
use serde_json::Value;
44

55
use crate::config::{CheckConfig, SubTask};
6-
use crate::payload::{Match, ScanEventPayload};
6+
use crate::payload::ScanEventPayload;
7+
use crate::scanning::Scanner;
78

89
/// Check implementation (scaffolding).
910
pub fn check(check: &AgentCheck) -> Result<()> {
1011
let config = CheckConfig::from_instance(check)?;
1112
println!(
12-
"datasecurity: check started (task_id={}, {} sub task(s))",
13+
"datasecurity: check started (task_id={}, {} rule(s), {} sub task(s))",
1314
config.task_id,
15+
config.scanning_rules.len(),
1416
config.scan_data.len()
1517
);
1618

19+
let scanner = Scanner::new(&config.scanning_rules).context("creating sds scanner")?;
20+
1721
for sub_task in &config.scan_data {
18-
run_sub_task(check, &config, sub_task)?;
22+
run_sub_task(check, &config, &scanner, sub_task)?;
1923
}
2024

2125
println!("datasecurity: check completed");
2226
Ok(())
2327
}
2428

25-
fn run_sub_task(check: &AgentCheck, config: &CheckConfig, sub_task: &SubTask) -> Result<()> {
29+
fn run_sub_task(
30+
check: &AgentCheck,
31+
config: &CheckConfig,
32+
scanner: &Scanner,
33+
sub_task: &SubTask,
34+
) -> Result<()> {
2635
println!(
2736
"datasecurity: running sub task (sub_task_id={})",
2837
sub_task.sub_task_id
2938
);
3039

3140
// TODO(DSEC-139): fetch the rows from postgres.
3241
let data = fetch_data(sub_task);
33-
// TODO(DSEC-138): scan the rows with the SDS scanner.
34-
let matches = scan(&data);
42+
let matches = scanner.scan(&data).context("scanning sub task data")?;
3543

3644
let payload = ScanEventPayload {
3745
task_id: config.task_id.clone(),
@@ -68,40 +76,3 @@ fn run_sub_task(check: &AgentCheck, config: &CheckConfig, sub_task: &SubTask) ->
6876
fn fetch_data(sub_task: &SubTask) -> Value {
6977
sub_task.dummy_response.clone()
7078
}
71-
72-
/// Placeholder scan over the returned columns.
73-
// TODO(DSEC-138): replace with the SDS scanner.
74-
fn scan(scan_result: &Value) -> Vec<Match> {
75-
let mut matches = Vec::new();
76-
if let Some(emails) = scan_result.get("email").and_then(Value::as_array) {
77-
let count = emails
78-
.iter()
79-
.filter(|value| value.as_str().is_some_and(|email| email.contains('@')))
80-
.count() as i64;
81-
if count > 0 {
82-
matches.push(Match {
83-
rule_id: "email-scanner".to_string(),
84-
column_name: "email".to_string(),
85-
count_matched_rows: count,
86-
});
87-
}
88-
}
89-
matches
90-
}
91-
92-
#[cfg(test)]
93-
mod tests {
94-
use super::*;
95-
use serde_json::json;
96-
97-
#[test]
98-
fn scan_dummy_response_returns_email_matches() {
99-
let scan_result = json!({
100-
"email": ["alice@example.com", "bob@test.com", "charlie@corp.com"],
101-
});
102-
let matches = scan(&scan_result);
103-
assert_eq!(matches.len(), 1);
104-
assert_eq!(matches[0].column_name, "email");
105-
assert_eq!(matches[0].count_matched_rows, 3);
106-
}
107-
}

pkg/collector/sharedlibrary/rustchecks/checks/datasecurity/src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@ use core::AgentCheck;
33
use serde::Deserialize;
44
use serde_json::Value;
55

6+
use crate::scanning::ScanningRule;
7+
68
impl CheckConfig {
79
/// Reads the check instance config into a `CheckConfig`.
810
pub fn from_instance(check: &AgentCheck) -> Result<Self> {
911
Ok(Self {
1012
task_id: check.instance.get("task_id").unwrap_or_default(),
13+
// `scanning_rules` is common to every sub task; `scan_data` is the
14+
// list of sub tasks to run against it.
15+
scanning_rules: check
16+
.instance
17+
.get("scanning_rules")
18+
.context("reading scanning_rules from instance config")?,
1119
scan_data: check
1220
.instance
1321
.get("scan_data")
@@ -26,6 +34,8 @@ pub struct CheckConfig {
2634
#[serde(default)]
2735
pub task_id: String,
2836
#[serde(default)]
37+
pub scanning_rules: Vec<ScanningRule>,
38+
#[serde(default)]
2939
pub scan_data: Vec<SubTask>,
3040
}
3141

pkg/collector/sharedlibrary/rustchecks/checks/datasecurity/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use check::check;
55

66
mod config;
77
mod payload;
8+
mod scanning;
89
mod version;
910
use version::VERSION;
1011

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//! Self-contained SDS scanning: rule config parsing (`rule`) plus building and
2+
//! running the dd-sds scanner over column-oriented query results. Everything
3+
//! scanning-related lives in this module so the rest of the check stays lean.
4+
5+
use std::collections::{HashMap, HashSet};
6+
use std::sync::Arc;
7+
8+
use anyhow::{Context, Result};
9+
use dd_sds::{RootRuleConfig, RuleConfig, RuleMatch, Scanner as SdsScanner, ScannerBuilder};
10+
use serde_json::{Map, Value};
11+
12+
use crate::payload::Match;
13+
14+
mod rule;
15+
pub use rule::ScanningRule;
16+
17+
#[cfg(test)]
18+
mod tests;
19+
20+
/// A built dd-sds scanner plus the rule ids in builder order. dd-sds reports
21+
/// matches by rule index, so we keep the ids to map matches back to our rules.
22+
pub struct Scanner {
23+
scanner: SdsScanner,
24+
rule_ids: Vec<String>,
25+
}
26+
27+
impl Scanner {
28+
/// Builds a scanner from the check's scanning rules. The full dd-sds rule
29+
/// surface (pattern, proximity keywords, suppressions, precedence, secondary
30+
/// validators such as Luhn/JWT, ...) is supported because each rule carries
31+
/// the flattened `RootRuleConfig` verbatim.
32+
pub fn new(rules: &[ScanningRule]) -> Result<Self> {
33+
let mut rule_ids = Vec::with_capacity(rules.len());
34+
let compiled: Vec<RootRuleConfig<Arc<dyn RuleConfig>>> = rules
35+
.iter()
36+
.map(|rule| {
37+
rule_ids.push(rule.id.clone());
38+
rule.config.clone().into_dyn()
39+
})
40+
.collect();
41+
42+
let scanner = ScannerBuilder::new(&compiled)
43+
.build()
44+
.context("building sds scanner")?;
45+
Ok(Self { scanner, rule_ids })
46+
}
47+
48+
/// Scans a column-oriented result (`{ column: [values...] }`) and returns
49+
/// one `Match` per (column, rule) pair with the count of matched rows.
50+
pub fn scan(&self, data: &Value) -> Result<Vec<Match>> {
51+
let mut event = Map::new();
52+
if let Some(columns) = data.as_object() {
53+
for (name, values) in columns {
54+
let str_values: Vec<Value> = values
55+
.as_array()
56+
.map(|vs| {
57+
vs.iter()
58+
.filter_map(|v| v.as_str().map(|s| Value::String(s.to_string())))
59+
.collect()
60+
})
61+
.unwrap_or_default();
62+
event.insert(name.clone(), Value::Array(str_values));
63+
}
64+
}
65+
66+
let mut event_value = Value::Object(event);
67+
let hits = self
68+
.scanner
69+
.scan(&mut event_value)
70+
.context("scanning query result")?;
71+
72+
Ok(aggregate_matches(&self.rule_ids, &hits))
73+
}
74+
}
75+
76+
/// Groups raw dd-sds hits into `(column, rule)` pairs, counting distinct matched
77+
/// rows (paths) per pair. Output is sorted so emitted events are deterministic.
78+
fn aggregate_matches(rule_ids: &[String], hits: &[RuleMatch]) -> Vec<Match> {
79+
let mut rows: HashMap<(String, String), HashSet<String>> = HashMap::new();
80+
for hit in hits {
81+
let path = hit.path.to_string();
82+
let column = column_name_from_path(&path);
83+
let rule_id = rule_ids.get(hit.rule_index).cloned().unwrap_or_default();
84+
rows.entry((column, rule_id)).or_default().insert(path);
85+
}
86+
87+
let mut matches: Vec<Match> = rows
88+
.into_iter()
89+
.map(|((column, rule_id), paths)| Match {
90+
rule_id,
91+
column_name: column,
92+
count_matched_rows: paths.len() as i64,
93+
})
94+
.collect();
95+
96+
matches.sort_by(|a, b| {
97+
a.column_name
98+
.cmp(&b.column_name)
99+
.then_with(|| a.rule_id.cmp(&b.rule_id))
100+
});
101+
matches
102+
}
103+
104+
/// `foo[0]` / `foo.bar` -> `foo`.
105+
fn column_name_from_path(path: &str) -> String {
106+
match path.find(['[', '.']) {
107+
Some(i) => path[..i].to_string(),
108+
None => path.to_string(),
109+
}
110+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use dd_sds::{RegexRuleConfig, RootRuleConfig};
2+
use serde::Deserialize;
3+
4+
/// A scanning rule, shared by every sub task. `id` (used to map matches back to
5+
/// the rule) and `name` are ours; everything else is the flattened dd-sds
6+
/// `RootRuleConfig<RegexRuleConfig>`, so the full rule schema — pattern,
7+
/// proximity keywords, suppressions, precedence and secondary validators (Luhn,
8+
/// JWT, ...) — comes straight from dd-sds with no duplication.
9+
#[derive(Debug, Deserialize)]
10+
pub struct ScanningRule {
11+
pub id: String,
12+
#[serde(default)]
13+
#[allow(dead_code)]
14+
pub name: String,
15+
#[serde(flatten)]
16+
pub config: RootRuleConfig<RegexRuleConfig>,
17+
}

0 commit comments

Comments
 (0)