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    /// Run the `process` pipeline over every VulnMalper JSON in the folder
24    /// and write a fresh PloitMalper report next to it before ingesting.
25    pub process: bool,
26    /// Backend override: "auto" uses the persisted config.
27    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/// Summary returned to the CLI after an import attempt.
47#[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
61// ---------------------------------------------------------------------------
62// Entry point
63// ---------------------------------------------------------------------------
64
65pub 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    // Malper analyse reports are recognised but intentionally ignored.
76    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    // ------------------------------------------------------------------
102    // Run identity
103    // ------------------------------------------------------------------
104    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    // Primary target: prefer the NetMalper graph, then VulnMalper JSON.
129    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    // ------------------------------------------------------------------
198    // Storage
199    // ------------------------------------------------------------------
200    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    // Snapshot existing state for observation diffing.
239    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    // ------------------------------------------------------------------
256    // Build the new world state in dependency order.
257    // ------------------------------------------------------------------
258    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    // ------------------------------------------------------------------
280    // Commit with history-aware diffs.
281    // ------------------------------------------------------------------
282    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    // ------------------------------------------------------------------
305    // Scan run record
306    // ------------------------------------------------------------------
307    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
363// ---------------------------------------------------------------------------
364// Run identity helpers
365// ---------------------------------------------------------------------------
366
367struct 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
461/// Extract the backtick-quoted value following a markdown marker like
462/// `**Target:**`.
463fn 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
485/// Normalise an RFC3339 timestamp, a `YYYY-MM-DD HH:MM:SS UTC` string, or a
486/// unix epoch into an RFC3339 string.
487fn 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    // IP addresses must match exactly — substring matching would wrongly treat
521    // e.g. 192.168.1.14 and 192.168.1.1 as related.
522    if is_ip(&primary) || is_ip(&other) {
523        return false;
524    }
525
526    // Hostname compatibility: same domain or subdomain-of relationship.
527    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    // Folder name anchors the run (e.g. `ploit-malper ingest ./results/example.com`).
535    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    // Strip a `:port` suffix unless this is an IPv6 literal.
556    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
582/// The tool that produced an artifact, used for ScanRun.tools and report refs.
583fn 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
592/// Store a report artifact's full content in the content store and remember
593/// where it went so the ScanRun record can reference it.
594fn 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
623/// Correlate a finding (or endpoint) with the service that actually hosts it.
624///
625/// Priority order:
626/// 1. explicit endpoint port parsed from the target URL (highest confidence)
627/// 2. the discovered service record on that port for the asset
628/// 3. the scheme-derived protocol (http/https -> tcp)
629/// 4. fallback to the host's service port/name when no endpoint is explicit
630///
631/// A finding must never silently inherit a *different* service (e.g. an
632/// HTTP finding landing on 445/tcp) just because both share the asset.
633fn 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// ---------------------------------------------------------------------------
674// World-state building
675// ---------------------------------------------------------------------------
676
677#[derive(Default)]
678struct BuildContext {
679    assets: HashMap<String, Asset>,
680    services: HashMap<String, Service>,
681    findings: HashMap<String, Finding>,
682    /// artifact hash -> content-store relative path of the stored report.
683    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    // Primary asset from the root node / target.
727    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    // Pass 1: ip + port nodes.
735    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                    // Associate with the service on the same port, if any.
817                    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        // Enrich the matching service.
927        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        // Findings.
966        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            // Correlate the finding with the service that actually hosts it,
1019            // preferring the explicit endpoint port over the host default so
1020            // e.g. an HTTP finding never inherits the SMB 445/tcp service.
1021            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    // Always store the report content as a file.
1042    store_report(ctx, artifact)?;
1043
1044    // If a VulnMalper JSON export is present, use it as the structured source.
1045    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        // Skip header / separator rows.
1078        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 the full report content as a file.
1136    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    // Surface every injectable endpoint the report flags (e.g. SQLi) as a
1176    // finding so it lands in the intelligence database.
1177    for finding in injectable_findings(&artifact.content, &asset_id) {
1178        ctx.findings.insert(finding.stable_id.clone(), finding);
1179    }
1180
1181    Ok(())
1182}
1183
1184/// Extract the injectable endpoints table from a PloitMalper report and turn
1185/// each flagged URL into a high-severity finding.
1186fn 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
1229// ---------------------------------------------------------------------------
1230// Committing with history-aware diffs
1231// ---------------------------------------------------------------------------
1232
1233struct 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
1562// ---------------------------------------------------------------------------
1563// Small helpers
1564// ---------------------------------------------------------------------------
1565
1566fn 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        // IPs must not match via substring (192.168.1.1 vs 192.168.1.14).
1633        assert!(!target_compatible("192.168.1.14", "192.168.1.1", ""));
1634        // Folder hint anchors subdomain runs.
1635        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        // The host default would be port 445 (SMB)...
1698        let smb = correlate_service(&mut ctx, &asset_id, None, 445, "smb");
1699        // ...but an explicit endpoint port on the finding must win.
1700        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}