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