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