Skip to content

Commit 4374a37

Browse files
datasecurity: add postgres backend engine and report sub task scan errors in payload
1 parent 2bb8595 commit 4374a37

9 files changed

Lines changed: 633 additions & 47 deletions

File tree

pkg/collector/sharedlibrary/rustchecks/Cargo.lock

Lines changed: 389 additions & 23 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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ name = "datasecurity"
33
version = "0.1.0"
44
edition = "2024"
55

6+
[features]
7+
default = ["engine-postgres"]
8+
# Each backend engine is gated behind its own feature so the set of compiled
9+
# engines is explicit. Add new engines as `engine-<name>` here.
10+
engine-postgres = ["dep:postgres"]
11+
612
[dependencies]
713
anyhow = "1.0.100"
814
core = { path = "../../core" }
915
dd_sds = { package = "dd-sensitive-data-scanner", version = "=0.1.0-20260715-fcd8f9716b61", default-features = false, features = ["dd-sds"] }
1016
libc = "0.2.182"
17+
postgres = { version = "0.19", optional = true }
1118
serde = { version = "1", features = ["derive"] }
1219
serde_json = "1"
1320

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! Backend scan engines: run a sub task's query and return its column data.
2+
3+
use anyhow::Result;
4+
use serde_json::Value;
5+
6+
use crate::config::SubTask;
7+
8+
#[cfg(feature = "engine-postgres")]
9+
mod postgres;
10+
11+
/// A data-source engine that runs a sub task's query and returns the result as
12+
/// a `{ column: [values] }` map ready for the scanner.
13+
pub trait ScanEngine: Sync {
14+
/// Engine name, matched against the sub task platform.
15+
fn name(&self) -> &'static str;
16+
/// Runs the sub task's query and returns its columns.
17+
fn run_scan(&self, sub_task: &SubTask) -> Result<Value>;
18+
}
19+
20+
/// Compiled engines. Add a new engine here behind its `engine-*` feature.
21+
fn engines() -> &'static [&'static dyn ScanEngine] {
22+
&[
23+
#[cfg(feature = "engine-postgres")]
24+
&postgres::ENGINE,
25+
]
26+
}
27+
28+
fn engine_for(platform: &str) -> Result<&'static dyn ScanEngine> {
29+
engines()
30+
.iter()
31+
.copied()
32+
.find(|engine| engine.name() == platform)
33+
.ok_or_else(|| {
34+
let available = engines()
35+
.iter()
36+
.map(|engine| engine.name())
37+
.collect::<Vec<_>>()
38+
.join(", ");
39+
anyhow::anyhow!("unsupported platform {platform:?} (compiled engines: [{available}])")
40+
})
41+
}
42+
43+
/// Runs the sub task on the engine selected by its platform.
44+
pub fn execute_scan(sub_task: &SubTask) -> Result<Value> {
45+
engine_for(&sub_task.platform)?.run_scan(sub_task)
46+
}
47+
48+
#[cfg(all(test, feature = "engine-postgres"))]
49+
mod tests {
50+
use super::engine_for;
51+
52+
#[test]
53+
fn resolves_postgres_engine() {
54+
assert_eq!(engine_for("postgres").unwrap().name(), "postgres");
55+
assert!(engine_for("mysql").is_err());
56+
}
57+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//! Postgres scan engine.
2+
3+
use std::time::Duration;
4+
5+
use anyhow::{Context, Result};
6+
use postgres::config::SslMode;
7+
use postgres::{Config, NoTls, Row};
8+
use serde_json::{Map, Value};
9+
10+
use crate::backend::ScanEngine;
11+
use crate::config::SubTask;
12+
13+
pub struct PostgresEngine;
14+
pub const ENGINE: PostgresEngine = PostgresEngine;
15+
16+
impl ScanEngine for PostgresEngine {
17+
fn name(&self) -> &'static str {
18+
"postgres"
19+
}
20+
21+
fn run_scan(&self, sub_task: &SubTask) -> Result<Value> {
22+
let conn = &sub_task.connection;
23+
let timeout = sub_task.timeout_seconds;
24+
println!(
25+
"datasecurity: connecting to postgres host={} port={} dbname={} user={} timeout={}s",
26+
conn.host, conn.port, conn.dbname, conn.username, timeout
27+
);
28+
29+
let mut config = Config::new();
30+
config
31+
.host(&conn.host)
32+
.port(conn.port)
33+
.dbname(&conn.dbname)
34+
.user(&conn.username)
35+
.password(&conn.password)
36+
.ssl_mode(SslMode::Disable)
37+
// `0` means no statement timeout in postgres.
38+
.options(&format!("-c statement_timeout={}", timeout * 1000));
39+
if timeout > 0 {
40+
config.connect_timeout(Duration::from_secs(timeout));
41+
}
42+
43+
let mut client = config.connect(NoTls).context("connecting to postgres")?;
44+
45+
let rows = client
46+
.query(sub_task.query.as_str(), &[])
47+
.context("running postgres query")?;
48+
49+
Ok(rows_to_columns(&rows))
50+
}
51+
}
52+
53+
/// Builds a `{ column: [string values] }` map from the query rows.
54+
fn rows_to_columns(rows: &[Row]) -> Value {
55+
let Some(first) = rows.first() else {
56+
return Value::Object(Map::new());
57+
};
58+
59+
let names: Vec<String> = first.columns().iter().map(|c| c.name().to_string()).collect();
60+
let mut columns: Map<String, Value> = names
61+
.iter()
62+
.map(|name| (name.clone(), Value::Array(Vec::new())))
63+
.collect();
64+
65+
for row in rows {
66+
for (i, name) in names.iter().enumerate() {
67+
if let Some(Value::Array(values)) = columns.get_mut(name) {
68+
values.push(cell_to_value(row, i));
69+
}
70+
}
71+
}
72+
73+
Value::Object(columns)
74+
}
75+
76+
/// Renders a postgres cell as a string value (or null), so the scanner sees a
77+
/// uniform `{ column: [string values] }` shape.
78+
fn cell_to_value(row: &Row, index: usize) -> Value {
79+
fn string(v: Option<impl ToString>) -> Value {
80+
v.map(|n| Value::String(n.to_string())).unwrap_or(Value::Null)
81+
}
82+
83+
if let Ok(v) = row.try_get::<_, Option<String>>(index) {
84+
return string(v);
85+
}
86+
if let Ok(v) = row.try_get::<_, Option<i64>>(index) {
87+
return string(v);
88+
}
89+
if let Ok(v) = row.try_get::<_, Option<i32>>(index) {
90+
return string(v);
91+
}
92+
if let Ok(v) = row.try_get::<_, Option<i16>>(index) {
93+
return string(v);
94+
}
95+
if let Ok(v) = row.try_get::<_, Option<f64>>(index) {
96+
return string(v);
97+
}
98+
if let Ok(v) = row.try_get::<_, Option<bool>>(index) {
99+
return string(v);
100+
}
101+
Value::Null
102+
}

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

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use anyhow::{Context, Result};
22
use core::*;
3-
use serde_json::Value;
43

