|
| 1 | +use std::time::{SystemTime, UNIX_EPOCH}; |
| 2 | + |
1 | 3 | use anyhow::{Context, Result, anyhow}; |
2 | | -use core::*; |
| 4 | +use shlib_core::*; |
| 5 | +use serde_json::Value; |
3 | 6 |
|
4 | 7 | use crate::backend; |
5 | 8 | use crate::config::{CheckConfig, SubTask}; |
6 | | -use crate::payload::{Match, ScanEventPayload, ScanStatus}; |
| 9 | +use crate::constants::SDS_RESULT_EVENT_TYPE; |
| 10 | +use crate::payload::{Match, ScanStatus}; |
| 11 | +use crate::proto::{self, ScanMetadata, ScanResult, ScanTaskMetadata, SdsResultPayload, Status}; |
7 | 12 | use crate::scanning::Scanner; |
8 | 13 |
|
9 | 14 | /// Check entrypoint. |
@@ -45,56 +50,195 @@ fn run_sub_task( |
45 | 50 | sub_task.sub_task_id, sub_task.entity.platform |
46 | 51 | ); |
47 | 52 |
|
| 53 | + // Time the scan so the payload can carry started_at / ended_at / duration. |
| 54 | + let started_at = SystemTime::now(); |
| 55 | + let scan = run_scan(scanner, sub_task); |
| 56 | + let ended_at = SystemTime::now(); |
| 57 | + |
48 | 58 | // A sub task failure is reported inside the payload (status=ERROR) rather |
49 | 59 | // than aborting the check, so every sub task produces exactly one event. |
50 | | - let (status, failure_reason, matches) = match run_scan(scanner, sub_task) { |
51 | | - Ok(matches) => { |
| 60 | + let (status, failure_reason, matches, scanned_row_count) = match scan { |
| 61 | + Ok(out) => { |
52 | 62 | println!( |
53 | 63 | "datasecurity: sub task succeeded ({} match(es))", |
54 | 64 | matches.len() |
55 | 65 | ); |
56 | | - (ScanStatus::Success, String::new(), matches) |
| 66 | + (ScanStatus::Success, String::new(), out.matches, out.scanned_row_count) |
57 | 67 | } |
58 | 68 | Err(err) => { |
59 | 69 | let reason = format!("{err:#}"); |
60 | 70 | eprintln!( |
61 | 71 | "datasecurity: sub task {} failed: {reason}", |
62 | 72 | sub_task.sub_task_id |
63 | 73 | ); |
64 | | - (ScanStatus::Error, reason, Vec::new()) |
| 74 | + (ScanStatus::Error, reason, Vec::new(), 0) |
65 | 75 | } |
66 | 76 | }; |
67 | 77 |
|
68 | | - let payload = ScanEventPayload { |
69 | | - task_id: config.task_id.clone(), |
70 | | - sub_task_id: sub_task.sub_task_id.clone(), |
| 78 | + // Build the SDS result protobuf: task metadata, timing, postgres location and |
| 79 | + // matches, mirroring the Data Observability crawler payload. |
| 80 | + let payload = build_sds_result( |
| 81 | + config, |
| 82 | + sub_task, |
71 | 83 | status, |
72 | | - failure_reason, |
73 | | - matches, |
74 | | - }; |
| 84 | + &failure_reason, |
| 85 | + &matches, |
| 86 | + scanned_row_count, |
| 87 | + started_at, |
| 88 | + ended_at, |
| 89 | + ); |
| 90 | + |
| 91 | + // Emit the protobuf on the `sds-result` event platform track. |
| 92 | + if config.send_sds_result { |
| 93 | + check.event_platform_event_bytes(&proto::encode(&payload), SDS_RESULT_EVENT_TYPE)?; |
| 94 | + } |
75 | 95 |
|
76 | | - // TODO(DSEC-140): send sdsresult rather than an event |
77 | | - let payload_json = |
78 | | - serde_json::to_string(&payload).context("failed to serialize scan event payload")?; |
79 | | - check.event( |
80 | | - "datasecurity scan result", |
81 | | - &payload_json, |
82 | | - 0, |
83 | | - "normal", |
84 | | - "", |
85 | | - &[], |
86 | | - "info", |
87 | | - "", |
88 | | - "datasecurity", |
89 | | - "", |
90 | | - )?; |
| 96 | + // TODO(DSEC): remove this JSON event once the protobuf is validated end to |
| 97 | + // end — we do not need to send SDS results as JSON. It is serialized from the |
| 98 | + // same protobuf so the two representations cannot drift. |
| 99 | + if config.send_sds_result_json { |
| 100 | + let payload_json = |
| 101 | + proto::to_json(&payload).context("failed to serialize sds result payload to json")?; |
| 102 | + check.event( |
| 103 | + "datasecurity scan result", |
| 104 | + &payload_json, |
| 105 | + 0, |
| 106 | + "normal", |
| 107 | + "", |
| 108 | + &[], |
| 109 | + "info", |
| 110 | + "", |
| 111 | + "datasecurity", |
| 112 | + "", |
| 113 | + )?; |
| 114 | + } |
91 | 115 |
|
92 | 116 | Ok(()) |
93 | 117 | } |
94 | 118 |
|
95 | | -/// Fetches the sub task's data and scans it, returning the matches. |
96 | | -/// TODO(dsec-161): add tests for the scan. |
97 | | -fn run_scan(scanner: &Scanner, sub_task: &SubTask) -> Result<Vec<Match>> { |
| 119 | +/// Builds the `SdsResultPayload` protobuf for one sub task. |
| 120 | +/// |
| 121 | +/// Mirrors the Data Observability crawler payload (`Resource`, `RuleIds`, |
| 122 | +/// `ScanningSource`, `ScanResults`), swapping the snowflake location for a |
| 123 | +/// postgres one and adding the scan-task metadata block. |
| 124 | +#[allow(clippy::too_many_arguments)] |
| 125 | +fn build_sds_result( |
| 126 | + config: &CheckConfig, |
| 127 | + sub_task: &SubTask, |
| 128 | + status: ScanStatus, |
| 129 | + failure_reason: &str, |
| 130 | + matches: &[Match], |
| 131 | + scanned_row_count: i64, |
| 132 | + started_at: SystemTime, |
| 133 | + ended_at: SystemTime, |
| 134 | +) -> SdsResultPayload { |
| 135 | + let entity = &sub_task.entity; |
| 136 | + let duration_ms = ended_at |
| 137 | + .duration_since(started_at) |
| 138 | + .map(|d| d.as_millis() as i64) |
| 139 | + .unwrap_or(0); |
| 140 | + |
| 141 | + let location = proto::ScanLocation { |
| 142 | + scan_location: Some(proto::scan_location::ScanLocation::PostgresTable( |
| 143 | + proto::PostgresTable { |
| 144 | + database_cluster_name: entity.database_cluster_name.clone(), |
| 145 | + database_instance_name: entity.database_instance_name.clone(), |
| 146 | + database_host_name: sub_task.connection.host.clone(), |
| 147 | + database_name: entity.database.clone(), |
| 148 | + schema_name: entity.schema.clone(), |
| 149 | + table_name: entity.table.clone(), |
| 150 | + scanned_row_count, |
| 151 | + // TODO(DSEC): populate table_row_count (from DBM metadata) and |
| 152 | + // scanned_columns. |
| 153 | + ..Default::default() |
| 154 | + }, |
| 155 | + )), |
| 156 | + ..Default::default() |
| 157 | + }; |
| 158 | + |
| 159 | + let scan_result = ScanResult { |
| 160 | + table_matches: proto::table_matches(matches), |
| 161 | + location: Some(location), |
| 162 | + duration: duration_ms, |
| 163 | + scan_metadata: Some(ScanMetadata { |
| 164 | + scan_task_metadata: Some(ScanTaskMetadata { |
| 165 | + task_id: config.task_id.clone(), |
| 166 | + sub_task_id: sub_task.sub_task_id.clone(), |
| 167 | + started_at: Some(proto::to_timestamp(started_at)), |
| 168 | + ended_at: Some(proto::to_timestamp(ended_at)), |
| 169 | + status: match status { |
| 170 | + ScanStatus::Success => Status::Success, |
| 171 | + ScanStatus::Error => Status::Error, |
| 172 | + } as i32, |
| 173 | + failure_reason: (!failure_reason.is_empty()).then(|| failure_reason.to_string()), |
| 174 | + }), |
| 175 | + }), |
| 176 | + ..Default::default() |
| 177 | + }; |
| 178 | + |
| 179 | + SdsResultPayload { |
| 180 | + timestamp: now_unix_millis(), |
| 181 | + resource: Some(proto::Resource { |
| 182 | + r#type: "postgres_table".to_string(), |
| 183 | + name: resource_name(sub_task), |
| 184 | + }), |
| 185 | + rule_ids: config.scanning_rules.iter().map(|rule| rule.id.clone()).collect(), |
| 186 | + // The scanning source is the Agent. TODO(DSEC): populate hostname and |
| 187 | + // agent version once the check receives them (not provided via config yet). |
| 188 | + scanning_source: Some(proto::ScanningSource { |
| 189 | + source: Some(proto::scanning_source::Source::Agent( |
| 190 | + proto::scanning_source::Agent::default(), |
| 191 | + )), |
| 192 | + }), |
| 193 | + scan_results: vec![scan_result], |
| 194 | + ..Default::default() |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +/// Result of a successful sub task scan. |
| 199 | +struct ScanOutput { |
| 200 | + matches: Vec<Match>, |
| 201 | + scanned_row_count: i64, |
| 202 | +} |
| 203 | + |
| 204 | +/// Fetches the sub task's data and scans it, returning the matches and the |
| 205 | +/// number of rows scanned. |
| 206 | +fn run_scan(scanner: &Scanner, sub_task: &SubTask) -> Result<ScanOutput> { |
98 | 207 | let data = backend::fetch_data(sub_task).context("fetching sub task data")?; |
99 | | - scanner.scan(data).context("scanning sub task data") |
| 208 | + let matches = scanner.scan(data.clone()).context("scanning sub task data")?; |
| 209 | + Ok(ScanOutput { |
| 210 | + scanned_row_count: scanned_rows(&data), |
| 211 | + matches, |
| 212 | + }) |
| 213 | +} |
| 214 | + |
| 215 | +/// Number of rows scanned: the longest column array in the `{ column: [values] }` |
| 216 | +/// map returned by the backend. |
| 217 | +fn scanned_rows(data: &Value) -> i64 { |
| 218 | + data.as_object() |
| 219 | + .and_then(|columns| { |
| 220 | + columns |
| 221 | + .values() |
| 222 | + .filter_map(|value| value.as_array().map(Vec::len)) |
| 223 | + .max() |
| 224 | + }) |
| 225 | + .unwrap_or(0) as i64 |
| 226 | +} |
| 227 | + |
| 228 | +/// Current unix time in milliseconds, for the payload timestamp. |
| 229 | +fn now_unix_millis() -> i64 { |
| 230 | + SystemTime::now() |
| 231 | + .duration_since(UNIX_EPOCH) |
| 232 | + .map(|d| d.as_millis() as i64) |
| 233 | + .unwrap_or(0) |
| 234 | +} |
| 235 | + |
| 236 | +/// Resource name (`<instance_name>.<database>.<schema>.<table>`), following the |
| 237 | +/// DO crawler convention. |
| 238 | +fn resource_name(sub_task: &SubTask) -> String { |
| 239 | + let entity = &sub_task.entity; |
| 240 | + format!( |
| 241 | + "{}.{}.{}.{}", |
| 242 | + entity.database_instance_name, entity.database, entity.schema, entity.table |
| 243 | + ) |
100 | 244 | } |
0 commit comments