1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use sha2::{Digest, Sha256};
4use std::collections::BTreeMap;
5use time::format_description::well_known::Rfc3339;
6use time::OffsetDateTime;
7
8pub fn now_utc() -> String {
9 OffsetDateTime::now_utc()
10 .format(&Rfc3339)
11 .unwrap_or_else(|_| format!("{:?}", std::time::SystemTime::now()))
12}
13
14pub fn sha256_hex(input: &str) -> String {
15 let mut hasher = Sha256::new();
16 hasher.update(input.as_bytes());
17 format!("{:x}", hasher.finalize())
18}
19
20fn stable_id(prefix: &str, key: &str) -> String {
21 sha256_hex(&format!("{}:{}", prefix, key))
22}
23
24pub const ASSET_ACTIVE: &str = "active";
25pub const ASSET_REMOVED: &str = "removed";
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct Asset {
30 pub stable_id: String,
31 pub asset_type: String,
32 pub name: String,
33 pub ip: Option<String>,
34 pub fqdn: Option<String>,
35 pub reverse_dns: Option<String>,
36 pub first_seen: String,
37 pub last_seen: String,
38 pub status: String,
39 #[serde(default)]
40 pub metadata: Value,
41}
42
43impl Asset {
44 pub fn new(name: &str, asset_type: &str) -> Self {
45 let mut asset = Self {
46 stable_id: stable_id("asset", name),
47 asset_type: asset_type.to_string(),
48 name: name.to_string(),
49 ..Default::default()
50 };
51 asset.metadata = Value::Object(Default::default());
52 let now = now_utc();
53 asset.first_seen = now.clone();
54 asset.last_seen = now;
55 asset.status = ASSET_ACTIVE.to_string();
56 asset
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct Service {
63 pub stable_id: String,
64 pub asset_id: String,
65 pub port: u16,
66 pub protocol: String,
67 pub service_name: String,
68 pub product: Option<String>,
69 pub version: Option<String>,
70 pub version_str: Option<String>,
71 pub banner: Option<String>,
72 #[serde(default)]
73 pub technologies: Vec<String>,
74 #[serde(default)]
75 pub cpes: Vec<String>,
76 pub first_seen: String,
77 pub last_seen: String,
78 pub status: String,
79 #[serde(default)]
80 pub metadata: Value,
81}
82
83impl Service {
84 pub fn new(asset_id: &str, port: u16, protocol: &str, service_name: &str) -> Self {
85 let key = format!("{}:{}:{}:{}", asset_id, port, protocol, service_name);
86 let mut service = Self {
87 stable_id: stable_id("service", &key),
88 asset_id: asset_id.to_string(),
89 port,
90 protocol: protocol.to_string(),
91 service_name: service_name.to_string(),
92 ..Default::default()
93 };
94 service.metadata = Value::Object(Default::default());
95 let now = now_utc();
96 service.first_seen = now.clone();
97 service.last_seen = now;
98 service.status = ASSET_ACTIVE.to_string();
99 service
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
105pub struct Finding {
106 pub stable_id: String,
107 pub asset_id: String,
108 pub service_id: Option<String>,
109 pub title: String,
110 pub severity: String,
111 pub tool: String,
112 pub target_url: Option<String>,
113 pub detail: Option<String>,
116 pub detail_path: Option<String>,
118 pub reference: Option<String>,
119 #[serde(default)]
120 pub cves: Vec<String>,
121 #[serde(default)]
122 pub endpoints: Vec<String>,
123 #[serde(default)]
124 pub technologies: Vec<String>,
125 pub first_seen: String,
126 pub last_seen: String,
127 pub status: String,
128 pub exploitability: Option<String>,
129 #[serde(default)]
130 pub metadata: Value,
131}
132
133impl Finding {
134 pub fn new(asset_id: &str, tool: &str, title: &str) -> Self {
142 let key = format!("{}:{}:{}", asset_id, tool, stable_title_identity(title));
143 let mut finding = Self {
144 stable_id: stable_id("finding", &key),
145 asset_id: asset_id.to_string(),
146 tool: tool.to_string(),
147 title: title.to_string(),
148 ..Default::default()
149 };
150 finding.metadata = Value::Object(Default::default());
151 let now = now_utc();
152 finding.first_seen = now.clone();
153 finding.last_seen = now;
154 finding.status = ASSET_ACTIVE.to_string();
155 finding
156 }
157}
158
159fn stable_title_identity(title: &str) -> String {
163 let collapsed = title.split_whitespace().collect::<Vec<_>>().join(" ");
164 let parts: Vec<&str> = collapsed
165 .split('|')
166 .map(str::trim)
167 .filter(|part| !part.is_empty())
168 .collect();
169 let joined = parts.join("|");
170 joined
171 .trim_end_matches(|c: char| ".,:;\\/|".contains(c))
172 .trim()
173 .to_string()
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178pub struct Observation {
179 pub stable_id: String,
180 pub run_id: String,
181 pub subject_type: String,
182 pub subject_id: String,
183 pub kind: String,
184 #[serde(default)]
185 pub before: Value,
186 #[serde(default)]
187 pub after: Value,
188 pub detail: String,
189 pub observed_at: String,
190}
191
192impl Observation {
193 pub fn new(
194 run_id: &str,
195 subject_type: &str,
196 subject_id: &str,
197 kind: &str,
198 before: Value,
199 after: Value,
200 detail: &str,
201 ) -> Self {
202 let before_s = before.to_string();
203 let after_s = after.to_string();
204 let key = format!(
205 "{}:{}:{}:{}:{}:{}",
206 run_id, subject_type, subject_id, kind, before_s, after_s
207 );
208 Self {
209 stable_id: stable_id("observation", &key),
210 run_id: run_id.to_string(),
211 subject_type: subject_type.to_string(),
212 subject_id: subject_id.to_string(),
213 kind: kind.to_string(),
214 before,
215 after,
216 detail: detail.to_string(),
217 observed_at: now_utc(),
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, Default)]
224pub struct ScanRun {
225 pub stable_id: String,
226 pub target: String,
227 pub folder: String,
228 pub started_at: Option<String>,
229 pub finished_at: Option<String>,
230 #[serde(default)]
231 pub tools: Vec<String>,
232 #[serde(default)]
233 pub artifacts: Vec<Value>,
234 pub content_hash: String,
235 pub imported_at: String,
236 #[serde(default)]
237 pub stats: Value,
238}
239
240impl ScanRun {
241 pub fn new(
242 run_id: &str,
243 target: &str,
244 folder: &str,
245 content_hash: &str,
246 started_at: Option<String>,
247 ) -> Self {
248 Self {
249 stable_id: run_id.to_string(),
250 target: target.to_string(),
251 folder: folder.to_string(),
252 started_at,
253 finished_at: None,
254 content_hash: content_hash.to_string(),
255 imported_at: now_utc(),
256 stats: Value::Object(Default::default()),
257 ..Default::default()
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
266pub struct ExploitExecution {
267 pub execution_id: String,
268 pub run_id: String,
269 pub asset_id: String,
270 pub vulnerability_id: String,
271 pub module_type: String,
272 pub module: String,
273 pub host: String,
274 pub payload: Option<String>,
275 pub status: String,
276 pub start_time: String,
277 pub finish_time: Option<String>,
278 pub job_id: Option<String>,
279 pub session_id: Option<String>,
280 pub error: Option<String>,
281 #[serde(default)]
282 pub loot: Vec<String>,
283 #[serde(default)]
284 pub options: BTreeMap<String, String>,
285 pub selected: bool,
287 pub created_at: String,
288}
289
290impl ExploitExecution {
291 pub fn new(run_id: &str, asset_id: &str, vulnerability_id: &str, module: &str) -> Self {
292 let key = format!("{}:{}:{}:{}", run_id, asset_id, vulnerability_id, module);
293 let now = now_utc();
294 Self {
295 execution_id: sha256_hex(&format!("exploit_exec:{}:{}", key, now)),
296 run_id: run_id.to_string(),
297 asset_id: asset_id.to_string(),
298 vulnerability_id: vulnerability_id.to_string(),
299 module: module.to_string(),
300 module_type: String::new(),
301 host: String::new(),
302 payload: None,
303 status: String::new(),
304 start_time: now.clone(),
305 finish_time: None,
306 job_id: None,
307 session_id: None,
308 error: None,
309 loot: Vec::new(),
310 options: BTreeMap::new(),
311 selected: false,
312 created_at: now,
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn finding_identity_is_stable_across_framing() {
323 let a = Finding::new("asset", "nmap", "Apache Version /");
324 let b = Finding::new("asset", "nmap", "Apache Version | ");
325 assert_eq!(a.stable_id, b.stable_id);
326 let c = Finding::new("asset", "nmap", "Apache Version");
327 assert_eq!(a.stable_id, c.stable_id);
328 }
329
330 #[test]
331 fn finding_identity_keeps_distinct_titles_separate() {
332 let a = Finding::new("asset", "nmap", "Apache 2.4");
333 let b = Finding::new("asset", "nmap", "Apache 2.2");
334 assert_ne!(a.stable_id, b.stable_id);
335 }
336
337 #[test]
338 fn finding_identity_is_scoped_by_tool_and_asset() {
339 let a = Finding::new("asset", "nmap", "Apache");
340 let b = Finding::new("asset", "nikto", "Apache");
341 let c = Finding::new("asset2", "nmap", "Apache");
342 assert_ne!(a.stable_id, b.stable_id);
343 assert_ne!(a.stable_id, c.stable_id);
344 }
345}