1use std::io::Write;
2use std::process::{Command, Stdio};
3
4use serde_json::{json, Value};
5
6use crate::config::DatabaseConfig;
7use crate::db::models::{Asset, ExploitExecution, Finding, Observation, ScanRun, Service};
8use crate::db::schema::{self, FieldDef};
9use crate::db::{collections, Storage};
10use crate::error::{AppError, Result};
11
12const REQUEST_TIMEOUT_S: u64 = 60;
13
14thread_local! {
19 static VERBOSE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
20}
21
22pub fn set_verbose(verbose: bool) {
23 VERBOSE.with(|cell| cell.set(verbose));
24}
25
26fn is_verbose() -> bool {
27 VERBOSE.with(|cell| cell.get())
28}
29
30fn http_error(
35 action: &str,
36 collection: &str,
37 status: u16,
38 body: &Value,
39 url: &str,
40 query: &[(&str, &str)],
41) -> AppError {
42 let concise = format!(
43 "Failed to {action} in '{}' (HTTP {}): {}",
44 collection, status, body
45 );
46 if !is_verbose() {
47 return AppError::Network(concise);
48 }
49 let mut detail = format!("Failed to {action} in '{collection}' (HTTP {status})");
50 detail.push_str("\n url: ");
51 detail.push_str(url);
52 let mut params: Vec<String> = Vec::new();
53 let mut filter = String::new();
54 let mut sort = String::new();
55 let mut expand = String::new();
56 for (k, v) in query {
57 match *k {
58 "filter" => filter.push_str(v),
59 "sort" => sort.push_str(v),
60 "expand" => expand.push_str(v),
61 _ => params.push(format!("{}={}", k, v)),
62 }
63 }
64 if !filter.is_empty() {
65 detail.push_str(&format!("\n filter: {filter}"));
66 }
67 if !sort.is_empty() {
68 detail.push_str(&format!("\n sort: {sort}"));
69 }
70 if !expand.is_empty() {
71 detail.push_str(&format!("\n expand: {expand}"));
72 }
73 if !params.is_empty() {
74 detail.push_str(&format!("\n request params: {}", params.join("&")));
75 }
76 detail.push_str(&format!("\n response body: {body}"));
77 detail.push_str("\n backtrace:\n");
78 detail.push_str(&std::backtrace::Backtrace::force_capture().to_string());
79 AppError::Network(detail)
80}
81
82fn id_field_for(collection: &str) -> &'static str {
86 if collection == collections::EXPLOIT_EXECUTIONS {
87 "execution_id"
88 } else {
89 "stable_id"
90 }
91}
92
93pub struct PocketBaseStorage {
97 config: DatabaseConfig,
98 token: String,
99 token_validated: bool,
101}
102
103struct HttpResponse {
104 status: u16,
105 body: Value,
106}
107
108fn curl_request(
109 method: &str,
110 url: &str,
111 token: Option<&str>,
112 body: Option<&Value>,
113 query: &[(&str, &str)],
114) -> Result<HttpResponse> {
115 let mut command = Command::new("curl");
116 command
117 .arg("-sS")
118 .arg("--max-time")
119 .arg(REQUEST_TIMEOUT_S.to_string())
120 .arg("-H")
121 .arg("Content-Type: application/json")
122 .arg("-w")
123 .arg("\n%{http_code}")
124 .stdout(Stdio::piped())
125 .stderr(Stdio::piped());
126
127 if let Some(token) = token {
128 command
129 .arg("-H")
130 .arg(format!("Authorization: Bearer {}", token));
131 }
132
133 if !query.is_empty() {
134 command.arg("-G");
135 for (key, value) in query {
136 command
137 .arg("--data-urlencode")
138 .arg(format!("{}={}", key, value));
139 }
140 }
141
142 if body.is_some() {
143 if method != "GET" {
144 command.arg("-X").arg(method);
145 }
146 command.arg("--data-binary").arg("@-").stdin(Stdio::piped());
147 } else if method != "GET" {
148 command.arg("-X").arg(method);
149 }
150
151 command.arg(url);
152
153 let mut child = command
154 .spawn()
155 .map_err(|e| AppError::Network(format!("failed to spawn curl: {e}")))?;
156
157 if let Some(b) = body {
158 if let Some(mut stdin) = child.stdin.take() {
159 let payload = serde_json::to_string(b)?;
160 stdin
161 .write_all(payload.as_bytes())
162 .map_err(|e| AppError::Network(format!("failed to write request body: {e}")))?;
163 }
164 }
165
166 let output = child
167 .wait_with_output()
168 .map_err(|e| AppError::Network(format!("failed to wait for curl: {e}")))?;
169
170 let raw = String::from_utf8_lossy(&output.stdout).to_string();
171 let trimmed = raw.trim_end();
172 let (body_str, status) = match trimmed.rsplit_once('\n') {
173 Some((rest, code)) => (rest, code.trim().parse::<u16>().unwrap_or(0)),
174 None => (trimmed, 0),
175 };
176
177 let body: Value = if body_str.trim().is_empty() {
178 Value::Null
179 } else {
180 serde_json::from_str(body_str).unwrap_or_else(|_| json!({ "raw": body_str }))
181 };
182
183 Ok(HttpResponse { status, body })
184}
185
186fn get_str(value: &Value, key: &str) -> String {
187 value
188 .get(key)
189 .and_then(Value::as_str)
190 .unwrap_or("")
191 .to_string()
192}
193
194fn get_opt_str(value: &Value, key: &str) -> Option<String> {
195 value
196 .get(key)
197 .and_then(Value::as_str)
198 .filter(|s| !s.is_empty())
199 .map(str::to_string)
200}
201
202fn get_vec(value: &Value, key: &str) -> Vec<String> {
203 value
204 .get(key)
205 .and_then(Value::as_array)
206 .map(|items| {
207 items
208 .iter()
209 .filter_map(|v| v.as_str().map(str::to_string))
210 .collect()
211 })
212 .unwrap_or_default()
213}
214
215fn get_vec_value(value: &Value, key: &str) -> Vec<Value> {
216 value
217 .get(key)
218 .and_then(Value::as_array)
219 .cloned()
220 .unwrap_or_default()
221}
222
223fn get_json(value: &Value, key: &str) -> Value {
224 value.get(key).cloned().unwrap_or(Value::Null)
225}
226
227fn as_json_object(value: &Value) -> Value {
228 match value {
229 Value::Null => Value::Object(Default::default()),
230 other => other.clone(),
231 }
232}
233
234fn asset_to_record(asset: &Asset) -> Value {
239 let mut record = serde_json::Map::new();
240 record.insert("stable_id".into(), json!(asset.stable_id));
241 record.insert("asset_type".into(), json!(asset.asset_type));
242 record.insert("name".into(), json!(asset.name));
243 if let Some(v) = &asset.ip {
244 record.insert("ip".into(), json!(v));
245 }
246 if let Some(v) = &asset.fqdn {
247 record.insert("fqdn".into(), json!(v));
248 }
249 if let Some(v) = &asset.reverse_dns {
250 record.insert("reverse_dns".into(), json!(v));
251 }
252 record.insert("first_seen".into(), json!(asset.first_seen));
253 record.insert("last_seen".into(), json!(asset.last_seen));
254 record.insert("status".into(), json!(asset.status));
255 record.insert("metadata".into(), as_json_object(&asset.metadata));
256 Value::Object(record)
257}
258
259fn record_to_asset(value: &Value) -> Asset {
260 Asset {
261 stable_id: get_str(value, "stable_id"),
262 asset_type: get_str(value, "asset_type"),
263 name: get_str(value, "name"),
264 ip: get_opt_str(value, "ip"),
265 fqdn: get_opt_str(value, "fqdn"),
266 reverse_dns: get_opt_str(value, "reverse_dns"),
267 first_seen: get_str(value, "first_seen"),
268 last_seen: get_str(value, "last_seen"),
269 status: get_str(value, "status"),
270 metadata: get_json(value, "metadata"),
271 }
272}
273
274fn service_to_record(service: &Service) -> Value {
275 let mut record = serde_json::Map::new();
276 record.insert("stable_id".into(), json!(service.stable_id));
277 record.insert("asset_id".into(), json!(service.asset_id));
278 record.insert("port".into(), json!(service.port));
279 record.insert("protocol".into(), json!(service.protocol));
280 record.insert("service_name".into(), json!(service.service_name));
281 if let Some(v) = &service.product {
282 record.insert("product".into(), json!(v));
283 }
284 if let Some(v) = &service.version {
285 record.insert("version".into(), json!(v));
286 }
287 if let Some(v) = &service.version_str {
288 record.insert("version_str".into(), json!(v));
289 }
290 if let Some(v) = &service.banner {
291 record.insert("banner".into(), json!(v));
292 }
293 record.insert("technologies".into(), json!(service.technologies));
294 record.insert("cpes".into(), json!(service.cpes));
295 record.insert("first_seen".into(), json!(service.first_seen));
296 record.insert("last_seen".into(), json!(service.last_seen));
297 record.insert("status".into(), json!(service.status));
298 record.insert("metadata".into(), as_json_object(&service.metadata));
299 Value::Object(record)
300}
301
302fn record_to_service(value: &Value) -> Service {
303 Service {
304 stable_id: get_str(value, "stable_id"),
305 asset_id: get_str(value, "asset_id"),
306 port: value
307 .get("port")
308 .and_then(Value::as_u64)
309 .and_then(|p| u16::try_from(p).ok())
310 .unwrap_or(0),
311 protocol: get_str(value, "protocol"),
312 service_name: get_str(value, "service_name"),
313 product: get_opt_str(value, "product"),
314 version: get_opt_str(value, "version"),
315 version_str: get_opt_str(value, "version_str"),
316 banner: get_opt_str(value, "banner"),
317 technologies: get_vec(value, "technologies"),
318 cpes: get_vec(value, "cpes"),
319 first_seen: get_str(value, "first_seen"),
320 last_seen: get_str(value, "last_seen"),
321 status: get_str(value, "status"),
322 metadata: get_json(value, "metadata"),
323 }
324}
325
326fn finding_to_record(finding: &Finding) -> Value {
327 let mut record = serde_json::Map::new();
328 record.insert("stable_id".into(), json!(finding.stable_id));
329 record.insert("asset_id".into(), json!(finding.asset_id));
330 if let Some(v) = &finding.service_id {
331 record.insert("service_id".into(), json!(v));
332 }
333 record.insert("title".into(), json!(finding.title));
334 record.insert("severity".into(), json!(finding.severity));
335 record.insert("tool".into(), json!(finding.tool));
336 if let Some(v) = &finding.target_url {
337 record.insert("target_url".into(), json!(v));
338 }
339 if let Some(v) = &finding.detail {
340 record.insert("detail".into(), json!(v));
341 }
342 if let Some(v) = &finding.detail_path {
343 record.insert("detail_path".into(), json!(v));
344 }
345 if let Some(v) = &finding.reference {
346 record.insert("reference".into(), json!(v));
347 }
348 record.insert("cves".into(), json!(finding.cves));
349 record.insert("endpoints".into(), json!(finding.endpoints));
350 record.insert("technologies".into(), json!(finding.technologies));
351 record.insert("first_seen".into(), json!(finding.first_seen));
352 record.insert("last_seen".into(), json!(finding.last_seen));
353 record.insert("status".into(), json!(finding.status));
354 if let Some(v) = &finding.exploitability {
355 record.insert("exploitability".into(), json!(v));
356 }
357 record.insert("metadata".into(), as_json_object(&finding.metadata));
358 Value::Object(record)
359}
360
361fn record_to_finding(value: &Value) -> Finding {
362 Finding {
363 stable_id: get_str(value, "stable_id"),
364 asset_id: get_str(value, "asset_id"),
365 service_id: get_opt_str(value, "service_id"),
366 title: get_str(value, "title"),
367 severity: get_str(value, "severity"),
368 tool: get_str(value, "tool"),
369 target_url: get_opt_str(value, "target_url"),
370 detail: get_opt_str(value, "detail"),
371 detail_path: get_opt_str(value, "detail_path"),
372 reference: get_opt_str(value, "reference"),
373 cves: get_vec(value, "cves"),
374 endpoints: get_vec(value, "endpoints"),
375 technologies: get_vec(value, "technologies"),
376 first_seen: get_str(value, "first_seen"),
377 last_seen: get_str(value, "last_seen"),
378 status: get_str(value, "status"),
379 exploitability: get_opt_str(value, "exploitability"),
380 metadata: get_json(value, "metadata"),
381 }
382}
383
384fn observation_to_record(observation: &Observation) -> Value {
385 json!({
386 "stable_id": observation.stable_id,
387 "run_id": observation.run_id,
388 "subject_type": observation.subject_type,
389 "subject_id": observation.subject_id,
390 "kind": observation.kind,
391 "before": as_json_object(&observation.before),
392 "after": as_json_object(&observation.after),
393 "detail": observation.detail,
394 "observed_at": observation.observed_at,
395 })
396}
397
398fn record_to_observation(value: &Value) -> Observation {
399 Observation {
400 stable_id: get_str(value, "stable_id"),
401 run_id: get_str(value, "run_id"),
402 subject_type: get_str(value, "subject_type"),
403 subject_id: get_str(value, "subject_id"),
404 kind: get_str(value, "kind"),
405 before: get_json(value, "before"),
406 after: get_json(value, "after"),
407 detail: get_str(value, "detail"),
408 observed_at: get_str(value, "observed_at"),
409 }
410}
411
412fn scan_run_to_record(run: &ScanRun) -> Value {
413 json!({
414 "stable_id": run.stable_id,
415 "target": run.target,
416 "folder": run.folder,
417 "started_at": run.started_at,
418 "finished_at": run.finished_at,
419 "tools": run.tools,
420 "artifacts": run.artifacts,
421 "content_hash": run.content_hash,
422 "imported_at": run.imported_at,
423 "stats": as_json_object(&run.stats),
424 })
425}
426
427fn record_to_scan_run(value: &Value) -> ScanRun {
428 ScanRun {
429 stable_id: get_str(value, "stable_id"),
430 target: get_str(value, "target"),
431 folder: get_str(value, "folder"),
432 started_at: get_opt_str(value, "started_at"),
433 finished_at: get_opt_str(value, "finished_at"),
434 tools: get_vec(value, "tools"),
435 artifacts: get_vec_value(value, "artifacts"),
436 content_hash: get_str(value, "content_hash"),
437 imported_at: get_str(value, "imported_at"),
438 stats: get_json(value, "stats"),
439 }
440}
441
442fn get_options_map(value: &Value, key: &str) -> std::collections::BTreeMap<String, String> {
443 value
444 .get(key)
445 .and_then(Value::as_object)
446 .map(|map| {
447 map.iter()
448 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
449 .collect()
450 })
451 .unwrap_or_default()
452}
453
454fn exploit_execution_to_record(execution: &ExploitExecution) -> Value {
455 json!({
456 "execution_id": execution.execution_id,
457 "run_id": execution.run_id,
458 "asset_id": execution.asset_id,
459 "vulnerability_id": execution.vulnerability_id,
460 "module_type": execution.module_type,
461 "module": execution.module,
462 "host": execution.host,
463 "payload": execution.payload,
464 "status": execution.status,
465 "start_time": execution.start_time,
466 "finish_time": execution.finish_time,
467 "job_id": execution.job_id,
468 "session_id": execution.session_id,
469 "error": execution.error,
470 "loot": execution.loot,
471 "options": execution.options,
472 "selected": execution.selected,
473 "created_at": execution.created_at,
474 })
475}
476
477fn record_to_exploit_execution(value: &Value) -> ExploitExecution {
478 ExploitExecution {
479 execution_id: get_str(value, "execution_id"),
480 run_id: get_str(value, "run_id"),
481 asset_id: get_str(value, "asset_id"),
482 vulnerability_id: get_str(value, "vulnerability_id"),
483 module_type: get_str(value, "module_type"),
484 module: get_str(value, "module"),
485 host: get_str(value, "host"),
486 payload: get_opt_str(value, "payload"),
487 status: get_str(value, "status"),
488 start_time: get_str(value, "start_time"),
489 finish_time: get_opt_str(value, "finish_time"),
490 job_id: get_opt_str(value, "job_id"),
491 session_id: get_opt_str(value, "session_id"),
492 error: get_opt_str(value, "error"),
493 loot: get_vec(value, "loot"),
494 options: get_options_map(value, "options"),
495 selected: value
496 .get("selected")
497 .and_then(Value::as_bool)
498 .unwrap_or(false),
499 created_at: get_str(value, "created_at"),
500 }
501}
502
503impl PocketBaseStorage {
504 pub fn new(config: &DatabaseConfig) -> Result<Self> {
505 let mut url = config.pocketbase_url.trim().to_string();
506 if url.is_empty() {
507 url = "http://127.0.0.1:8090".to_string();
508 }
509 let url = url.trim_end_matches('/').to_string();
510 Ok(Self {
511 config: DatabaseConfig {
512 pocketbase_url: url,
513 ..config.clone()
514 },
515 token: config.pocketbase_token.clone(),
516 token_validated: false,
517 })
518 }
519
520 fn base_url(&self) -> &str {
521 &self.config.pocketbase_url
522 }
523
524 pub fn token(&self) -> String {
526 self.token.clone()
527 }
528
529 pub fn login(&mut self) -> Result<String> {
532 let email = self.config.pocketbase_admin_email.trim();
533 let password = self.config.pocketbase_admin_password.trim();
534 if email.is_empty() || password.is_empty() {
535 return Err(AppError::Message(
536 "PocketBase requires superuser credentials (email + password) to connect. \
537 Run `ploit-malper db_setup` to configure them."
538 .to_string(),
539 ));
540 }
541 let url = format!(
542 "{}/api/collections/_superusers/auth-with-password",
543 self.base_url()
544 );
545 let body = json!({ "identity": email, "password": password });
546 let resp = curl_request("POST", &url, None, Some(&body), &[])?;
547 if resp.status != 200 && resp.status != 201 {
548 return Err(AppError::Network(format!(
549 "PocketBase authentication failed (HTTP {}): {}",
550 resp.status, resp.body
551 )));
552 }
553 let token = get_str(&resp.body, "token");
554 if token.is_empty() {
555 return Err(AppError::Network(format!(
556 "PocketBase authentication response did not include a token: {}",
557 resp.body
558 )));
559 }
560 self.token = token.clone();
561 self.config.pocketbase_token = token.clone();
562 self.token_validated = true;
563 Ok(token)
564 }
565
566 fn ensure_token(&mut self) -> Result<String> {
569 if self.token_validated && !self.token.is_empty() {
570 return Ok(self.token.clone());
571 }
572 if !self.token.is_empty() {
573 let url = format!("{}/api/collections", self.base_url());
574 let resp = curl_request("GET", &url, Some(&self.token), None, &[("perPage", "1")])?;
575 if resp.status == 200 {
576 self.token_validated = true;
577 return Ok(self.token.clone());
578 }
579 self.token.clear();
580 }
581 self.login()
582 }
583
584 pub fn list_collection_names(&mut self) -> Result<Vec<String>> {
585 let token = self.ensure_token()?;
586 let url = format!("{}/api/collections", self.base_url());
587 let query: &[(&str, &str)] = &[("perPage", "200")];
588 let resp = curl_request("GET", &url, Some(&token), None, query)?;
589 if resp.status != 200 {
590 return Err(http_error(
591 "list PocketBase collections",
592 "",
593 resp.status,
594 &resp.body,
595 &url,
596 query,
597 ));
598 }
599 let names = resp
600 .body
601 .get("items")
602 .and_then(Value::as_array)
603 .map(|items| {
604 items
605 .iter()
606 .filter_map(|c| c.get("name").and_then(Value::as_str).map(str::to_string))
607 .collect()
608 })
609 .unwrap_or_default();
610 Ok(names)
611 }
612
613 fn find_record(
614 &mut self,
615 collection: &str,
616 stable_id: &str,
617 ) -> Result<Option<(String, Value)>> {
618 let token = self.ensure_token()?;
619 let url = format!("{}/api/collections/{}/records", self.base_url(), collection);
620 let filter_field = id_field_for(collection);
621 let filter = format!("{} = \"{}\"", filter_field, stable_id);
622 let resp = curl_request(
623 "GET",
624 &url,
625 Some(&token),
626 None,
627 &[("filter", &filter), ("perPage", "500")],
628 )?;
629 if resp.status != 200 {
630 return Err(http_error(
631 "query records",
632 collection,
633 resp.status,
634 &resp.body,
635 &url,
636 &[("filter", &filter), ("perPage", "500")],
637 ));
638 }
639 let items = resp
640 .body
641 .get("items")
642 .and_then(Value::as_array)
643 .cloned()
644 .unwrap_or_default();
645 if let Some(first) = items.into_iter().next() {
646 let id = get_str(&first, "id");
647 return Ok(Some((id, first)));
648 }
649 Ok(None)
650 }
651
652 fn create_record(&mut self, collection: &str, record: &Value) -> Result<()> {
653 let token = self.ensure_token()?;
654 let url = format!("{}/api/collections/{}/records", self.base_url(), collection);
655 let resp = curl_request("POST", &url, Some(&token), Some(record), &[])?;
656 if resp.status != 200 && resp.status != 201 {
657 return Err(http_error(
658 "create record in",
659 collection,
660 resp.status,
661 &resp.body,
662 &url,
663 &[],
664 ));
665 }
666 Ok(())
667 }
668
669 fn update_record(&mut self, collection: &str, record_id: &str, record: &Value) -> Result<()> {
670 let token = self.ensure_token()?;
671 let url = format!(
672 "{}/api/collections/{}/records/{}",
673 self.base_url(),
674 collection,
675 record_id
676 );
677 let resp = curl_request("PATCH", &url, Some(&token), Some(record), &[])?;
678 if resp.status != 200 && resp.status != 204 {
679 return Err(http_error(
680 "update record in",
681 collection,
682 resp.status,
683 &resp.body,
684 &url,
685 &[],
686 ));
687 }
688 Ok(())
689 }
690
691 fn delete_record_by_id(&mut self, collection: &str, record_id: &str) -> Result<()> {
692 let token = self.ensure_token()?;
693 let url = format!(
694 "{}/api/collections/{}/records/{}",
695 self.base_url(),
696 collection,
697 record_id
698 );
699 let resp = curl_request("DELETE", &url, Some(&token), None, &[])?;
700 if resp.status != 200 && resp.status != 204 {
701 return Err(http_error(
702 "delete record in",
703 collection,
704 resp.status,
705 &resp.body,
706 &url,
707 &[],
708 ));
709 }
710 Ok(())
711 }
712
713 fn delete_records_by_filter(&mut self, collection: &str, filter: &str) -> Result<()> {
714 let records = self.list_records(collection, Some(filter))?;
715 for record in &records {
716 let record_id = get_str(record, "id");
717 if !record_id.is_empty() {
718 self.delete_record_by_id(collection, &record_id)?;
719 }
720 }
721 Ok(())
722 }
723
724 fn upsert(&mut self, collection: &str, stable_id: &str, record: &Value) -> Result<()> {
725 if let Some((record_id, _)) = self.find_record(collection, stable_id)? {
726 self.update_record(collection, &record_id, record)
727 } else {
728 self.create_record(collection, record)
729 }
730 }
731
732 fn list_records(&mut self, collection: &str, filter: Option<&str>) -> Result<Vec<Value>> {
733 let token = self.ensure_token()?;
734 let url = format!("{}/api/collections/{}/records", self.base_url(), collection);
735 const PER_PAGE: u64 = 500;
736 let mut items: Vec<Value> = Vec::new();
737 let mut page: u64 = 1;
738 loop {
739 let page_str = page.to_string();
740 let per_str = PER_PAGE.to_string();
741 let mut query: Vec<(&str, &str)> =
742 vec![("page", page_str.as_str()), ("perPage", per_str.as_str())];
743 if let Some(f) = filter {
744 query.push(("filter", f));
745 }
746 let resp = curl_request("GET", &url, Some(&token), None, &query)?;
747 if resp.status != 200 {
748 return Err(http_error(
749 "list records",
750 collection,
751 resp.status,
752 &resp.body,
753 &url,
754 &query,
755 ));
756 }
757 let batch = resp
758 .body
759 .get("items")
760 .and_then(Value::as_array)
761 .cloned()
762 .unwrap_or_default();
763 let count = batch.len();
764 items.extend(batch);
765 if count < PER_PAGE as usize {
766 break;
767 }
768 page += 1;
769 }
770 Ok(items)
771 }
772
773 fn create_collection_via_api(&mut self, name: &str) -> Result<()> {
776 let token = self.ensure_token()?;
777 let url = format!("{}/api/collections", self.base_url());
778 let defs = schema::field_defs(name);
779 let fields: Vec<Value> = defs.iter().map(|def| self.field_to_json(def)).collect();
780 let body = json!({
781 "name": name,
782 "type": "base",
783 "fields": fields,
784 });
785 let resp = curl_request("POST", &url, Some(&token), Some(&body), &[])?;
786 if resp.status != 200 && resp.status != 201 {
787 return Err(AppError::Network(format!(
788 "Failed to create PocketBase collection '{}' (HTTP {}): {}",
789 name, resp.status, resp.body
790 )));
791 }
792 Ok(())
793 }
794
795 fn field_to_json(&self, def: &FieldDef) -> Value {
796 let mut field = serde_json::Map::new();
797 field.insert("name".to_string(), json!(def.name));
798 field.insert("type".to_string(), json!(def.kind));
799 field.insert("required".to_string(), json!(def.required));
800 field.insert("unique".to_string(), json!(def.unique));
801 field.insert("hidden".to_string(), json!(false));
802 if def.kind == "select" {
803 field.insert(
804 "values".to_string(),
805 json!(["ip", "hostname", "domain", "url"]),
806 );
807 field.insert("maxSelect".to_string(), json!(1));
808 }
809 Value::Object(field)
810 }
811}
812
813impl Storage for PocketBaseStorage {
814 fn kind(&self) -> &str {
815 "pocketbase"
816 }
817
818 fn ping(&mut self) -> Result<bool> {
819 let url = format!("{}/api/health", self.base_url());
820 let resp = curl_request("GET", &url, None, None, &[])?;
821 if resp.status == 200 {
822 return Ok(true);
823 }
824 match self.ensure_token() {
826 Ok(token) => {
827 let resp = curl_request("GET", &url, Some(&token), None, &[])?;
828 Ok(resp.status == 200)
829 }
830 Err(e) => Err(e),
831 }
832 }
833
834 fn ensure_schema(&mut self) -> Result<()> {
835 let existing = self.list_collection_names()?;
838 for name in collections::ALL {
839 if !existing.iter().any(|n| n == name) {
840 self.create_collection_via_api(name)?;
841 }
842 }
843 Ok(())
844 }
845
846 fn upsert_scan_run(&mut self, run: &ScanRun) -> Result<()> {
847 self.upsert(
848 collections::SCAN_RUNS,
849 &run.stable_id,
850 &scan_run_to_record(run),
851 )
852 }
853
854 fn get_scan_run(&mut self, run_id: &str) -> Result<Option<ScanRun>> {
855 if let Some((_, record)) = self.find_record(collections::SCAN_RUNS, run_id)? {
856 return Ok(Some(record_to_scan_run(&record)));
857 }
858 Ok(None)
859 }
860
861 fn list_scan_runs(&mut self) -> Result<Vec<ScanRun>> {
862 let records = self.list_records(collections::SCAN_RUNS, None)?;
863 Ok(records.iter().map(record_to_scan_run).collect())
864 }
865
866 fn delete_scan_run(&mut self, run_id: &str) -> Result<()> {
867 let obs_filter = format!("run_id = \"{}\"", run_id);
868 self.delete_records_by_filter(collections::OBSERVATIONS, &obs_filter)?;
869 let exec_filter = format!("run_id = \"{}\"", run_id);
870 self.delete_records_by_filter(collections::EXPLOIT_EXECUTIONS, &exec_filter)?;
871 if let Some((record_id, _)) = self.find_record(collections::SCAN_RUNS, run_id)? {
872 self.delete_record_by_id(collections::SCAN_RUNS, &record_id)?;
873 }
874 Ok(())
875 }
876
877 fn upsert_asset(&mut self, asset: &Asset) -> Result<()> {
878 self.upsert(
879 collections::ASSETS,
880 &asset.stable_id,
881 &asset_to_record(asset),
882 )
883 }
884
885 fn get_asset(&mut self, asset_id: &str) -> Result<Option<Asset>> {
886 if let Some((_, record)) = self.find_record(collections::ASSETS, asset_id)? {
887 return Ok(Some(record_to_asset(&record)));
888 }
889 Ok(None)
890 }
891
892 fn list_assets(&mut self) -> Result<Vec<Asset>> {
893 let records = self.list_records(collections::ASSETS, None)?;
894 Ok(records.iter().map(record_to_asset).collect())
895 }
896
897 fn upsert_service(&mut self, service: &Service) -> Result<()> {
898 self.upsert(
899 collections::SERVICES,
900 &service.stable_id,
901 &service_to_record(service),
902 )
903 }
904
905 fn get_service(&mut self, service_id: &str) -> Result<Option<Service>> {
906 if let Some((_, record)) = self.find_record(collections::SERVICES, service_id)? {
907 return Ok(Some(record_to_service(&record)));
908 }
909 Ok(None)
910 }
911
912 fn list_services_for_asset(&mut self, asset_id: &str) -> Result<Vec<Service>> {
913 let filter = format!("asset_id = \"{}\"", asset_id);
914 let records = self.list_records(collections::SERVICES, Some(&filter))?;
915 Ok(records.iter().map(record_to_service).collect())
916 }
917
918 fn list_all_services(&mut self) -> Result<Vec<Service>> {
919 let records = self.list_records(collections::SERVICES, None)?;
920 Ok(records.iter().map(record_to_service).collect())
921 }
922
923 fn upsert_finding(&mut self, finding: &Finding) -> Result<()> {
924 self.upsert(
925 collections::FINDINGS,
926 &finding.stable_id,
927 &finding_to_record(finding),
928 )
929 }
930
931 fn get_finding(&mut self, finding_id: &str) -> Result<Option<Finding>> {
932 if let Some((_, record)) = self.find_record(collections::FINDINGS, finding_id)? {
933 return Ok(Some(record_to_finding(&record)));
934 }
935 Ok(None)
936 }
937
938 fn list_findings_for_asset(&mut self, asset_id: &str) -> Result<Vec<Finding>> {
939 let filter = format!("asset_id = \"{}\"", asset_id);
940 let records = self.list_records(collections::FINDINGS, Some(&filter))?;
941 Ok(records.iter().map(record_to_finding).collect())
942 }
943
944 fn list_all_findings(&mut self) -> Result<Vec<Finding>> {
945 let records = self.list_records(collections::FINDINGS, None)?;
946 Ok(records.iter().map(record_to_finding).collect())
947 }
948
949 fn add_observation(&mut self, observation: &Observation) -> Result<()> {
950 if self.get_observation(&observation.stable_id)?.is_some() {
951 return Ok(());
952 }
953 self.create_record(
954 collections::OBSERVATIONS,
955 &observation_to_record(observation),
956 )
957 }
958
959 fn get_observation(&mut self, obs_id: &str) -> Result<Option<Observation>> {
960 if let Some((_, record)) = self.find_record(collections::OBSERVATIONS, obs_id)? {
961 return Ok(Some(record_to_observation(&record)));
962 }
963 Ok(None)
964 }
965
966 fn list_observations_for(
967 &mut self,
968 subject_type: &str,
969 subject_id: &str,
970 ) -> Result<Vec<Observation>> {
971 let filter = format!(
972 "subject_type = \"{}\" && subject_id = \"{}\"",
973 subject_type, subject_id
974 );
975 let records = self.list_records(collections::OBSERVATIONS, Some(&filter))?;
976 Ok(records.iter().map(record_to_observation).collect())
977 }
978
979 fn list_all_observations(&mut self) -> Result<Vec<Observation>> {
980 let records = self.list_records(collections::OBSERVATIONS, None)?;
981 Ok(records.iter().map(record_to_observation).collect())
982 }
983
984 fn upsert_exploit_execution(&mut self, execution: &ExploitExecution) -> Result<()> {
985 self.upsert(
986 collections::EXPLOIT_EXECUTIONS,
987 &execution.execution_id,
988 &exploit_execution_to_record(execution),
989 )
990 }
991
992 fn get_exploit_execution(&mut self, execution_id: &str) -> Result<Option<ExploitExecution>> {
993 if let Some((_, record)) =
994 self.find_record(collections::EXPLOIT_EXECUTIONS, execution_id)?
995 {
996 return Ok(Some(record_to_exploit_execution(&record)));
997 }
998 Ok(None)
999 }
1000
1001 fn list_exploit_executions(&mut self) -> Result<Vec<ExploitExecution>> {
1002 let records = self.list_records(collections::EXPLOIT_EXECUTIONS, None)?;
1003 Ok(records.iter().map(record_to_exploit_execution).collect())
1004 }
1005
1006 fn list_exploit_executions_for_run(&mut self, run_id: &str) -> Result<Vec<ExploitExecution>> {
1007 let filter = format!("run_id = \"{}\"", run_id);
1008 let records = self.list_records(collections::EXPLOIT_EXECUTIONS, Some(&filter))?;
1009 Ok(records.iter().map(record_to_exploit_execution).collect())
1010 }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015 use super::*;
1016
1017 #[test]
1018 fn id_field_is_execution_id_for_exploit_executions() {
1019 assert_eq!(id_field_for("ExploitExecutions"), "execution_id");
1020 }
1021
1022 #[test]
1023 fn id_field_is_stable_id_for_all_other_collections() {
1024 assert_eq!(id_field_for("Assets"), "stable_id");
1025 assert_eq!(id_field_for("Services"), "stable_id");
1026 assert_eq!(id_field_for("Findings"), "stable_id");
1027 assert_eq!(id_field_for("Observations"), "stable_id");
1028 assert_eq!(id_field_for("ScanRuns"), "stable_id");
1029 }
1030
1031 #[test]
1032 fn every_required_collection_has_a_valid_id_field() {
1033 for collection in collections::ALL {
1034 let field = id_field_for(collection);
1035 let defs = schema::field_defs(collection);
1036 assert!(
1037 defs.iter().any(|def| def.name == field),
1038 "collection '{}' schema missing its id field '{}'",
1039 collection,
1040 field
1041 );
1042 }
1043 }
1044
1045 #[test]
1046 fn concise_error_is_used_by_default() {
1047 assert!(!is_verbose());
1048 let err = http_error(
1049 "query records",
1050 "ExploitExecutions",
1051 400,
1052 &json!({"message": "boom"}),
1053 "http://pocketbase/api/collections/ExploitExecutions/records",
1054 &[("filter", "stable_id = \"x\"")],
1055 );
1056 let msg = err.to_string();
1057 assert!(msg.contains("Failed to query records in 'ExploitExecutions' (HTTP 400)"));
1058 assert!(
1059 !msg.contains("backtrace"),
1060 "concise error leaked debug detail: {msg}"
1061 );
1062 }
1063
1064 #[test]
1065 fn verbose_error_includes_request_context() {
1066 set_verbose(true);
1067 let err = http_error(
1068 "query records",
1069 "ExploitExecutions",
1070 400,
1071 &json!({"data": {}, "message": "Something went wrong"}),
1072 "http://pocketbase/api/collections/ExploitExecutions/records",
1073 &[
1074 ("filter", "stable_id = \"x\""),
1075 ("sort", "-created"),
1076 ("expand", "rel"),
1077 ("perPage", "500"),
1078 ],
1079 );
1080 set_verbose(false);
1081 let msg = err.to_string();
1082 assert!(msg.contains("filter: stable_id = \"x\""), "{msg}");
1083 assert!(msg.contains("sort: -created"));
1084 assert!(msg.contains("expand: rel"));
1085 assert!(msg.contains("request params: perPage=500"));
1086 assert!(msg.contains("response body:"));
1087 assert!(msg.contains("backtrace:"));
1088 }
1089}