1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use serde_json::{json, Value};
5use time::format_description::well_known::Rfc3339;
6use time::OffsetDateTime;
7
8use crate::config::DatabaseConfig;
9use crate::db::content;
10use crate::db::models::{
11 now_utc, sha256_hex, Asset, Finding, Observation, ScanRun, Service, ASSET_REMOVED,
12};
13use crate::db::Storage;
14use crate::error::{AppError, Result};
15use crate::ingest::scanner::{scan_folder, Artifact, ArtifactKind};
16
17#[derive(Debug, Clone)]
19pub struct IngestOptions {
20 pub verbose: bool,
21 pub dry_run: bool,
22 pub force: bool,
23 pub backend: String,
25 pub pocketbase_url: Option<String>,
26 pub sqlite_path: Option<String>,
27}
28
29impl Default for IngestOptions {
30 fn default() -> Self {
31 Self {
32 verbose: false,
33 dry_run: false,
34 force: false,
35 backend: "auto".to_string(),
36 pocketbase_url: None,
37 sqlite_path: None,
38 }
39 }
40}
41
42#[derive(Debug, Default, Clone)]
44pub struct IngestSummary {
45 pub run_id: String,
46 pub target: String,
47 pub artifacts_accepted: usize,
48 pub artifacts_rejected: usize,
49 pub assets: usize,
50 pub services: usize,
51 pub findings: usize,
52 pub observations: usize,
53 pub skipped_already_imported: bool,
54 pub dry_run: bool,
55}
56
57pub fn ingest_folder(
62 folder: &Path,
63 config: &DatabaseConfig,
64 opts: &IngestOptions,
65) -> Result<IngestSummary> {
66 if opts.verbose {
67 println!("[+] Scanning folder: {}", folder.display());
68 }
69 let scan = scan_folder(folder, opts.verbose)?;
70
71 for artifact in scan
73 .artifacts
74 .iter()
75 .filter(|a| a.kind == ArtifactKind::MalperAnalyse)
76 {
77 println!(
78 "[.] Ignoring Malper analyse report (by design): {}",
79 artifact.path.display()
80 );
81 }
82
83 let accepted: Vec<&Artifact> = scan
84 .artifacts
85 .iter()
86 .filter(|a| a.kind != ArtifactKind::MalperAnalyse)
87 .collect();
88
89 if accepted.is_empty() {
90 return Err(AppError::Message(format!(
91 "no Malper artifacts found in '{}' ({} file(s) rejected)",
92 folder.display(),
93 scan.rejected.len()
94 )));
95 }
96
97 let mut infos = Vec::new();
101 for artifact in &accepted {
102 infos.push((artifact, extract_info(artifact)?));
103 }
104
105 let explicit_ids: Vec<String> = infos
106 .iter()
107 .filter_map(|(_, info)| info.run_id.clone())
108 .collect();
109 if explicit_ids.len() > 1 {
110 let mut unique: Vec<String> = Vec::new();
111 for id in &explicit_ids {
112 if !unique.contains(id) {
113 unique.push(id.clone());
114 }
115 }
116 if unique.len() > 1 {
117 return Err(AppError::Message(format!(
118 "mixed run: multiple explicit run ids found ({})",
119 unique.join(", ")
120 )));
121 }
122 }
123
124 let primary = infos
126 .iter()
127 .find(|(a, _)| a.kind == ArtifactKind::NetMalperGraph)
128 .or_else(|| {
129 infos
130 .iter()
131 .find(|(a, _)| a.kind == ArtifactKind::VulnMalperJson)
132 })
133 .or_else(|| infos.first())
134 .map(|(_, info)| info.target.clone())
135 .unwrap_or_default();
136
137 let folder_hint = folder
138 .file_name()
139 .map(|s| s.to_string_lossy().to_lowercase())
140 .unwrap_or_default();
141
142 for (artifact, info) in &infos {
143 if !target_compatible(&primary, &info.target, &folder_hint) {
144 return Err(AppError::Message(format!(
145 "mixed/incompatible run: {} targets '{}' but run target is '{}'",
146 artifact.path.display(),
147 info.target,
148 primary
149 )));
150 }
151 }
152
153 let run_id = if explicit_ids.is_empty() {
154 let combined = content_hash(&accepted);
155 format!("run_{}", sha256_hex(&format!("{}:{}", primary, combined)))
156 } else {
157 explicit_ids[0].clone()
158 };
159 let content_hash = content_hash(&accepted);
160
161 let started_at = infos
162 .iter()
163 .filter_map(|(_, info)| info.timestamp.as_deref())
164 .filter_map(rfc3339)
165 .min()
166 .map(|dt| dt.to_string());
167
168 let mut summary = IngestSummary {
169 run_id: run_id.clone(),
170 target: primary.clone(),
171 artifacts_accepted: accepted.len(),
172 artifacts_rejected: scan.rejected.len(),
173 dry_run: opts.dry_run,
174 ..Default::default()
175 };
176
177 if opts.dry_run {
178 println!("[dry-run] would import {} artifact(s):", accepted.len());
179 for artifact in &accepted {
180 println!(
181 " - {} ({})",
182 artifact.path.display(),
183 artifact.kind.label()
184 );
185 }
186 println!(
187 "[dry-run] run_id={} target={} content_hash={}",
188 run_id, primary, content_hash
189 );
190 return Ok(summary);
191 }
192
193 let backend = if opts.backend == "auto" {
197 config.backend.as_str()
198 } else {
199 opts.backend.as_str()
200 };
201 let mut storage = crate::db::open_storage_with(
202 config,
203 backend,
204 opts.pocketbase_url.as_deref(),
205 opts.sqlite_path.as_deref(),
206 )?;
207
208 if !storage.ping()? {
209 return Err(AppError::Message(format!(
210 "{} backend is not reachable",
211 storage.kind()
212 )));
213 }
214 storage.ensure_schema()?;
215
216 if let Some(run) = storage.get_scan_run(&run_id)? {
217 if !opts.force {
218 if run.content_hash == content_hash {
219 println!(
220 "[+] Run {} already imported (target={}); skipping (use --force to re-import)",
221 run_id, primary
222 );
223 summary.skipped_already_imported = true;
224 return Ok(summary);
225 }
226 return Err(AppError::Message(format!(
227 "run {} already exists with different content (target={}); use --force to re-import",
228 run_id, primary
229 )));
230 }
231 println!("[+] Re-importing run {} (--force)", run_id);
232 }
233
234 let prev_assets: HashMap<String, Asset> = storage
236 .list_assets()?
237 .into_iter()
238 .map(|a| (a.stable_id.clone(), a))
239 .collect();
240 let prev_services: HashMap<String, Service> = storage
241 .list_all_services()?
242 .into_iter()
243 .map(|s| (s.stable_id.clone(), s))
244 .collect();
245 let prev_findings: HashMap<String, Finding> = storage
246 .list_all_findings()?
247 .into_iter()
248 .map(|f| (f.stable_id.clone(), f))
249 .collect();
250
251 let mut ctx = BuildContext {
255 assets: HashMap::new(),
256 services: HashMap::new(),
257 findings: HashMap::new(),
258 report_content_paths: HashMap::new(),
259 has_vulnmalper_json: false,
260 };
261
262 for artifact in &accepted {
263 match artifact.kind {
264 ArtifactKind::NetMalperGraph => import_netmalper(artifact, &mut ctx)?,
265 ArtifactKind::VulnMalperJson => {
266 ctx.has_vulnmalper_json = true;
267 import_vulnmalper_json(artifact, &mut ctx)?;
268 }
269 ArtifactKind::VulnMalperMarkdown => import_vulnmalper_markdown(artifact, &mut ctx)?,
270 ArtifactKind::PloitMalperReport => import_ploitmalper_report(artifact, &mut ctx)?,
271 ArtifactKind::MalperAnalyse => {}
272 }
273 }
274
275 let mut observations = Vec::new();
279
280 let mut asset_observations =
281 commit_assets(&mut storage, &run_id, &mut ctx.assets, &prev_assets)?;
282 observations.append(&mut asset_observations);
283 summary.assets = ctx.assets.len();
284
285 let mut service_observations =
286 commit_services(&mut storage, &run_id, &mut ctx.services, &prev_services)?;
287 observations.append(&mut service_observations);
288 summary.services = ctx.services.len();
289
290 let mut finding_observations =
291 commit_findings(&mut storage, &run_id, &mut ctx.findings, &prev_findings)?;
292 observations.append(&mut finding_observations);
293 summary.findings = ctx.findings.len();
294
295 for observation in &observations {
296 storage.add_observation(observation)?;
297 }
298 summary.observations = observations.len();
299
300 let tools = accepted
304 .iter()
305 .map(|a| tool_for_kind(a.kind))
306 .filter(|t| !t.is_empty())
307 .map(str::to_string)
308 .collect::<HashSet<_>>();
309 let mut tools: Vec<String> = tools.into_iter().collect();
310 tools.sort();
311 tools.dedup();
312
313 let artifact_refs: Vec<Value> = accepted
314 .iter()
315 .map(|a| {
316 let mut entry = json!({
317 "path": a.path.display().to_string(),
318 "kind": a.kind.label(),
319 "size": a.size,
320 "hash": a.hash,
321 });
322 if let Some(rel) = ctx.report_content_paths.get(&a.hash) {
323 entry["content_path"] = json!(rel);
324 entry["tool"] = json!(tool_for_kind(a.kind));
325 }
326 entry
327 })
328 .collect();
329
330 let mut run = ScanRun::new(
331 &run_id,
332 &primary,
333 &folder.display().to_string(),
334 &content_hash,
335 started_at,
336 );
337 run.tools = tools;
338 run.artifacts = artifact_refs;
339 run.finished_at = Some(now_utc());
340 run.stats = json!({
341 "assets": summary.assets,
342 "services": summary.services,
343 "findings": summary.findings,
344 "observations": summary.observations,
345 });
346 storage.upsert_scan_run(&run)?;
347
348 if opts.verbose {
349 println!("[+] Imported run {} (target={})", run_id, primary);
350 println!(
351 " assets={} services={} findings={} observations={}",
352 summary.assets, summary.services, summary.findings, summary.observations
353 );
354 }
355
356 Ok(summary)
357}
358
359struct ArtifactInfo {
364 target: String,
365 timestamp: Option<String>,
366 run_id: Option<String>,
367}
368
369fn extract_info(artifact: &Artifact) -> Result<ArtifactInfo> {
370 match artifact.kind {
371 ArtifactKind::NetMalperGraph | ArtifactKind::VulnMalperJson => {
372 let value: Value = serde_json::from_str(&artifact.content)?;
373 let object = value
374 .as_object()
375 .ok_or_else(|| AppError::Message("artifact JSON is not an object".to_string()))?;
376
377 if artifact.kind == ArtifactKind::NetMalperGraph {
378 let meta = object
379 .get("meta")
380 .and_then(Value::as_object)
381 .ok_or_else(|| AppError::Message("netmalper graph missing meta".to_string()))?;
382 Ok(ArtifactInfo {
383 target: meta
384 .get("target")
385 .and_then(Value::as_str)
386 .unwrap_or_default()
387 .to_string(),
388 timestamp: meta
389 .get("timestamp")
390 .and_then(Value::as_str)
391 .map(str::to_string),
392 run_id: explicit_run_id(object),
393 })
394 } else {
395 let vuln = object
396 .get("vulnmalper")
397 .and_then(Value::as_object)
398 .ok_or_else(|| {
399 AppError::Message("vulnmalper json missing metadata".to_string())
400 })?;
401 Ok(ArtifactInfo {
402 target: vuln
403 .get("source_target")
404 .and_then(Value::as_str)
405 .unwrap_or_default()
406 .to_string(),
407 timestamp: vuln
408 .get("generated_utc")
409 .and_then(Value::as_str)
410 .map(str::to_string),
411 run_id: explicit_run_id(object),
412 })
413 }
414 }
415 ArtifactKind::VulnMalperMarkdown => Ok(ArtifactInfo {
416 target: md_kv(&artifact.content, "**Target:**").unwrap_or_default(),
417 timestamp: md_kv(&artifact.content, "**Generated:**"),
418 run_id: None,
419 }),
420 ArtifactKind::PloitMalperReport => {
421 let target = artifact
422 .content
423 .lines()
424 .find(|l| l.contains("**Host:**"))
425 .and_then(|l| l.split("**Host:**").nth(1))
426 .and_then(extract_backtick_or_trim)
427 .unwrap_or_default();
428 let timestamp = artifact
429 .content
430 .lines()
431 .find(|l| l.contains("**Generated:**"))
432 .and_then(|l| l.split("**Generated:**").nth(1))
433 .map(|rest| rest.trim().trim_matches('`').trim().to_string());
434 Ok(ArtifactInfo {
435 target,
436 timestamp,
437 run_id: None,
438 })
439 }
440 ArtifactKind::MalperAnalyse => Ok(ArtifactInfo {
441 target: String::new(),
442 timestamp: None,
443 run_id: None,
444 }),
445 }
446}
447
448fn explicit_run_id(object: &serde_json::Map<String, Value>) -> Option<String> {
449 object
450 .get("run_id")
451 .or_else(|| object.get("runId"))
452 .and_then(Value::as_str)
453 .filter(|s| !s.is_empty())
454 .map(str::to_string)
455}
456
457fn md_kv(content: &str, marker: &str) -> Option<String> {
460 let line = content.lines().find(|l| l.contains(marker))?;
461 let rest = line.split(marker).nth(1)?;
462 extract_backtick_or_trim(rest)
463}
464
465fn extract_backtick_or_trim(rest: &str) -> Option<String> {
466 let rest = rest.trim();
467 if let Some(start) = rest.find('`') {
468 let after = &rest[start + 1..];
469 if let Some(end) = after.find('`') {
470 return Some(after[..end].trim().to_string());
471 }
472 return Some(after.trim().to_string());
473 }
474 if rest.is_empty() {
475 return None;
476 }
477 let value = rest.split('|').next().unwrap_or(rest).trim();
478 Some(value.to_string())
479}
480
481fn rfc3339(value: &str) -> Option<OffsetDateTime> {
484 let trimmed = value.trim();
485 if let Ok(dt) = OffsetDateTime::parse(trimmed, &Rfc3339) {
486 return Some(dt);
487 }
488 let normalised = trimmed.replace(" UTC", "Z").replace(' ', "T");
489 if let Ok(dt) = OffsetDateTime::parse(&normalised, &Rfc3339) {
490 return Some(dt);
491 }
492 if let Ok(epoch) = trimmed.parse::<i64>() {
493 if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch) {
494 return Some(dt);
495 }
496 }
497 None
498}
499
500fn content_hash(artifacts: &[&Artifact]) -> String {
501 let mut hashes: Vec<&str> = artifacts.iter().map(|a| a.hash.as_str()).collect();
502 hashes.sort_unstable();
503 sha256_hex(&hashes.join("|"))
504}
505
506fn target_compatible(primary: &str, other: &str, folder_hint: &str) -> bool {
507 let primary = normalize_target(primary);
508 let other = normalize_target(other);
509 if primary.is_empty() || other.is_empty() {
510 return true;
511 }
512 if primary == other {
513 return true;
514 }
515
516 if is_ip(&primary) || is_ip(&other) {
519 return false;
520 }
521
522 let primary_labels: Vec<&str> = primary.split('.').collect();
524 let other_labels: Vec<&str> = other.split('.').collect();
525 let subdomain = |a: &[&str], b: &[&str]| a.len() >= 2 && b.len() >= 2 && a.ends_with(b);
526 if subdomain(&primary_labels, &other_labels) || subdomain(&other_labels, &primary_labels) {
527 return true;
528 }
529
530 if !folder_hint.is_empty()
532 && (primary == folder_hint
533 || other == folder_hint
534 || primary.ends_with(&format!(".{}", folder_hint))
535 || other.ends_with(&format!(".{}", folder_hint)))
536 {
537 return true;
538 }
539
540 false
541}
542
543fn normalize_target(value: &str) -> String {
544 let lower = value.trim().to_lowercase();
545 let stripped = lower
546 .strip_prefix("https://")
547 .or_else(|| lower.strip_prefix("http://"))
548 .unwrap_or(&lower);
549 let host = stripped.trim_end_matches('/');
550
551 if host.parse::<std::net::Ipv6Addr>().is_err() {
553 if let Some(colon) = host.rfind(':') {
554 let after = &host[colon + 1..];
555 if !after.is_empty() && after.chars().all(|c| c.is_ascii_digit()) {
556 return host[..colon].to_string();
557 }
558 }
559 }
560 host.to_string()
561}
562
563fn is_ip(value: &str) -> bool {
564 value.parse::<std::net::Ipv4Addr>().is_ok() || value.parse::<std::net::Ipv6Addr>().is_ok()
565}
566
567fn classify_target(value: &str) -> &'static str {
568 let host = normalize_target(value);
569 if host.parse::<std::net::Ipv4Addr>().is_ok() || host.parse::<std::net::Ipv6Addr>().is_ok() {
570 "ip"
571 } else if host.split('.').count() > 1 {
572 "domain"
573 } else {
574 "hostname"
575 }
576}
577
578fn tool_for_kind(kind: ArtifactKind) -> &'static str {
580 match kind {
581 ArtifactKind::NetMalperGraph => "netmalper",
582 ArtifactKind::VulnMalperJson | ArtifactKind::VulnMalperMarkdown => "vulnmalper",
583 ArtifactKind::PloitMalperReport => "ploitmalper",
584 ArtifactKind::MalperAnalyse => "",
585 }
586}
587
588fn store_report(ctx: &mut BuildContext, artifact: &Artifact) -> Result<()> {
591 let (_, rel) = content::store_content(&artifact.content)?;
592 ctx.report_content_paths.insert(artifact.hash.clone(), rel);
593 Ok(())
594}
595
596fn service_id(asset_id: &str, port: u16, protocol: &str) -> String {
597 sha256_hex(&format!("service:{}:{}:{}", asset_id, port, protocol))
598}
599
600fn new_service(asset_id: &str, port: u16, protocol: &str, name: &str) -> Service {
601 let mut service = Service::new(asset_id, port, protocol, name);
602 service.stable_id = service_id(asset_id, port, protocol);
603 service
604}
605
606fn push_endpoint(metadata: &mut Value, url: &str) {
607 if !metadata.is_object() {
608 *metadata = json!({});
609 }
610 if let Some(arr) = metadata["endpoints"].as_array_mut() {
611 if !arr.iter().any(|v| v.as_str() == Some(url)) {
612 arr.push(json!(url));
613 }
614 } else {
615 metadata["endpoints"] = json!([url]);
616 }
617}
618
619struct BuildContext {
624 assets: HashMap<String, Asset>,
625 services: HashMap<String, Service>,
626 findings: HashMap<String, Finding>,
627 report_content_paths: HashMap<String, String>,
629 has_vulnmalper_json: bool,
630}
631
632impl BuildContext {
633 fn ensure_asset(&mut self, name: &str) {
634 if !self
635 .assets
636 .contains_key(&sha256_hex(&format!("asset:{}", name)))
637 {
638 let asset = Asset::new(name, classify_target(name));
639 self.assets.insert(asset.stable_id.clone(), asset);
640 }
641 }
642
643 fn ensure_service(&mut self, asset_id: &str, port: u16, protocol: &str, name: &str) {
644 let key = service_id(asset_id, port, protocol);
645 self.services
646 .entry(key)
647 .or_insert_with(|| new_service(asset_id, port, protocol, name));
648 }
649}
650
651fn import_netmalper(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
652 let value: Value = serde_json::from_str(&artifact.content)?;
653 let object = value
654 .as_object()
655 .ok_or_else(|| AppError::Message("netmalper graph: not an object".to_string()))?;
656 let meta = object
657 .get("meta")
658 .and_then(Value::as_object)
659 .ok_or_else(|| AppError::Message("netmalper graph: missing meta".to_string()))?;
660 let target = meta
661 .get("target")
662 .and_then(Value::as_str)
663 .ok_or_else(|| AppError::Message("netmalper graph: missing target".to_string()))?
664 .to_string();
665
666 let nodes = object
667 .get("nodes")
668 .and_then(Value::as_array)
669 .ok_or_else(|| AppError::Message("netmalper graph: missing nodes".to_string()))?;
670
671 let mut root = Asset::new(&target, classify_target(&target));
673 if let Some(fqdn) = meta.get("fqdn").and_then(Value::as_str) {
674 root.fqdn = Some(fqdn.to_string());
675 }
676 let root_id = root.stable_id.clone();
677 ctx.assets.insert(root.stable_id.clone(), root);
678
679 for node in nodes {
681 let data = node.get("data").cloned().unwrap_or_else(|| json!({}));
682 match node.get("type").and_then(Value::as_str).unwrap_or_default() {
683 "ip" => {
684 if let Some(ip) = data.get("ip").and_then(Value::as_str) {
685 if ip != target {
686 ctx.ensure_asset(ip);
687 let asset_id = sha256_hex(&format!("asset:{}", ip));
688 if let Some(asset) = ctx.assets.get_mut(&asset_id) {
689 if let Some(reverse_dns) =
690 data.get("reverse_dns").and_then(Value::as_str)
691 {
692 if reverse_dns != ip {
693 asset.reverse_dns = Some(reverse_dns.to_string());
694 }
695 }
696 }
697 } else if let Some(asset) = ctx.assets.get_mut(&root_id) {
698 asset.ip = Some(ip.to_string());
699 if let Some(reverse_dns) = data.get("reverse_dns").and_then(Value::as_str) {
700 if reverse_dns != ip {
701 asset.reverse_dns = Some(reverse_dns.to_string());
702 }
703 }
704 }
705 }
706 }
707 "port" => {
708 let port = data.get("port").and_then(Value::as_u64).unwrap_or(0) as u16;
709 let host = data
710 .get("host")
711 .and_then(Value::as_str)
712 .unwrap_or(&target)
713 .to_string();
714 ctx.ensure_asset(&host);
715 let asset_id = sha256_hex(&format!("asset:{}", host));
716 let protocol = data
717 .get("protocol")
718 .and_then(Value::as_str)
719 .unwrap_or("tcp")
720 .to_string();
721 let service_name = data
722 .get("service")
723 .and_then(Value::as_str)
724 .unwrap_or("unknown")
725 .to_string();
726 ctx.ensure_service(&asset_id, port, &protocol, &service_name);
727 let key = service_id(&asset_id, port, &protocol);
728 if let Some(service) = ctx.services.get_mut(&key) {
729 if let Some(product) = data.get("product").and_then(Value::as_str) {
730 if !product.is_empty() {
731 service.product = Some(product.to_string());
732 }
733 }
734 if let Some(version) = data.get("version").and_then(Value::as_str) {
735 if !version.is_empty() {
736 service.version = Some(version.to_string());
737 }
738 }
739 if let Some(version_str) = data.get("version_str").and_then(Value::as_str) {
740 if !version_str.is_empty() {
741 service.version_str = Some(version_str.to_string());
742 }
743 }
744 if let Some(cpes) = data.get("cpe").and_then(Value::as_array) {
745 for cpe in cpes.iter().filter_map(Value::as_str) {
746 if !service.cpes.iter().any(|c| c == cpe) {
747 service.cpes.push(cpe.to_string());
748 }
749 }
750 }
751 }
752 }
753 "endpoint" => {
754 if let Some(url) = data.get("url").and_then(Value::as_str) {
755 let host = normalize_target(url);
756 ctx.ensure_asset(&host);
757 let asset_id = sha256_hex(&format!("asset:{}", host));
758 if let Some(asset) = ctx.assets.get_mut(&asset_id) {
759 push_endpoint(&mut asset.metadata, url);
760 }
761 if let Some(port) = port_from_host_url(url, data.get("port")) {
763 let protocol = "tcp";
764 ctx.ensure_service(&asset_id, port, protocol, "http");
765 let key = service_id(&asset_id, port, protocol);
766 if let Some(service) = ctx.services.get_mut(&key) {
767 push_endpoint(&mut service.metadata, url);
768 if let Some(status) = data.get("status").and_then(Value::as_u64) {
769 service.metadata["http_status"] = json!(status);
770 }
771 }
772 }
773 }
774 }
775 "nse_finding" => {
776 let script_id = data
777 .get("script_id")
778 .and_then(Value::as_str)
779 .unwrap_or("nse");
780 let host = data
781 .get("host")
782 .and_then(Value::as_str)
783 .unwrap_or(&target)
784 .to_string();
785 ctx.ensure_asset(&host);
786 let asset_id = sha256_hex(&format!("asset:{}", host));
787 let port = data.get("port").and_then(Value::as_u64).unwrap_or(0) as u16;
788 let output = data
789 .get("output")
790 .and_then(Value::as_str)
791 .unwrap_or_default();
792
793 let mut finding =
794 Finding::new(&asset_id, &format!("nmap/{}", script_id), script_id);
795 finding.severity = "info".to_string();
796 if !output.is_empty() {
797 let (detail, detail_path) = content::store_or_excerpt(output)?;
798 finding.detail = Some(detail);
799 finding.detail_path = detail_path;
800 }
801 let protocol = "tcp";
802 ctx.ensure_service(&asset_id, port, protocol, "unknown");
803 let service_key = service_id(&asset_id, port, protocol);
804 finding.service_id = Some(service_key.clone());
805 if let Some(service) = ctx.services.get_mut(&service_key) {
806 if service.banner.is_none() && !output.is_empty() {
807 service.banner = Some(truncate(output, 2048));
808 }
809 }
810 ctx.findings.insert(finding.stable_id.clone(), finding);
811 }
812 _ => {}
813 }
814 }
815
816 Ok(())
817}
818
819fn import_vulnmalper_json(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
820 let value: Value = serde_json::from_str(&artifact.content)?;
821 let object = value
822 .as_object()
823 .ok_or_else(|| AppError::Message("vulnmalper json: not an object".to_string()))?;
824 let hosts = object
825 .get("hosts")
826 .and_then(Value::as_array)
827 .cloned()
828 .unwrap_or_default();
829
830 for host in hosts {
831 let host_obj = match host.as_object() {
832 Some(h) => h,
833 None => continue,
834 };
835 let hostname = host_obj
836 .get("host")
837 .and_then(Value::as_str)
838 .map(str::to_string)
839 .or_else(|| {
840 host_obj
841 .get("url")
842 .and_then(Value::as_str)
843 .map(normalize_target)
844 .filter(|s| !s.is_empty())
845 })
846 .unwrap_or_default();
847 if hostname.is_empty() {
848 continue;
849 }
850
851 ctx.ensure_asset(&hostname);
852 let asset_id = sha256_hex(&format!("asset:{}", hostname));
853
854 let port = host_obj.get("port").and_then(Value::as_u64).unwrap_or(0) as u16;
855 let scheme = host_obj
856 .get("scheme")
857 .and_then(Value::as_str)
858 .unwrap_or("http");
859 let service_name = host_obj
860 .get("service")
861 .and_then(Value::as_str)
862 .filter(|s| !s.is_empty())
863 .unwrap_or(scheme)
864 .to_string();
865 let url = host_obj
866 .get("url")
867 .and_then(Value::as_str)
868 .unwrap_or_default()
869 .to_string();
870
871 ctx.ensure_service(&asset_id, port, "tcp", &service_name);
873 let service_key = service_id(&asset_id, port, "tcp");
874 if let Some(service) = ctx.services.get_mut(&service_key) {
875 if service.service_name == "unknown" || service.service_name.is_empty() {
876 service.service_name = service_name.clone();
877 }
878 if let Some(product) = host_obj.get("product").and_then(Value::as_str) {
879 if !product.is_empty() {
880 service.product = Some(product.to_string());
881 }
882 }
883 if let Some(tech) = host_obj.get("tech").and_then(Value::as_array) {
884 for t in tech.iter().filter_map(Value::as_str) {
885 if !service.technologies.iter().any(|x| x == t) {
886 service.technologies.push(t.to_string());
887 }
888 }
889 }
890 if let Some(alive) = host_obj.get("alive").and_then(Value::as_bool) {
891 service.metadata["alive"] = json!(alive);
892 }
893 if let Some(status) = host_obj.get("status").and_then(Value::as_u64) {
894 service.metadata["http_status"] = json!(status);
895 }
896 if host_obj.get("waf").is_some() {
897 service.metadata["waf"] = host_obj["waf"].clone();
898 }
899 if let Some(injectable) = host_obj.get("injectable").and_then(Value::as_array) {
900 service.metadata["injectable"] = json!(injectable);
901 }
902 if let Some(ep) = host_obj.get("crawl_new_endpoints").and_then(Value::as_u64) {
903 service.metadata["crawl_new_endpoints"] = json!(ep);
904 }
905 if !url.is_empty() {
906 push_endpoint(&mut service.metadata, &url);
907 }
908 }
909
910 let findings = host_obj
912 .get("findings")
913 .and_then(Value::as_array)
914 .cloned()
915 .unwrap_or_default();
916 for finding_value in findings {
917 let fobj = match finding_value.as_object() {
918 Some(f) => f,
919 None => continue,
920 };
921 let tool = fobj
922 .get("tool")
923 .and_then(Value::as_str)
924 .unwrap_or("unknown");
925 let title = fobj
926 .get("title")
927 .and_then(Value::as_str)
928 .unwrap_or("unnamed finding");
929 let mut finding = Finding::new(&asset_id, tool, title);
930 finding.severity = fobj
931 .get("severity")
932 .and_then(Value::as_str)
933 .unwrap_or("info")
934 .to_lowercase();
935 finding.exploitability =
936 Some(exploitability_from_severity(&finding.severity).to_string());
937 finding.target_url = fobj
938 .get("target")
939 .and_then(Value::as_str)
940 .map(str::to_string)
941 .or_else(|| {
942 if url.is_empty() {
943 None
944 } else {
945 Some(url.clone())
946 }
947 });
948 finding.detail = fobj
949 .get("detail")
950 .and_then(Value::as_str)
951 .filter(|s| !s.is_empty())
952 .map(content::store_or_excerpt)
953 .transpose()?
954 .map(|(detail, detail_path)| {
955 finding.detail_path = detail_path;
956 detail
957 });
958 finding.reference = fobj
959 .get("reference")
960 .and_then(Value::as_str)
961 .map(str::to_string)
962 .filter(|s| !s.is_empty());
963 finding.service_id = Some(service_key.clone());
964 if !url.is_empty() {
965 finding.endpoints.push(url.clone());
966 }
967 ctx.findings.insert(finding.stable_id.clone(), finding);
968 }
969 }
970
971 Ok(())
972}
973
974fn import_vulnmalper_markdown(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
975 store_report(ctx, artifact)?;
977
978 if ctx.has_vulnmalper_json {
980 return Ok(());
981 }
982
983 let target = md_kv(&artifact.content, "**Target:**").unwrap_or_default();
984 if target.is_empty() {
985 return Ok(());
986 }
987 ctx.ensure_asset(&target);
988 let asset_id = sha256_hex(&format!("asset:{}", target));
989
990 let mut current_url = String::new();
991 let mut in_summary = false;
992 for line in artifact.content.lines() {
993 let line = line.trim();
994 if let Some(rest) = line.strip_prefix("### 🌐 `") {
995 current_url = rest.trim_end_matches('`').trim().to_string();
996 in_summary = false;
997 continue;
998 }
999 if line.starts_with("#### Summary") {
1000 in_summary = true;
1001 continue;
1002 }
1003 if line.starts_with("#### ") || line.starts_with("<details") {
1004 in_summary = false;
1005 continue;
1006 }
1007 if !in_summary || !line.starts_with('|') {
1008 continue;
1009 }
1010 let cells: Vec<&str> = line.split('|').collect();
1011 if cells.len() < 5 {
1013 continue;
1014 }
1015 if cells.iter().any(|c| {
1016 c.contains("Severity")
1017 || c.contains("Tool")
1018 || c.contains("Title")
1019 || c.contains("---")
1020 || c.trim() == "#"
1021 }) {
1022 continue;
1023 }
1024 let number = cells.get(1).map(|c| c.trim()).unwrap_or("");
1025 let severity_cell = cells.get(2).map(|c| c.trim()).unwrap_or("");
1026 let tool_cell = cells.get(3).map(|c| c.trim()).unwrap_or("");
1027 if number.is_empty() || severity_cell.is_empty() {
1028 continue;
1029 }
1030 let severity = severity_cell
1031 .split("**")
1032 .nth(1)
1033 .map(|s| s.trim().to_lowercase())
1034 .filter(|s| !s.is_empty())
1035 .unwrap_or_else(|| "info".to_string());
1036 let tool = tool_cell.split('`').nth(1).unwrap_or("unknown").to_string();
1037 let title = cells[4..].join("|").trim().to_string();
1038 if title.is_empty() {
1039 continue;
1040 }
1041
1042 let mut finding = Finding::new(&asset_id, &tool, &title);
1043 finding.severity = severity.clone();
1044 finding.exploitability = Some(exploitability_from_severity(&severity).to_string());
1045 if !current_url.is_empty() {
1046 finding.target_url = Some(current_url.clone());
1047 finding.endpoints.push(current_url.clone());
1048 }
1049 ctx.findings.insert(finding.stable_id.clone(), finding);
1050 }
1051
1052 Ok(())
1053}
1054
1055fn import_ploitmalper_report(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
1056 store_report(ctx, artifact)?;
1058
1059 let target = artifact
1060 .content
1061 .lines()
1062 .find(|l| l.contains("**Host:**"))
1063 .and_then(|l| l.split("**Host:**").nth(1))
1064 .and_then(extract_backtick_or_trim)
1065 .unwrap_or_default();
1066 if target.is_empty() {
1067 return Ok(());
1068 }
1069
1070 ctx.ensure_asset(&target);
1071 let asset_id = sha256_hex(&format!("asset:{}", target));
1072 if let Some(asset) = ctx.assets.get_mut(&asset_id) {
1073 let risk_score = artifact
1074 .content
1075 .lines()
1076 .find(|l| l.starts_with("## Overall Risk"))
1077 .and_then(|l| l.split_terminator(':').nth(1))
1078 .and_then(|r| r.trim().split('/').next())
1079 .and_then(|v| v.trim().parse::<u64>().ok());
1080 let enrichment = match asset.metadata.get_mut("ploitmalper") {
1081 Some(Value::Object(map)) => map,
1082 _ => {
1083 asset.metadata["ploitmalper"] = json!({});
1084 asset.metadata["ploitmalper"].as_object_mut().unwrap()
1085 }
1086 };
1087 if let Some(score) = risk_score {
1088 enrichment.insert("risk_score".to_string(), json!(score));
1089 enrichment.insert(
1090 "risk_label".to_string(),
1091 json!(risk_label_from_score(score)),
1092 );
1093 }
1094 }
1095
1096 Ok(())
1097}
1098
1099struct ObsSpec {
1104 kind: &'static str,
1105 before: Value,
1106 after: Value,
1107 detail: String,
1108}
1109
1110fn commit_assets(
1111 storage: &mut Box<dyn Storage>,
1112 run_id: &str,
1113 new: &mut HashMap<String, Asset>,
1114 prev: &HashMap<String, Asset>,
1115) -> Result<Vec<Observation>> {
1116 let mut observations = Vec::new();
1117 for (id, asset) in new.iter_mut() {
1118 match prev.get(id) {
1119 Some(old) => {
1120 asset.first_seen = old.first_seen.clone();
1121 asset.last_seen = now_utc();
1122 for spec in diff_asset(old, asset) {
1123 observations.push(Observation::new(
1124 run_id,
1125 "asset",
1126 id,
1127 spec.kind,
1128 spec.before,
1129 spec.after,
1130 &spec.detail,
1131 ));
1132 }
1133 storage.upsert_asset(asset)?;
1134 }
1135 None => {
1136 observations.push(Observation::new(
1137 run_id,
1138 "asset",
1139 id,
1140 "asset_discovered",
1141 Value::Null,
1142 json!(asset),
1143 "asset discovered in this run",
1144 ));
1145 storage.upsert_asset(asset)?;
1146 }
1147 }
1148 }
1149 for (id, old) in prev {
1150 if !new.contains_key(id) {
1151 let mut removed = old.clone();
1152 removed.status = ASSET_REMOVED.to_string();
1153 storage.upsert_asset(&removed)?;
1154 observations.push(Observation::new(
1155 run_id,
1156 "asset",
1157 id,
1158 "asset_removed",
1159 json!(old),
1160 Value::Null,
1161 "asset no longer present in this run",
1162 ));
1163 }
1164 }
1165 Ok(observations)
1166}
1167
1168fn commit_services(
1169 storage: &mut Box<dyn Storage>,
1170 run_id: &str,
1171 new: &mut HashMap<String, Service>,
1172 prev: &HashMap<String, Service>,
1173) -> Result<Vec<Observation>> {
1174 let mut observations = Vec::new();
1175 for (id, service) in new.iter_mut() {
1176 match prev.get(id) {
1177 Some(old) => {
1178 service.first_seen = old.first_seen.clone();
1179 service.last_seen = now_utc();
1180 for spec in diff_service(old, service) {
1181 observations.push(Observation::new(
1182 run_id,
1183 "service",
1184 id,
1185 spec.kind,
1186 spec.before,
1187 spec.after,
1188 &spec.detail,
1189 ));
1190 }
1191 storage.upsert_service(service)?;
1192 }
1193 None => {
1194 observations.push(Observation::new(
1195 run_id,
1196 "service",
1197 id,
1198 "service_discovered",
1199 Value::Null,
1200 json!(service),
1201 "service discovered in this run",
1202 ));
1203 storage.upsert_service(service)?;
1204 }
1205 }
1206 }
1207 for (id, old) in prev {
1208 if !new.contains_key(id) {
1209 let mut removed = old.clone();
1210 removed.status = ASSET_REMOVED.to_string();
1211 storage.upsert_service(&removed)?;
1212 observations.push(Observation::new(
1213 run_id,
1214 "service",
1215 id,
1216 "service_removed",
1217 json!(old),
1218 Value::Null,
1219 "service no longer present in this run",
1220 ));
1221 }
1222 }
1223 Ok(observations)
1224}
1225
1226fn commit_findings(
1227 storage: &mut Box<dyn Storage>,
1228 run_id: &str,
1229 new: &mut HashMap<String, Finding>,
1230 prev: &HashMap<String, Finding>,
1231) -> Result<Vec<Observation>> {
1232 let mut observations = Vec::new();
1233 for (id, finding) in new.iter_mut() {
1234 match prev.get(id) {
1235 Some(old) => {
1236 finding.first_seen = old.first_seen.clone();
1237 finding.last_seen = now_utc();
1238 for spec in diff_finding(old, finding) {
1239 observations.push(Observation::new(
1240 run_id,
1241 "finding",
1242 id,
1243 spec.kind,
1244 spec.before,
1245 spec.after,
1246 &spec.detail,
1247 ));
1248 }
1249 storage.upsert_finding(finding)?;
1250 }
1251 None => {
1252 observations.push(Observation::new(
1253 run_id,
1254 "finding",
1255 id,
1256 "finding_discovered",
1257 Value::Null,
1258 json!(finding),
1259 "finding discovered in this run",
1260 ));
1261 storage.upsert_finding(finding)?;
1262 }
1263 }
1264 }
1265 for (id, old) in prev {
1266 if !new.contains_key(id) {
1267 let mut removed = old.clone();
1268 removed.status = ASSET_REMOVED.to_string();
1269 storage.upsert_finding(&removed)?;
1270 observations.push(Observation::new(
1271 run_id,
1272 "finding",
1273 id,
1274 "finding_removed",
1275 json!(old),
1276 Value::Null,
1277 "finding no longer present in this run",
1278 ));
1279 }
1280 }
1281 Ok(observations)
1282}
1283
1284fn diff_asset(old: &Asset, new: &Asset) -> Vec<ObsSpec> {
1285 let mut out = Vec::new();
1286 if old.status != new.status {
1287 out.push(ObsSpec {
1288 kind: "asset_changed",
1289 before: json!(old.status),
1290 after: json!(new.status),
1291 detail: format!("status {} -> {}", old.status, new.status),
1292 });
1293 }
1294 if old.ip != new.ip {
1295 out.push(ObsSpec {
1296 kind: "asset_changed",
1297 before: json!(old.ip),
1298 after: json!(new.ip),
1299 detail: format!("ip {:?} -> {:?}", old.ip, new.ip),
1300 });
1301 }
1302 if old.fqdn != new.fqdn {
1303 out.push(ObsSpec {
1304 kind: "asset_changed",
1305 before: json!(old.fqdn),
1306 after: json!(new.fqdn),
1307 detail: format!("fqdn {:?} -> {:?}", old.fqdn, new.fqdn),
1308 });
1309 }
1310 if old.reverse_dns != new.reverse_dns {
1311 out.push(ObsSpec {
1312 kind: "asset_changed",
1313 before: json!(old.reverse_dns),
1314 after: json!(new.reverse_dns),
1315 detail: format!("reverse_dns {:?} -> {:?}", old.reverse_dns, new.reverse_dns),
1316 });
1317 }
1318 out
1319}
1320
1321fn diff_service(old: &Service, new: &Service) -> Vec<ObsSpec> {
1322 let mut out = Vec::new();
1323 if old.status != new.status {
1324 out.push(ObsSpec {
1325 kind: "service_changed",
1326 before: json!(old.status),
1327 after: json!(new.status),
1328 detail: format!("status {} -> {}", old.status, new.status),
1329 });
1330 }
1331 if old.product != new.product
1332 || old.version != new.version
1333 || old.version_str != new.version_str
1334 || old.banner != new.banner
1335 || old.service_name != new.service_name
1336 {
1337 out.push(ObsSpec {
1338 kind: "service_changed",
1339 before: json!({
1340 "service_name": old.service_name,
1341 "product": old.product,
1342 "version": old.version,
1343 "version_str": old.version_str,
1344 "banner": old.banner,
1345 }),
1346 after: json!({
1347 "service_name": new.service_name,
1348 "product": new.product,
1349 "version": new.version,
1350 "version_str": new.version_str,
1351 "banner": new.banner,
1352 }),
1353 detail: format!("service {} (port {}) changed", new.service_name, new.port),
1354 });
1355 }
1356 let old_tech: HashSet<&String> = old.technologies.iter().collect();
1357 let new_tech: HashSet<&String> = new.technologies.iter().collect();
1358 if old_tech != new_tech {
1359 out.push(ObsSpec {
1360 kind: "technology_changed",
1361 before: json!(old.technologies),
1362 after: json!(new.technologies),
1363 detail: format!(
1364 "technologies changed on service {}:{}",
1365 new.asset_id, new.port
1366 ),
1367 });
1368 }
1369 out
1370}
1371
1372fn diff_finding(old: &Finding, new: &Finding) -> Vec<ObsSpec> {
1373 let mut out = Vec::new();
1374 if old.status != new.status {
1375 out.push(ObsSpec {
1376 kind: "finding_changed",
1377 before: json!(old.status),
1378 after: json!(new.status),
1379 detail: format!("status {} -> {}", old.status, new.status),
1380 });
1381 }
1382 if old.severity != new.severity {
1383 out.push(ObsSpec {
1384 kind: "severity_changed",
1385 before: json!(old.severity),
1386 after: json!(new.severity),
1387 detail: format!(
1388 "severity {} -> {} for '{}'",
1389 old.severity, new.severity, new.title
1390 ),
1391 });
1392 }
1393 if old.exploitability != new.exploitability {
1394 out.push(ObsSpec {
1395 kind: "exploitability_changed",
1396 before: json!(old.exploitability),
1397 after: json!(new.exploitability),
1398 detail: format!(
1399 "exploitability {:?} -> {:?} for '{}'",
1400 old.exploitability, new.exploitability, new.title
1401 ),
1402 });
1403 }
1404 if old.title != new.title
1405 || old.detail != new.detail
1406 || old.detail_path != new.detail_path
1407 || old.reference != new.reference
1408 || old.target_url != new.target_url
1409 {
1410 out.push(ObsSpec {
1411 kind: "finding_changed",
1412 before: json!({
1413 "title": old.title,
1414 "detail": old.detail,
1415 "detail_path": old.detail_path,
1416 "reference": old.reference,
1417 "target_url": old.target_url,
1418 }),
1419 after: json!({
1420 "title": new.title,
1421 "detail": new.detail,
1422 "detail_path": new.detail_path,
1423 "reference": new.reference,
1424 "target_url": new.target_url,
1425 }),
1426 detail: format!("finding '{}' changed", new.title),
1427 });
1428 }
1429 out
1430}
1431
1432fn exploitability_from_severity(severity: &str) -> &'static str {
1437 match severity {
1438 "critical" | "high" => "likely",
1439 "medium" => "possible",
1440 "low" => "unlikely",
1441 _ => "none",
1442 }
1443}
1444
1445fn risk_label_from_score(score: u64) -> &'static str {
1446 match score {
1447 0..=29 => "LOW RISK",
1448 30..=69 => "MEDIUM RISK",
1449 70..=89 => "HIGH RISK",
1450 _ => "CRITICAL RISK",
1451 }
1452}
1453
1454fn truncate(value: &str, max_chars: usize) -> String {
1455 if value.chars().count() <= max_chars {
1456 return value.to_string();
1457 }
1458 let mut out: String = value.chars().take(max_chars).collect();
1459 out.push('…');
1460 out
1461}
1462
1463fn port_from_host_url(url: &str, fallback: Option<&Value>) -> Option<u16> {
1464 if let Some(v) = fallback.and_then(Value::as_u64) {
1465 if v > 0 && v <= 65535 {
1466 return Some(v as u16);
1467 }
1468 }
1469 let host_part = url.split("://").nth(1)?;
1470 let host_port = host_part.split('/').next()?;
1471 if let Some(colon) = host_port.rfind(':') {
1472 if let Some(port_str) = host_port.get(colon + 1..) {
1473 if let Ok(port) = port_str.parse::<u16>() {
1474 if port > 0 {
1475 return Some(port);
1476 }
1477 }
1478 }
1479 }
1480 None
1481}
1482
1483#[cfg(test)]
1484mod tests {
1485 use super::*;
1486
1487 #[test]
1488 fn target_compatibility() {
1489 assert!(target_compatible("192.168.1.14", "192.168.1.14", ""));
1490 assert!(target_compatible(
1491 "http://192.168.1.14:8080/",
1492 "192.168.1.14",
1493 ""
1494 ));
1495 assert!(target_compatible(
1496 "example.com",
1497 "https://www.example.com",
1498 ""
1499 ));
1500 assert!(!target_compatible("10.0.0.1", "192.168.1.14", ""));
1501 assert!(!target_compatible("192.168.1.14", "192.168.1.1", ""));
1503 assert!(target_compatible(
1505 "192.168.1.14",
1506 "192.168.1.14",
1507 "ingest-test"
1508 ));
1509 assert!(target_compatible(
1510 "example.com",
1511 "api.example.com",
1512 "example.com"
1513 ));
1514 }
1515
1516 #[test]
1517 fn rfc3339_variants() {
1518 assert!(rfc3339("2026-08-01T07:39:22Z").is_some());
1519 assert!(rfc3339("2026-08-01 15:05:04 UTC").is_some());
1520 assert!(rfc3339("1785665136").is_some());
1521 assert!(rfc3339("garbage").is_none());
1522 }
1523
1524 #[test]
1525 fn md_kv_extraction() {
1526 let content = "> **Target:** `192.168.1.14` | **Generated:** 2026-08-01 15:05:04 UTC | **Engine:** VulnMalper v8.0.0";
1527 assert_eq!(
1528 md_kv(content, "**Target:**").as_deref(),
1529 Some("192.168.1.14")
1530 );
1531 assert_eq!(
1532 md_kv(content, "**Generated:**").as_deref(),
1533 Some("2026-08-01 15:05:04 UTC")
1534 );
1535 }
1536
1537 #[test]
1538 fn service_id_stable() {
1539 let asset_id = "a";
1540 assert_eq!(
1541 service_id(asset_id, 80, "tcp"),
1542 service_id(asset_id, 80, "tcp")
1543 );
1544 assert_ne!(
1545 service_id(asset_id, 80, "tcp"),
1546 service_id(asset_id, 81, "tcp")
1547 );
1548 }
1549
1550 #[test]
1551 fn port_from_host_url_parses() {
1552 assert_eq!(
1553 port_from_host_url("http://192.168.1.14:8080/", None),
1554 Some(8080)
1555 );
1556 assert_eq!(port_from_host_url("https://example.com/path", None), None);
1557 let port_value = json!(22);
1558 assert_eq!(port_from_host_url("x", Some(&port_value)), Some(22));
1559 }
1560
1561 #[test]
1562 fn severity_exploitability_map() {
1563 assert_eq!(exploitability_from_severity("critical"), "likely");
1564 assert_eq!(exploitability_from_severity("info"), "none");
1565 }
1566}