4+
use crate::backend;
55
use crate::config::{CheckConfig, SubTask};
6-
use crate::payload::ScanEventPayload;
6+
use crate::payload::{Match, ScanEventPayload, ScanStatus};
77
use crate::scanning::Scanner;
88

99
/// Check implementation (scaffolding).
@@ -33,25 +33,35 @@ fn run_sub_task(
3333
sub_task: &SubTask,
3434
) -> Result<()> {
3535
println!(
36-
"datasecurity: running sub task (sub_task_id={})",
37-
sub_task.sub_task_id
36+
"datasecurity: running sub task (sub_task_id={}, platform={})",
37+
sub_task.sub_task_id, sub_task.platform
3838
);
3939

40-
// TODO(DSEC-139): fetch the rows from postgres.
41-
let data = fetch_data(sub_task);
42-
let matches = scanner.scan(&data).context("scanning sub task data")?;
40+
// A sub task failure is reported inside the payload (status=ERROR) rather
41+
// than aborting the check, so every sub task produces exactly one event.
42+
let (status, failure_reason, matches) = match run_scan(scanner, sub_task) {
43+
Ok(matches) => {
44+
println!("datasecurity: sub task succeeded ({} match(es))", matches.len());
45+
(ScanStatus::Success, String::new(), matches)
46+
}
47+
Err(err) => {
48+
let reason = format!("{err:#}");
49+
eprintln!(
50+
"datasecurity: sub task {} failed: {reason}",
51+
sub_task.sub_task_id
52+
);
53+
(ScanStatus::Error, reason, Vec::new())
54+
}
55+
};
4356

4457
let payload = ScanEventPayload {
4558
task_id: config.task_id.clone(),
4659
sub_task_id: sub_task.sub_task_id.clone(),
60+
status,
61+
failure_reason,
4762
matches,
4863
};
4964

50-
println!(
51-
"datasecurity: built scaffold event payload ({} match(es))",
52-
payload.matches.len()
53-
);
54-
5565
// TODO(DSEC-140): send sdsresult rather than an event
5666
let payload_json =
5767
serde_json::to_string(&payload).context("serializing scan event payload")?;
@@ -71,8 +81,8 @@ fn run_sub_task(
7181
Ok(())
7282
}
7383

74-
/// Mimics a postgres fetch by returning the sub task's dummy response.
75-
// TODO(DSEC-139): replace with a real postgres query.
76-
fn fetch_data(sub_task: &SubTask) -> Value {
77-
sub_task.dummy_response.clone()
84+
/// Fetches the sub task's data and scans it, returning the matches.
85+
fn run_scan(scanner: &Scanner, sub_task: &SubTask) -> Result<Vec<Match>> {
86+
let data = backend::execute_scan(sub_task).context("fetching sub task data")?;
87+
scanner.scan(&data).context("scanning sub task data")
7888
}

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use anyhow::{Context, Result};
22
use core::AgentCheck;
33
use serde::Deserialize;
4-
use serde_json::Value;
54

65
use crate::scanning::ScanningRule;
76

@@ -36,12 +35,43 @@ pub struct CheckConfig {
3635
pub scan_data: Vec<SubTask>,
3736
}
3837

39-
/// A single scan sub task.
40-
#[derive(Debug, Deserialize)]
38+
/// A single scan sub task: a query to run against one data source.
39+
#[derive(Debug, Default, Deserialize)]
4140
pub struct SubTask {
4241
#[serde(default)]
4342
pub sub_task_id: String,
44-
// TODO(DSEC-139): remove dummy response once the postgres backend lands.
43+
/// Data-source platform, used to select the backend engine (e.g. `postgres`).
44+
#[serde(default)]
45+
pub platform: String,
46+
#[serde(default)]
47+
pub connection: Connection,
48+
/// SQL query whose result columns are scanned.
49+
#[serde(default)]
50+
pub query: String,
51+
/// Connect and query timeout in seconds. `0` disables the timeout.
52+
#[serde(default = "default_timeout_seconds")]
53+
pub timeout_seconds: u64,
54+
}
55+
56+
fn default_timeout_seconds() -> u64 {
57+
30
58+
}
59+
60+
/// Database connection parameters for a sub task.
61+
#[derive(Debug, Default, Deserialize)]
62+
pub struct Connection {
63+
#[serde(default)]
64+
pub host: String,
65+
#[serde(default = "default_port")]
66+
pub port: u16,
67+
#[serde(default)]
68+
pub dbname: String,
4569
#[serde(default)]
46-
pub dummy_response: Value,
70+
pub username: String,
71+
#[serde(default)]
72+
pub password: String,
73+
}
74+
75+
fn default_port() -> u16 {
76+
5432
4777
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use core::generate_ffi;
22

3+
mod backend;
34
mod check;
45
use check::check;
56

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
use serde::Serialize;
22

3-
/// JSON event payload emitted by the check. Kept close to the shape of the real
4-
/// SDS result so it can be grown into the full payload later.
3+
/// JSON event payload emitted by the check, one per sub task. Kept close to the
4+
/// shape of the real SDS result (see `sds_result.proto`) so it can be grown into
5+
/// the full payload later. On failure, `status` is `ERROR` and `failure_reason`
6+
/// carries the cause; on success it holds the scan `matches`.
57
#[derive(Debug, Serialize)]
68
#[serde(rename_all = "snake_case")]
79
pub struct ScanEventPayload {
810
pub task_id: String,
911
pub sub_task_id: String,
12+
pub status: ScanStatus,
13+
#[serde(skip_serializing_if = "String::is_empty")]
14+
pub failure_reason: String,
1015
pub matches: Vec<Match>,
1116
}
1217

18+
/// Outcome of a sub task scan, mirroring the proto `ScanStatus`.
19+
#[derive(Debug, Clone, Copy, Serialize)]
20+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
21+
pub enum ScanStatus {
22+
Success,
23+
Error,
24+
}
25+
1326
#[derive(Debug, Serialize)]
1427
#[serde(rename_all = "snake_case")]
1528
pub struct Match {

pkg/collector/sharedlibrary/rustchecks/shared_checks_manifest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
checks:
22
- id: datasecurity
33
crate: datasecurity
4-
include_in_build: false
4+
include_in_build: true
55
platforms:
66
- linux
77
- id: example

0 commit comments

Comments
 (0)