Skip to main content

ploit_malper/ingest/
importer.rs

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