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