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
623fn correlate_service(
634 ctx: &mut BuildContext,
635 asset_id: &str,
636 target_url: Option<&str>,
637 fallback_port: u16,
638 fallback_name: &str,
639) -> Option<String> {
640 let endpoint_port =
641 target_url.and_then(|url| crate::engine::dedup::parse_endpoint_parts(url).port);
642 let port = endpoint_port.or(if fallback_port == 0 {
643 None
644 } else {
645 Some(fallback_port)
646 })?;
647
648 let base_name = if let Some(url) = target_url {
649 let parts = crate::engine::dedup::parse_endpoint_parts(url);
650 if !parts.scheme.is_empty() {
651 parts.scheme
652 } else if !fallback_name.is_empty() {
653 fallback_name.to_string()
654 } else {
655 "http".to_string()
656 }
657 } else if !fallback_name.is_empty() {
658 fallback_name.to_string()
659 } else {
660 "http".to_string()
661 };
662
663 ctx.ensure_service(asset_id, port, "tcp", &base_name);
664 let key = service_id(asset_id, port, "tcp");
665 if let Some(service) = ctx.services.get_mut(&key) {
666 if service.service_name == "unknown" || service.service_name.is_empty() {
667 service.service_name = base_name;
668 }
669 }
670 Some(key)
671}
672
673#[derive(Default)]
678struct BuildContext {
679 assets: HashMap<String, Asset>,
680 services: HashMap<String, Service>,
681 findings: HashMap<String, Finding>,
682 report_content_paths: HashMap<String, String>,
684 has_vulnmalper_json: bool,
685}
686
687impl BuildContext {
688 fn ensure_asset(&mut self, name: &str) {
689 if !self
690 .assets
691 .contains_key(&sha256_hex(&format!("asset:{}", name)))
692 {
693 let asset = Asset::new(name, classify_target(name));
694 self.assets.insert(asset.stable_id.clone(), asset);
695 }
696 }
697
698 fn ensure_service(&mut self, asset_id: &str, port: u16, protocol: &str, name: &str) {
699 let key = service_id(asset_id, port, protocol);
700 self.services
701 .entry(key)
702 .or_insert_with(|| new_service(asset_id, port, protocol, name));
703 }
704}
705
706fn import_netmalper(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
707 let value: Value = serde_json::from_str(&artifact.content)?;
708 let object = value
709 .as_object()
710 .ok_or_else(|| AppError::Message("netmalper graph: not an object".to_string()))?;
711 let meta = object
712 .get("meta")
713 .and_then(Value::as_object)
714 .ok_or_else(|| AppError::Message("netmalper graph: missing meta".to_string()))?;
715 let target = meta
716 .get("target")
717 .and_then(Value::as_str)
718 .ok_or_else(|| AppError::Message("netmalper graph: missing target".to_string()))?
719 .to_string();
720
721 let nodes = object
722 .get("nodes")
723 .and_then(Value::as_array)
724 .ok_or_else(|| AppError::Message("netmalper graph: missing nodes".to_string()))?;
725
726 let mut root = Asset::new(&target, classify_target(&target));
728 if let Some(fqdn) = meta.get("fqdn").and_then(Value::as_str) {
729 root.fqdn = Some(fqdn.to_string());
730 }
731 let root_id = root.stable_id.clone();
732 ctx.assets.insert(root.stable_id.clone(), root);
733
734 for node in nodes {
736 let data = node.get("data").cloned().unwrap_or_else(|| json!({}));
737 match node.get("type").and_then(Value::as_str).unwrap_or_default() {
738 "ip" => {
739 if let Some(ip) = data.get("ip").and_then(Value::as_str) {
740 if ip != target {
741 ctx.ensure_asset(ip);
742 let asset_id = sha256_hex(&format!("asset:{}", ip));
743 if let Some(asset) = ctx.assets.get_mut(&asset_id) {
744 if let Some(reverse_dns) =
745 data.get("reverse_dns").and_then(Value::as_str)
746 {
747 if reverse_dns != ip {
748 asset.reverse_dns = Some(reverse_dns.to_string());
749 }
750 }
751 }
752 } else if let Some(asset) = ctx.assets.get_mut(&root_id) {
753 asset.ip = Some(ip.to_string());
754 if let Some(reverse_dns) = data.get("reverse_dns").and_then(Value::as_str) {
755 if reverse_dns != ip {
756 asset.reverse_dns = Some(reverse_dns.to_string());
757 }
758 }
759 }
760 }
761 }
762 "port" => {
763 let port = data.get("port").and_then(Value::as_u64).unwrap_or(0) as u16;
764 let host = data
765 .get("host")
766 .and_then(Value::as_str)
767 .unwrap_or(&target)
768 .to_string();
769 ctx.ensure_asset(&host);
770 let asset_id = sha256_hex(&format!("asset:{}", host));
771 let protocol = data
772 .get("protocol")
773 .and_then(Value::as_str)
774 .unwrap_or("tcp")
775 .to_string();
776 let service_name = data
777 .get("service")
778 .and_then(Value::as_str)
779 .unwrap_or("unknown")
780 .to_string();
781 ctx.ensure_service(&asset_id, port, &protocol, &service_name);
782 let key = service_id(&asset_id, port, &protocol);
783 if let Some(service) = ctx.services.get_mut(&key) {
784 if let Some(product) = data.get("product").and_then(Value::as_str) {
785 if !product.is_empty() {
786 service.product = Some(product.to_string());
787 }
788 }
789 if let Some(version) = data.get("version").and_then(Value::as_str) {
790 if !version.is_empty() {
791 service.version = Some(version.to_string());
792 }
793 }
794 if let Some(version_str) = data.get("version_str").and_then(Value::as_str) {
795 if !version_str.is_empty() {
796 service.version_str = Some(version_str.to_string());
797 }
798 }
799 if let Some(cpes) = data.get("cpe").and_then(Value::as_array) {
800 for cpe in cpes.iter().filter_map(Value::as_str) {
801 if !service.cpes.iter().any(|c| c == cpe) {
802 service.cpes.push(cpe.to_string());
803 }
804 }
805 }
806 }
807 }
808 "endpoint" => {
809 if let Some(url) = data.get("url").and_then(Value::as_str) {
810 let host = normalize_target(url);
811 ctx.ensure_asset(&host);
812 let asset_id = sha256_hex(&format!("asset:{}", host));
813 if let Some(asset) = ctx.assets.get_mut(&asset_id) {
814 push_endpoint(&mut asset.metadata, url);
815 }
816 if let Some(port) = port_from_host_url(url, data.get("port")) {
818 let protocol = "tcp";
819 ctx.ensure_service(&asset_id, port, protocol, "http");
820 let key = service_id(&asset_id, port, protocol);
821 if let Some(service) = ctx.services.get_mut(&key) {
822 push_endpoint(&mut service.metadata, url);
823 if let Some(status) = data.get("status").and_then(Value::as_u64) {
824 service.metadata["http_status"] = json!(status);
825 }
826 }
827 }
828 }
829 }
830 "nse_finding" => {
831 let script_id = data
832 .get("script_id")
833 .and_then(Value::as_str)
834 .unwrap_or("nse");
835 let host = data
836 .get("host")
837 .and_then(Value::as_str)
838 .unwrap_or(&target)
839 .to_string();
840 ctx.ensure_asset(&host);
841 let asset_id = sha256_hex(&format!("asset:{}", host));
842 let port = data.get("port").and_then(Value::as_u64).unwrap_or(0) as u16;
843 let output = data
844 .get("output")
845 .and_then(Value::as_str)
846 .unwrap_or_default();
847
848 let mut finding =
849 Finding::new(&asset_id, &format!("nmap/{}", script_id), script_id);
850 finding.severity = "info".to_string();
851 if !output.is_empty() {
852 let (detail, detail_path) = content::store_or_excerpt(output)?;
853 finding.detail = Some(detail);
854 finding.detail_path = detail_path;
855 }
856 let protocol = "tcp";
857 ctx.ensure_service(&asset_id, port, protocol, "unknown");
858 let service_key = service_id(&asset_id, port, protocol);
859 finding.service_id = Some(service_key.clone());
860 if let Some(service) = ctx.services.get_mut(&service_key) {
861 if service.banner.is_none() && !output.is_empty() {
862 service.banner = Some(truncate(output, 2048));
863 }
864 }
865 ctx.findings.insert(finding.stable_id.clone(), finding);
866 }
867 _ => {}
868 }
869 }
870
871 Ok(())
872}
873
874fn import_vulnmalper_json(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
875 let value: Value = serde_json::from_str(&artifact.content)?;
876 let object = value
877 .as_object()
878 .ok_or_else(|| AppError::Message("vulnmalper json: not an object".to_string()))?;
879 let hosts = object
880 .get("hosts")
881 .and_then(Value::as_array)
882 .cloned()
883 .unwrap_or_default();
884
885 for host in hosts {
886 let host_obj = match host.as_object() {
887 Some(h) => h,
888 None => continue,
889 };
890 let hostname = host_obj
891 .get("host")
892 .and_then(Value::as_str)
893 .map(str::to_string)
894 .or_else(|| {
895 host_obj
896 .get("url")
897 .and_then(Value::as_str)
898 .map(normalize_target)
899 .filter(|s| !s.is_empty())
900 })
901 .unwrap_or_default();
902 if hostname.is_empty() {
903 continue;
904 }
905
906 ctx.ensure_asset(&hostname);
907 let asset_id = sha256_hex(&format!("asset:{}", hostname));
908
909 let port = host_obj.get("port").and_then(Value::as_u64).unwrap_or(0) as u16;
910 let scheme = host_obj
911 .get("scheme")
912 .and_then(Value::as_str)
913 .unwrap_or("http");
914 let service_name = host_obj
915 .get("service")
916 .and_then(Value::as_str)
917 .filter(|s| !s.is_empty())
918 .unwrap_or(scheme)
919 .to_string();
920 let url = host_obj
921 .get("url")
922 .and_then(Value::as_str)
923 .unwrap_or_default()
924 .to_string();
925
926 ctx.ensure_service(&asset_id, port, "tcp", &service_name);
928 let service_key = service_id(&asset_id, port, "tcp");
929 if let Some(service) = ctx.services.get_mut(&service_key) {
930 if service.service_name == "unknown" || service.service_name.is_empty() {
931 service.service_name = service_name.clone();
932 }
933 if let Some(product) = host_obj.get("product").and_then(Value::as_str) {
934 if !product.is_empty() {
935 service.product = Some(product.to_string());
936 }
937 }
938 if let Some(tech) = host_obj.get("tech").and_then(Value::as_array) {
939 for t in tech.iter().filter_map(Value::as_str) {
940 if !service.technologies.iter().any(|x| x == t) {
941 service.technologies.push(t.to_string());
942 }
943 }
944 }
945 if let Some(alive) = host_obj.get("alive").and_then(Value::as_bool) {
946 service.metadata["alive"] = json!(alive);
947 }
948 if let Some(status) = host_obj.get("status").and_then(Value::as_u64) {
949 service.metadata["http_status"] = json!(status);
950 }
951 if host_obj.get("waf").is_some() {
952 service.metadata["waf"] = host_obj["waf"].clone();
953 }
954 if let Some(injectable) = host_obj.get("injectable").and_then(Value::as_array) {
955 service.metadata["injectable"] = json!(injectable);
956 }
957 if let Some(ep) = host_obj.get("crawl_new_endpoints").and_then(Value::as_u64) {
958 service.metadata["crawl_new_endpoints"] = json!(ep);
959 }
960 if !url.is_empty() {
961 push_endpoint(&mut service.metadata, &url);
962 }
963 }
964
965 let findings = host_obj
967 .get("findings")
968 .and_then(Value::as_array)
969 .cloned()
970 .unwrap_or_default();
971 for finding_value in findings {
972 let fobj = match finding_value.as_object() {
973 Some(f) => f,
974 None => continue,
975 };
976 let tool = fobj
977 .get("tool")
978 .and_then(Value::as_str)
979 .unwrap_or("unknown");
980 let title = fobj
981 .get("title")
982 .and_then(Value::as_str)
983 .unwrap_or("unnamed finding");
984 let mut finding = Finding::new(&asset_id, tool, title);
985 finding.severity = fobj
986 .get("severity")
987 .and_then(Value::as_str)
988 .unwrap_or("info")
989 .to_lowercase();
990 finding.exploitability =
991 Some(exploitability_from_severity(&finding.severity).to_string());
992 finding.target_url = fobj
993 .get("target")
994 .and_then(Value::as_str)
995 .map(str::to_string)
996 .or_else(|| {
997 if url.is_empty() {
998 None
999 } else {
1000 Some(url.clone())
1001 }
1002 });
1003 finding.detail = fobj
1004 .get("detail")
1005 .and_then(Value::as_str)
1006 .filter(|s| !s.is_empty())
1007 .map(content::store_or_excerpt)
1008 .transpose()?
1009 .map(|(detail, detail_path)| {
1010 finding.detail_path = detail_path;
1011 detail
1012 });
1013 finding.reference = fobj
1014 .get("reference")
1015 .and_then(Value::as_str)
1016 .map(str::to_string)
1017 .filter(|s| !s.is_empty());
1018 let finding_url = finding
1022 .target_url
1023 .clone()
1024 .or_else(|| (!url.is_empty()).then(|| url.clone()));
1025 if let Some(service_key) =
1026 correlate_service(ctx, &asset_id, finding_url.as_deref(), port, &service_name)
1027 {
1028 finding.service_id = Some(service_key);
1029 }
1030 if !url.is_empty() {
1031 finding.endpoints.push(url.clone());
1032 }
1033 ctx.findings.insert(finding.stable_id.clone(), finding);
1034 }
1035 }
1036
1037 Ok(())
1038}
1039
1040fn import_vulnmalper_markdown(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
1041 store_report(ctx, artifact)?;
1043
1044 if ctx.has_vulnmalper_json {
1046 return Ok(());
1047 }
1048
1049 let target = md_kv(&artifact.content, "**Target:**").unwrap_or_default();
1050 if target.is_empty() {
1051 return Ok(());
1052 }
1053 ctx.ensure_asset(&target);
1054 let asset_id = sha256_hex(&format!("asset:{}", target));
1055
1056 let mut current_url = String::new();
1057 let mut in_summary = false;
1058 for line in artifact.content.lines() {
1059 let line = line.trim();
1060 if let Some(rest) = line.strip_prefix("### 🌐 `") {
1061 current_url = rest.trim_end_matches('`').trim().to_string();
1062 in_summary = false;
1063 continue;
1064 }
1065 if line.starts_with("#### Summary") {
1066 in_summary = true;
1067 continue;
1068 }
1069 if line.starts_with("#### ") || line.starts_with("<details") {
1070 in_summary = false;
1071 continue;
1072 }
1073 if !in_summary || !line.starts_with('|') {
1074 continue;
1075 }
1076 let cells: Vec<&str> = line.split('|').collect();
1077 if cells.len() < 5 {
1079 continue;
1080 }
1081 if cells.iter().any(|c| {
1082 c.contains("Severity")
1083 || c.contains("Tool")
1084 || c.contains("Title")
1085 || c.contains("---")
1086 || c.trim() == "#"
1087 }) {
1088 continue;
1089 }
1090 let number = cells.get(1).map(|c| c.trim()).unwrap_or("");
1091 let severity_cell = cells.get(2).map(|c| c.trim()).unwrap_or("");
1092 let tool_cell = cells.get(3).map(|c| c.trim()).unwrap_or("");
1093 if number.is_empty() || severity_cell.is_empty() {
1094 continue;
1095 }
1096 let severity = severity_cell
1097 .split("**")
1098 .nth(1)
1099 .map(|s| s.trim().to_lowercase())
1100 .filter(|s| !s.is_empty())
1101 .unwrap_or_else(|| "info".to_string());
1102 let tool = tool_cell.split('`').nth(1).unwrap_or("unknown").to_string();
1103 let title = cells[4..].join("|").trim().to_string();
1104 if title.is_empty() {
1105 continue;
1106 }
1107
1108 let mut finding = Finding::new(&asset_id, &tool, &title);
1109 finding.severity = severity.clone();
1110 finding.exploitability = Some(exploitability_from_severity(&severity).to_string());
1111 if !current_url.is_empty() {
1112 finding.target_url = Some(current_url.clone());
1113 finding.endpoints.push(current_url.clone());
1114 }
1115 if let Some(service_key) = correlate_service(
1116 ctx,
1117 &asset_id,
1118 if current_url.is_empty() {
1119 None
1120 } else {
1121 Some(current_url.as_str())
1122 },
1123 0,
1124 "http",
1125 ) {
1126 finding.service_id = Some(service_key);
1127 }
1128 ctx.findings.insert(finding.stable_id.clone(), finding);
1129 }
1130
1131 Ok(())
1132}
1133
1134fn import_ploitmalper_report(artifact: &Artifact, ctx: &mut BuildContext) -> Result<()> {
1135 store_report(ctx, artifact)?;
1137
1138 let target = artifact
1139 .content
1140 .lines()
1141 .find(|l| l.contains("**Host:**"))
1142 .and_then(|l| l.split("**Host:**").nth(1))
1143 .and_then(extract_backtick_or_trim)
1144 .unwrap_or_default();
1145 if target.is_empty() {
1146 return Ok(());
1147 }
1148
1149 ctx.ensure_asset(&target);
1150 let asset_id = sha256_hex(&format!("asset:{}", target));
1151 if let Some(asset) = ctx.assets.get_mut(&asset_id) {
1152 let risk_score = artifact
1153 .content
1154 .lines()
1155 .find(|l| l.starts_with("## Overall Risk"))
1156 .and_then(|l| l.split_terminator(':').nth(1))
1157 .and_then(|r| r.trim().split('/').next())
1158 .and_then(|v| v.trim().parse::<u64>().ok());
1159 let enrichment = match asset.metadata.get_mut("ploitmalper") {
1160 Some(Value::Object(map)) => map,
1161 _ => {
1162 asset.metadata["ploitmalper"] = json!({});
1163 asset.metadata["ploitmalper"].as_object_mut().unwrap()
1164 }
1165 };
1166 if let Some(score) = risk_score {
1167 enrichment.insert("risk_score".to_string(), json!(score));
1168 enrichment.insert(
1169 "risk_label".to_string(),
1170 json!(risk_label_from_score(score)),
1171 );
1172 }
1173 }
1174
1175 for finding in injectable_findings(&artifact.content, &asset_id) {
1178 ctx.findings.insert(finding.stable_id.clone(), finding);
1179 }
1180
1181 Ok(())
1182}
1183
1184fn injectable_findings(content: &str, asset_id: &str) -> Vec<Finding> {
1187 let mut findings = Vec::new();
1188 let mut in_section = false;
1189 for line in content.lines() {
1190 let trimmed = line.trim();
1191 if trimmed.starts_with("## 4. Injectable Endpoints") {
1192 in_section = true;
1193 continue;
1194 }
1195 if in_section && trimmed.starts_with("## ") {
1196 break;
1197 }
1198 if !in_section || !trimmed.starts_with('|') {
1199 continue;
1200 }
1201 let cells: Vec<&str> = trimmed.split('|').collect();
1202 let endpoint_cell = match cells.get(2) {
1203 Some(cell) => cell.trim(),
1204 None => continue,
1205 };
1206 let endpoint = endpoint_cell.trim_matches('`').trim();
1207 if endpoint.is_empty()
1208 || endpoint == "Endpoint"
1209 || endpoint.starts_with('-')
1210 || !endpoint.starts_with("http")
1211 {
1212 continue;
1213 }
1214 let endpoint = endpoint.to_string();
1215 let mut finding = Finding::new(
1216 asset_id,
1217 "ploitmalper",
1218 &format!("Injectable endpoint: {}", endpoint),
1219 );
1220 finding.severity = "high".to_string();
1221 finding.exploitability = Some("likely".to_string());
1222 finding.target_url = Some(endpoint.clone());
1223 finding.endpoints.push(endpoint);
1224 findings.push(finding);
1225 }
1226 findings
1227}
1228
1229struct ObsSpec {
1234 kind: &'static str,
1235 before: Value,
1236 after: Value,
1237 detail: String,
1238}
1239
1240fn commit_assets(
1241 storage: &mut Box<dyn Storage>,
1242 run_id: &str,
1243 new: &mut HashMap<String, Asset>,
1244 prev: &HashMap<String, Asset>,
1245) -> Result<Vec<Observation>> {
1246 let mut observations = Vec::new();
1247 for (id, asset) in new.iter_mut() {
1248 match prev.get(id) {
1249 Some(old) => {
1250 asset.first_seen = old.first_seen.clone();
1251 asset.last_seen = now_utc();
1252 for spec in diff_asset(old, asset) {
1253 observations.push(Observation::new(
1254 run_id,
1255 "asset",
1256 id,
1257 spec.kind,
1258 spec.before,
1259 spec.after,
1260 &spec.detail,
1261 ));
1262 }
1263 storage.upsert_asset(asset)?;
1264 }
1265 None => {
1266 observations.push(Observation::new(
1267 run_id,
1268 "asset",
1269 id,
1270 "asset_discovered",
1271 Value::Null,
1272 json!(asset),
1273 "asset discovered in this run",
1274 ));
1275 storage.upsert_asset(asset)?;
1276 }
1277 }
1278 }
1279 for (id, old) in prev {
1280 if !new.contains_key(id) {
1281 let mut removed = old.clone();
1282 removed.status = ASSET_REMOVED.to_string();
1283 storage.upsert_asset(&removed)?;
1284 observations.push(Observation::new(
1285 run_id,
1286 "asset",
1287 id,
1288 "asset_removed",
1289 json!(old),
1290 Value::Null,
1291 "asset no longer present in this run",
1292 ));
1293 }
1294 }
1295 Ok(observations)
1296}
1297
1298fn commit_services(
1299 storage: &mut Box<dyn Storage>,
1300 run_id: &str,
1301 new: &mut HashMap<String, Service>,
1302 prev: &HashMap<String, Service>,
1303) -> Result<Vec<Observation>> {
1304 let mut observations = Vec::new();
1305 for (id, service) in new.iter_mut() {
1306 match prev.get(id) {
1307 Some(old) => {
1308 service.first_seen = old.first_seen.clone();
1309 service.last_seen = now_utc();
1310 for spec in diff_service(old, service) {
1311 observations.push(Observation::new(
1312 run_id,
1313 "service",
1314 id,
1315 spec.kind,
1316 spec.before,
1317 spec.after,
1318 &spec.detail,
1319 ));
1320 }
1321 storage.upsert_service(service)?;
1322 }
1323 None => {
1324 observations.push(Observation::new(
1325 run_id,
1326 "service",
1327 id,
1328 "service_discovered",
1329 Value::Null,
1330 json!(service),
1331 "service discovered in this run",
1332 ));
1333 storage.upsert_service(service)?;
1334 }
1335 }
1336 }
1337 for (id, old) in prev {
1338 if !new.contains_key(id) {
1339 let mut removed = old.clone();
1340 removed.status = ASSET_REMOVED.to_string();
1341 storage.upsert_service(&removed)?;
1342 observations.push(Observation::new(
1343 run_id,
1344 "service",
1345 id,
1346 "service_removed",
1347 json!(old),
1348 Value::Null,
1349 "service no longer present in this run",
1350 ));
1351 }
1352 }
1353 Ok(observations)
1354}
1355
1356fn commit_findings(
1357 storage: &mut Box<dyn Storage>,
1358 run_id: &str,
1359 new: &mut HashMap<String, Finding>,
1360 prev: &HashMap<String, Finding>,
1361) -> Result<Vec<Observation>> {
1362 let mut observations = Vec::new();
1363 for (id, finding) in new.iter_mut() {
1364 match prev.get(id) {
1365 Some(old) => {
1366 finding.first_seen = old.first_seen.clone();
1367 finding.last_seen = now_utc();
1368 for spec in diff_finding(old, finding) {
1369 observations.push(Observation::new(
1370 run_id,
1371 "finding",
1372 id,
1373 spec.kind,
1374 spec.before,
1375 spec.after,
1376 &spec.detail,
1377 ));
1378 }
1379 storage.upsert_finding(finding)?;
1380 }
1381 None => {
1382 observations.push(Observation::new(
1383 run_id,
1384 "finding",
1385 id,
1386 "finding_discovered",
1387 Value::Null,
1388 json!(finding),
1389 "finding discovered in this run",
1390 ));
1391 storage.upsert_finding(finding)?;
1392 }
1393 }
1394 }
1395 for (id, old) in prev {
1396 if !new.contains_key(id) {
1397 let mut removed = old.clone();
1398 removed.status = ASSET_REMOVED.to_string();
1399 storage.upsert_finding(&removed)?;
1400 observations.push(Observation::new(
1401 run_id,
1402 "finding",
1403 id,
1404 "finding_removed",
1405 json!(old),
1406 Value::Null,
1407 "finding no longer present in this run",
1408 ));
1409 }
1410 }
1411 Ok(observations)
1412}
1413
1414fn diff_asset(old: &Asset, new: &Asset) -> Vec<ObsSpec> {
1415 let mut out = Vec::new();
1416 if old.status != new.status {
1417 out.push(ObsSpec {
1418 kind: "asset_changed",
1419 before: json!(old.status),
1420 after: json!(new.status),
1421 detail: format!("status {} -> {}", old.status, new.status),
1422 });
1423 }
1424 if old.ip != new.ip {
1425 out.push(ObsSpec {
1426 kind: "asset_changed",
1427 before: json!(old.ip),
1428 after: json!(new.ip),
1429 detail: format!("ip {:?} -> {:?}", old.ip, new.ip),
1430 });
1431 }
1432 if old.fqdn != new.fqdn {
1433 out.push(ObsSpec {
1434 kind: "asset_changed",
1435 before: json!(old.fqdn),
1436 after: json!(new.fqdn),
1437 detail: format!("fqdn {:?} -> {:?}", old.fqdn, new.fqdn),
1438 });
1439 }
1440 if old.reverse_dns != new.reverse_dns {
1441 out.push(ObsSpec {
1442 kind: "asset_changed",
1443 before: json!(old.reverse_dns),
1444 after: json!(new.reverse_dns),
1445 detail: format!("reverse_dns {:?} -> {:?}", old.reverse_dns, new.reverse_dns),
1446 });
1447 }
1448 out
1449}
1450
1451fn diff_service(old: &Service, new: &Service) -> Vec<ObsSpec> {
1452 let mut out = Vec::new();
1453 if old.status != new.status {
1454 out.push(ObsSpec {
1455 kind: "service_changed",
1456 before: json!(old.status),
1457 after: json!(new.status),
1458 detail: format!("status {} -> {}", old.status, new.status),
1459 });
1460 }
1461 if old.product != new.product
1462 || old.version != new.version
1463 || old.version_str != new.version_str
1464 || old.banner != new.banner
1465 || old.service_name != new.service_name
1466 {
1467 out.push(ObsSpec {
1468 kind: "service_changed",
1469 before: json!({
1470 "service_name": old.service_name,
1471 "product": old.product,
1472 "version": old.version,
1473 "version_str": old.version_str,
1474 "banner": old.banner,
1475 }),
1476 after: json!({
1477 "service_name": new.service_name,
1478 "product": new.product,
1479 "version": new.version,
1480 "version_str": new.version_str,
1481 "banner": new.banner,
1482 }),
1483 detail: format!("service {} (port {}) changed", new.service_name, new.port),
1484 });
1485 }
1486 let old_tech: HashSet<&String> = old.technologies.iter().collect();
1487 let new_tech: HashSet<&String> = new.technologies.iter().collect();
1488 if old_tech != new_tech {
1489 out.push(ObsSpec {
1490 kind: "technology_changed",
1491 before: json!(old.technologies),
1492 after: json!(new.technologies),
1493 detail: format!(
1494 "technologies changed on service {}:{}",
1495 new.asset_id, new.port
1496 ),
1497 });
1498 }
1499 out
1500}
1501
1502fn diff_finding(old: &Finding, new: &Finding) -> Vec<ObsSpec> {
1503 let mut out = Vec::new();
1504 if old.status != new.status {
1505 out.push(ObsSpec {
1506 kind: "finding_changed",
1507 before: json!(old.status),
1508 after: json!(new.status),
1509 detail: format!("status {} -> {}", old.status, new.status),
1510 });
1511 }
1512 if old.severity != new.severity {
1513 out.push(ObsSpec {
1514 kind: "severity_changed",
1515 before: json!(old.severity),
1516 after: json!(new.severity),
1517 detail: format!(
1518 "severity {} -> {} for '{}'",
1519 old.severity, new.severity, new.title
1520 ),
1521 });
1522 }
1523 if old.exploitability != new.exploitability {
1524 out.push(ObsSpec {
1525 kind: "exploitability_changed",
1526 before: json!(old.exploitability),
1527 after: json!(new.exploitability),
1528 detail: format!(
1529 "exploitability {:?} -> {:?} for '{}'",
1530 old.exploitability, new.exploitability, new.title
1531 ),
1532 });
1533 }
1534 if old.title != new.title
1535 || old.detail != new.detail
1536 || old.detail_path != new.detail_path
1537 || old.reference != new.reference
1538 || old.target_url != new.target_url
1539 {
1540 out.push(ObsSpec {
1541 kind: "finding_changed",
1542 before: json!({
1543 "title": old.title,
1544 "detail": old.detail,
1545 "detail_path": old.detail_path,
1546 "reference": old.reference,
1547 "target_url": old.target_url,
1548 }),
1549 after: json!({
1550 "title": new.title,
1551 "detail": new.detail,
1552 "detail_path": new.detail_path,
1553 "reference": new.reference,
1554 "target_url": new.target_url,
1555 }),
1556 detail: format!("finding '{}' changed", new.title),
1557 });
1558 }
1559 out
1560}
1561
1562fn exploitability_from_severity(severity: &str) -> &'static str {
1567 match severity {
1568 "critical" | "high" => "likely",
1569 "medium" => "possible",
1570 "low" => "unlikely",
1571 _ => "none",
1572 }
1573}
1574
1575fn risk_label_from_score(score: u64) -> &'static str {
1576 match score {
1577 0..=29 => "LOW RISK",
1578 30..=69 => "MEDIUM RISK",
1579 70..=89 => "HIGH RISK",
1580 _ => "CRITICAL RISK",
1581 }
1582}
1583
1584fn truncate(value: &str, max_chars: usize) -> String {
1585 if value.chars().count() <= max_chars {
1586 return value.to_string();
1587 }
1588 let mut out: String = value.chars().take(max_chars).collect();
1589 out.push('…');
1590 out
1591}
1592
1593fn port_from_host_url(url: &str, fallback: Option<&Value>) -> Option<u16> {
1594 if let Some(v) = fallback.and_then(Value::as_u64) {
1595 if v > 0 && v <= 65535 {
1596 return Some(v as u16);
1597 }
1598 }
1599 let host_part = url.split("://").nth(1)?;
1600 let host_port = host_part.split('/').next()?;
1601 if let Some(colon) = host_port.rfind(':') {
1602 if let Some(port_str) = host_port.get(colon + 1..) {
1603 if let Ok(port) = port_str.parse::<u16>() {
1604 if port > 0 {
1605 return Some(port);
1606 }
1607 }
1608 }
1609 }
1610 None
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615 use super::*;
1616 use std::path::PathBuf;
1617
1618 #[test]
1619 fn target_compatibility() {
1620 assert!(target_compatible("192.168.1.14", "192.168.1.14", ""));
1621 assert!(target_compatible(
1622 "http://192.168.1.14:8080/",
1623 "192.168.1.14",
1624 ""
1625 ));
1626 assert!(target_compatible(
1627 "example.com",
1628 "https://www.example.com",
1629 ""
1630 ));
1631 assert!(!target_compatible("10.0.0.1", "192.168.1.14", ""));
1632 assert!(!target_compatible("192.168.1.14", "192.168.1.1", ""));
1634 assert!(target_compatible(
1636 "192.168.1.14",
1637 "192.168.1.14",
1638 "ingest-test"
1639 ));
1640 assert!(target_compatible(
1641 "example.com",
1642 "api.example.com",
1643 "example.com"
1644 ));
1645 }
1646
1647 #[test]
1648 fn rfc3339_variants() {
1649 assert!(rfc3339("2026-08-01T07:39:22Z").is_some());
1650 assert!(rfc3339("2026-08-01 15:05:04 UTC").is_some());
1651 assert!(rfc3339("1785665136").is_some());
1652 assert!(rfc3339("garbage").is_none());
1653 }
1654
1655 #[test]
1656 fn md_kv_extraction() {
1657 let content = "> **Target:** `192.168.1.14` | **Generated:** 2026-08-01 15:05:04 UTC | **Engine:** VulnMalper v8.0.0";
1658 assert_eq!(
1659 md_kv(content, "**Target:**").as_deref(),
1660 Some("192.168.1.14")
1661 );
1662 assert_eq!(
1663 md_kv(content, "**Generated:**").as_deref(),
1664 Some("2026-08-01 15:05:04 UTC")
1665 );
1666 }
1667
1668 #[test]
1669 fn service_id_stable() {
1670 let asset_id = "a";
1671 assert_eq!(
1672 service_id(asset_id, 80, "tcp"),
1673 service_id(asset_id, 80, "tcp")
1674 );
1675 assert_ne!(
1676 service_id(asset_id, 80, "tcp"),
1677 service_id(asset_id, 81, "tcp")
1678 );
1679 }
1680
1681 #[test]
1682 fn port_from_host_url_parses() {
1683 assert_eq!(
1684 port_from_host_url("http://192.168.1.14:8080/", None),
1685 Some(8080)
1686 );
1687 assert_eq!(port_from_host_url("https://example.com/path", None), None);
1688 let port_value = json!(22);
1689 assert_eq!(port_from_host_url("x", Some(&port_value)), Some(22));
1690 }
1691
1692 #[test]
1693 fn correlate_http_finding_to_its_own_service() {
1694 let mut ctx = BuildContext::default();
1695 ctx.ensure_asset("http://192.168.1.50/");
1696 let asset_id = sha256_hex(&format!("asset:{}", "http://192.168.1.50/"));
1697 let smb = correlate_service(&mut ctx, &asset_id, None, 445, "smb");
1699 let web = correlate_service(
1701 &mut ctx,
1702 &asset_id,
1703 Some("http://192.168.1.50:8080/"),
1704 445,
1705 "smb",
1706 );
1707 assert!(smb.is_some());
1708 assert!(web.is_some());
1709 assert_ne!(smb, web);
1710 assert!(ctx.services.get(web.as_ref().unwrap()).map(|s| s.port) == Some(8080));
1711 assert!(
1712 ctx.services
1713 .get(web.as_ref().unwrap())
1714 .map(|s| s.service_name.clone())
1715 == Some("http".to_string())
1716 );
1717 }
1718
1719 #[test]
1720 fn severity_exploitability_map() {
1721 assert_eq!(exploitability_from_severity("critical"), "likely");
1722 assert_eq!(exploitability_from_severity("info"), "none");
1723 }
1724
1725 #[test]
1726 fn injectable_findings_extracted_from_report() {
1727 let report = "# PloitMalper - Vulnerability Analysis Report\n\
1728 \n\
1729 **Generated:** 123\n\
1730 **Tool Version:** 0.2.1\n\
1731 \n\
1732 ## 1. Executive Summary\n\
1733 - **Host:** http://192.168.1.2/\n\
1734 \n\
1735 ## 4. Injectable Endpoints\n\
1736 \n\
1737 1 potential injection point(s) reported:\n\
1738 \n\
1739 | # | Endpoint | Affected Host |\n\
1740 |---|----------|---------------|\n\
1741 | 1 | `http://192.168.1.2?name=1` | http://192.168.1.2/ |\n\
1742 \n\
1743 ## 5. CVE Intelligence\n";
1744 let findings = injectable_findings(report, "asset-1");
1745 assert_eq!(findings.len(), 1);
1746 assert_eq!(findings[0].asset_id, "asset-1");
1747 assert_eq!(findings[0].tool, "ploitmalper");
1748 assert_eq!(findings[0].severity, "high");
1749 assert_eq!(findings[0].exploitability.as_deref(), Some("likely"));
1750 assert_eq!(
1751 findings[0].title,
1752 "Injectable endpoint: http://192.168.1.2?name=1"
1753 );
1754 assert_eq!(findings[0].endpoints, vec!["http://192.168.1.2?name=1"]);
1755 assert_eq!(
1756 findings[0].target_url.as_deref(),
1757 Some("http://192.168.1.2?name=1")
1758 );
1759 }
1760
1761 #[test]
1762 fn injectable_findings_skips_header_and_absent_section() {
1763 let no_injectable = "# PloitMalper - Vulnerability Analysis Report\n\
1764 \n\
1765 ## 4. Injectable Endpoints\n\
1766 \n\
1767 No injectable endpoints flagged by the scanner.\n\
1768 \n\
1769 ## 5. CVE Intelligence\n";
1770 assert!(injectable_findings(no_injectable, "asset-1").is_empty());
1771
1772 let no_section = "# PloitMalper - Vulnerability Analysis Report\n\
1773 ## 1. Executive Summary\n";
1774 assert!(injectable_findings(no_section, "asset-1").is_empty());
1775 }
1776
1777 fn temp_dir(name: &str) -> PathBuf {
1778 let dir = std::env::temp_dir().join("ploitmalper_tests").join(name);
1779 let _ = std::fs::remove_dir_all(&dir);
1780 std::fs::create_dir_all(&dir).unwrap();
1781 dir
1782 }
1783
1784 #[test]
1785 fn ingest_process_pipeline_imports_injectable_findings() {
1786 let dir = temp_dir("ingest_process");
1787 let target = "192.168.1.2";
1788 let finding = serde_json::json!({
1789 "tool": "nikto",
1790 "severity": "low",
1791 "title": "Missing security header",
1792 "target": format!("http://{}/", target),
1793 "detail": "Suggested security header missing.",
1794 "reference": ""
1795 });
1796 let json = serde_json::json!({
1797 "vulnmalper": { "version": "8.0.0", "source_target": target },
1798 "findings": [finding],
1799 "hosts": [{
1800 "host": target,
1801 "url": format!("http://{}/", target),
1802 "port": 80,
1803 "scheme": "http",
1804 "findings": [finding],
1805 "injectable": [format!("http://{}?name=1", target)]
1806 }]
1807 });
1808 let json_path = dir.join(format!("vulnmalper_{}.json", target));
1809 std::fs::write(&json_path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
1810
1811 let mut config_mgr = crate::config::ConfigManager::new();
1812 let processed = crate::process::process_folder(&dir, &mut config_mgr, false).unwrap();
1813 assert_eq!(processed, 1);
1814
1815 let report_path = dir.join(format!("vulnmalper_{}_ploitmalper.md", target));
1816 let report = std::fs::read_to_string(&report_path).unwrap();
1817 assert!(report.contains("# PloitMalper - Vulnerability Analysis Report"));
1818 assert!(report.contains("http://192.168.1.2?name=1"));
1819
1820 let db_path = dir.join("test.db");
1821 let db_cfg = crate::config::DatabaseConfig {
1822 backend: "sqlite".to_string(),
1823 sqlite_path: db_path.to_string_lossy().to_string(),
1824 configured: true,
1825 ..Default::default()
1826 };
1827 let opts = IngestOptions {
1828 verbose: false,
1829 dry_run: false,
1830 force: false,
1831 process: false,
1832 backend: "auto".to_string(),
1833 pocketbase_url: None,
1834 sqlite_path: None,
1835 };
1836 let summary = ingest_folder(&dir, &db_cfg, &opts).unwrap();
1837 assert!(summary.artifacts_accepted >= 2);
1838
1839 let mut storage = crate::db::open_storage_with(&db_cfg, "sqlite", None, None).unwrap();
1840 let findings = storage.list_all_findings().unwrap();
1841 let titles: Vec<String> = findings.iter().map(|f| f.title.clone()).collect();
1842 assert!(
1843 titles
1844 .iter()
1845 .any(|t| t.contains("Injectable endpoint: http://192.168.1.2?name=1")),
1846 "injectable finding missing from db: {:?}",
1847 titles
1848 );
1849 assert!(titles.iter().any(|t| t.contains("Missing security header")));
1850
1851 let _ = PathBuf::from(&db_path);
1852 let _ = std::fs::remove_file(&db_path);
1853 let _ = std::fs::remove_dir_all(&dir);
1854 }
1855}