RedAmon Neo4j Graph Schema

September 12, 2026 ยท View on GitHub

Overview

This document defines the Neo4j graph database schema for storing reconnaissance data. The schema is designed to enable attack chain analysis by connecting all discovered assets, services, technologies, and vulnerabilities in a navigable graph structure.


๐ŸŽฏ Design Principles

  1. Hierarchical Ownership: All nodes trace back to a Domain with user_id and project_id
  2. Attack Surface Mapping: Every potential entry point is modeled (ports, URLs, parameters)
  3. Technology-Vulnerability Linkage: Technologies connect to known CVEs for risk assessment
  4. No Redundancy: Information stored once, relationships handle connections
  5. Query Efficiency: Optimized for path traversal (attack chains)
  6. Multi-Tenant Isolation: Every ENTITY node has user_id + project_id for tenant filtering. The three GLOBAL REFERENCE labels are the deliberate exception - see below.

๐ŸŒ Global Reference Nodes (CVE, MitreData, Capec)

CVE, MitreData and Capec are the public NVD/MITRE catalogue, not findings. They are UNIQUE on their natural id (c.id, m.id, cap.capec_id), so there is exactly ONE node per CVE for the whole database, shared by every project that finds it. They carry no user_id / project_id.

// entity node - tenant-scoped
MERGE (v:Vulnerability {id: $id, user_id: $uid, project_id: $pid})

// reference node - natural id ONLY, never a tenant key
MERGE (c:CVE {id: $cve_id})
  ON CREATE SET c.source = 'nuclei'        // provenance: first writer wins

Three rules follow from that, each of which was a real defect:

  1. Never stamp a tenant on one. SET c += props with user_id/project_id in the dict made the last project to touch a CVE its owner, and every project-scoped delete then removed the shared node along with every other project's links to it.
  2. Never key a MERGE on the tenant triple. MERGE (c:CVE {id, user_id, project_id}) collides with the uniqueness constraint on id and raises ConstraintValidationFailed whenever the CVE already exists.
  3. Never delete one by project. Project wipes exclude these labels and then sweep the nodes no project can REACH any more. Reachability, not degree: the catalogue is internally linked as CVE -> MitreData -> Capec, so an unreferenced CVE still holds its CWE.

Reading them is by traversal, not by tenant filter. graph_db/tenant_filter.py exempts these labels from injection - a filter on an unstamped node matches nothing - but only for a pattern that names reference labels ONLY, and only in a query that carries a tenant-scoped pattern of its own:

MATCH (t:Technology)-[:HAS_KNOWN_CVE]->(c:CVE) RETURN c.id, c.cvss   // โœ…
MATCH (c:CVE) RETURN c.id                                            // โŒ refused

The idx_cve_tenant / idx_mitredata_tenant / idx_capec_tenant indexes indexed a property these nodes no longer carry, so init_schema drops them: they are listed in DROP_LEGACY_CONSTRAINTS (graph_db/schema.py) and go on the next connection to any existing database.


๐Ÿ”‡ The Muted Label (suppressed findings)

Muted is the one label that is added to a node rather than being its type. When an operator suppresses a finding as noise it keeps its functional label and gains this one, so a suppressed Nuclei finding is :Vulnerability:Muted.

// mute   - add the label, keep the type
MATCH (v:Vulnerability {id: $id, user_id: $uid, project_id: $pid})
SET v:Muted, v.muted = true, v.muted_at = datetime(), v.muted_by = $uid

// unmute - lossless, nothing was destroyed
MATCH (v:Vulnerability:Muted {id: $id, user_id: $uid, project_id: $pid})
REMOVE v:Muted, v.muted, v.muted_at, v.muted_by, v.muted_reason

Only finding-bearing nodes may be muted. Vulnerability, JsReconFinding, Secret, MultiscannerFinding, GithubSecret, GithubSensitiveFile, MalPackageFinding, ExploitGvm. Asset and reference nodes (IP, Port, Domain, Endpoint, CVE, ...) are context: muting one would orphan the real findings hanging off it.

Why add a label instead of swapping it

Adding is what makes unmute lossless and what makes mute survive a re-scan. Recon re-runs MERGE (v:Vulnerability {id, user_id, project_id}), which still matches a :Vulnerability:Muted node, refreshes its scan properties and leaves the mute intact. Had mute replaced the functional label, that MERGE would match nothing and create a second, un-muted duplicate of the same finding, because vulnerability_tenant_unique is on :Vulnerability(id, user_id, project_id) and the duplicate would satisfy it.

The single-label convention this bends, and its price

Everywhere else in this schema a node has exactly ONE label, because the graph renderer and several aggregations take labels[0] and Neo4j does not order labels. A muted node is dual-labelled, so its labels[0] is nondeterministic.

That is safe only because of a containment rule that must hold for every read path: no labels[0] consumer ever receives a muted node. Excluding :Muted is therefore a correctness requirement, not only a visibility one - a reader that forgets the filter both leaks a suppressed finding and may mis-type it as "Muted". The one legitimate reader of muted nodes is the Triage page's Muted table, which derives the type as [l IN labels(n) WHERE l <> 'Muted'][0] and never uses labels[0].

Invisibility is enforced at the tenant chokepoint

graph_db/tenant_filter.py rewrites every node pattern to carry the tenant keys; the same rewrite now also excludes Muted, so the guarantee rides on the mechanism that already covers every query shape the agent can emit:

MATCH (v:Vulnerability)  ->  MATCH (v:Vulnerability&!Muted {user_id: .., project_id: ..})
MATCH (n)                ->  MATCH (n:!Muted {user_id: .., project_id: ..})
MATCH (n:A|B)            ->  MATCH (n:(A|B)&!Muted {user_id: .., project_id: ..})

A query that names the label itself is refused rather than scoped, so the agent has no vocabulary for mute at all:

MATCH (v:Vulnerability&!Muted {...}) RETURN v   // โœ… what injection produces
MATCH (n:Muted) RETURN n                        // โŒ refused: reserved label
MATCH (v:Vulnerability) WHERE NOT v:Muted ...   // โŒ refused: exclusion is automatic

Readers that hand-write their own Cypher and never reach scope_query are separate enforcement sites and must exclude :Muted themselves: the /graph loader (webapp/src/app/api/graph/liveRead.ts), the fixed-op node-type query (agentic/api.py _GRAPH_TYPES_CYPHER), analytics, insights and reports.

Properties

PropertyTypeMeaning
mutedBooleanAlways true when present; the label is the real marker
muted_atdatetimeWhen it was suppressed
muted_byStringuser_id of the operator who suppressed it
muted_reasonStringOptional operator note

Triage properties (any finding node, independent of mute)

Written by a triage run. They RANK a finding and never hide it; mute stays a human action, and there is no code path from a run to the Muted label.

The score and how it was reached. All of it is stored, because an operator who cannot see WHY a finding ranked where it did has no way to disagree with it.

PropertyTypeMeaning
triage_priority_scoreFloat0-100, the sort key. Bigger is more urgent
triage_math_scoreFloatThe score before any AI correction
triage_riskFloatC x L x I x R, 0-1, before the tier is folded into the score. The project-level risk roll-up combines these
triage_tierStringT1 Act now | T2 Act soon | T3 Plan | T4 Track
triage_tier_ruleStringWhich rule placed it in that tier
triage_factorsString (JSON)C, L, I, R, each with the evidence it came from
triage_signalsString[]The readable fact chips: KEV, EPSS 0.94, live endpoint
triage_stateStringopen | fixed | gone | inactive | false_positive. Only open is ranked
triage_hostStringThe host the model resolved and scored against, deterministically
triage_group_keyStringOne problem, one fix. Replaces triage_cluster_id
triage_detectorStringWhich detector fired (nuclei:<template>, gvm:<oid>, trufflehog:<detector>). Real / False positive clicks are counted per detector, per user, and feed back into C
triage_run_idStringWhich run produced this. Drives "new since the last triage"
triage_model_versionStringSCORE_MODEL_VERSION; two runs are comparable only when it matches
triage_intel_dateStringWhen the CVE intelligence behind it was fetched
triage_proofString (JSON)The chain findings that proved it, so proof survives a lost edge
triaged_atdatetimeWhen the run wrote this

What the AI concluded. It corrects factors and never produces a score.

PropertyTypeMeaning
triage_ai_verdictStringreal | doubtful | false_positive | unclear | not_reviewed
triage_ai_correctionsString (JSON)What it changed, and the disputes it raised
triage_ai_quoteStringThe exact evidence text, VERIFIED as a substring of what was sent
triage_ai_modelStringWhich model reviewed it
triage_ai_atdatetimeWhen
triage_evidence_hashStringThe review cache key: evidence + prompt version + model
triage_fix_leverStringThe short phrase describing what would fix it

The verdict, which a person owns.

PropertyTypeMeaning
triage_statusStringconfirmed | likely_noise | unreviewed (absent = unreviewed)
triage_confidenceFloat0.0 - 1.0
triage_reasonStringOne line, why
triage_sourceStringai | human. A human verdict is never overwritten

A human owns the verdict, not the measurements. When triage_source is human, later runs keep updating the facts, the factors and the score, because those are measurements and a stale rank helps nobody. Only triage_status, triage_reason and triage_confidence are left alone.

Retention: ingest-then-prune. A scan no longer deletes its findings up front and re-creates them; that deleted the operator's mute, their verdict, the AI's cached review and the link from a fix item back to the finding. A scan now MERGEs what it still reports, which refreshes updated_at, and afterwards prune_unseen_findings removes what it did not touch.

No new "last seen" property was needed: updated_at is already stamped by every node write, so "not seen in this run" is exactly "older than the run started".

A finding carrying :Muted or triage_source = 'human' is never deleted by a prune. It is stamped stale_since instead, which triage reads as state = fixed and notMuted() excludes, so it leaves every count and every table at once. The prune runs ONLY after an ingest that actually produced findings: a scan that reported nothing is evidence the scan failed, not evidence the findings are gone.

The stamp is lifted again the moment the owning scanner reports the finding: the same prune REMOVEs stale_since from anything of its sources the run touched (updated_at at or after the run started). Nothing else clears it, so without this a human-confirmed finding that came back would stay Resolved for ever.

Muted carries no colour in webapp/src/app/graph/config/colors.ts on purpose: it is never rendered, because it never reaches the renderer.


๐Ÿ—๏ธ Multi-Tenant AWS Scalability Strategy

This schema uses Logical Partitioning with Composite Indexes for multi-tenant isolation. Every node type includes user_id and project_id properties with composite indexes.

Why This Approach?

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    Single Neo4j Database                        โ”‚
โ”‚                                                                 โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”             โ”‚
โ”‚  โ”‚  User A     โ”‚  โ”‚  User B     โ”‚  โ”‚  User C     โ”‚  ...        โ”‚
โ”‚  โ”‚  Project 1  โ”‚  โ”‚  Project 1  โ”‚  โ”‚  Project 1  โ”‚             โ”‚
โ”‚  โ”‚  Project 2  โ”‚  โ”‚  Project 2  โ”‚  โ”‚  Project 2  โ”‚             โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜             โ”‚
โ”‚                                                                 โ”‚
โ”‚  Composite Constraints: (fields, user_id, project_id) IS UNIQUE โ”‚
โ”‚  Query Pattern: Always filter by tenant FIRST                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Query Pattern (CRITICAL)

All queries MUST start by filtering on user_id and project_id to leverage indexes:

// โœ… CORRECT - Uses composite index, scans only tenant's data
MATCH (d:Domain {user_id: $userId, project_id: $projectId})
-[:HAS_SUBDOMAIN]->(s:Subdomain)
-[:RESOLVES_TO]->(ip:IP)
-[:HAS_PORT]->(p:Port)
RETURN d, s, ip, p

// โŒ WRONG - Full graph scan, affects all tenants
MATCH (v:Vulnerability {severity: 'critical'})
RETURN v

AWS Deployment Architecture

Node.js API (EKS/ECS Fargate)
        โ”‚
        โ”œโ”€โ”€ ElastiCache Redis โ”€โ”€โ”€ Query caching per tenant
        โ”‚
        โ””โ”€โ”€ Neo4j AuraDB / Neo4j on EC2
                โ”‚
                โ””โ”€โ”€ Composite indexes on (user_id, project_id)

Scaling Path

PhaseUsersStrategyAWS Services
MVP0-100Single DB + IndexesECS Fargate, Neo4j AuraDB
Growth100-1KRead ReplicasEKS, AuraDB Professional
Scale1K+Sharded by User PoolsEKS Multi-AZ, Neo4j Cluster

๐Ÿ“Š Node Types

โš ๏ธ IMPORTANT: All node types below implicitly include user_id and project_id properties for multi-tenant isolation, even if not shown in the examples. These are indexed with composite indexes for optimal query performance; the full list is TENANT_INDEXES in graph_db/schema.py.

The exceptions are the three global reference labels (CVE, MitreData, Capec) and KBChunk, which are shared across the whole database and carry no tenant keys at all.

1. Domain (Root Node)

The entry point for all queries. Contains project/user ownership.

(:Domain {
    name: "vulnweb.com",                    // Root domain name (UNIQUE per tenant)
    user_id: "samgiam",                     // Owner/user identifier
    project_id: "first_test",               // Project identifier
    scan_timestamp: datetime,               // When scan was performed
    scan_type: "domain_discovery_port_scan_http_probe_vuln_scan",
    target: "testphp.vulnweb.com",          // Original target (may differ from root)
    filtered_mode: true,                    // Was SUBDOMAIN_LIST filter used?
    subdomain_filter: ["testphp."],         // Subdomain prefixes from SUBDOMAIN_LIST
    modules_executed: ["whois", "dns_resolution", "port_scan", "http_probe", "vuln_scan"],
    
    // Scan modes (from metadata)
    anonymous_mode: false,                   // Retained for compatibility; always false
    bruteforce_mode: false,                  // Was subdomain bruteforcing enabled?
    
    // WHOIS Information
    registrar: "Gandi SAS",
    registrar_url: "http://www.gandi.net",
    whois_server: "whois.gandi.net",
    creation_date: datetime,
    expiration_date: datetime,
    updated_date: datetime,
    dnssec: "unsigned",
    
    // Owner Information
    organization: "Invicti Security Limited",
    country: "MT",
    city: "REDACTED FOR PRIVACY",           // City (often redacted)
    state: null,                             // State/province
    address: "REDACTED FOR PRIVACY",         // Street address
    registrant_postal_code: "REDACTED FOR PRIVACY",
    
    // Contact Information (may be redacted)
    registrant_name: "REDACTED FOR PRIVACY",
    admin_name: "REDACTED FOR PRIVACY",
    admin_org: "REDACTED FOR PRIVACY",
    tech_name: "REDACTED FOR PRIVACY",
    tech_org: "REDACTED FOR PRIVACY",
    
    // Status
    status: ["clientTransferProhibited"],
    
    // WHOIS Contact Emails
    whois_emails: ["abuse@support.gandi.net", "...@contact.gandi.net"],
    
    // WHOIS extra fields
    domain_name: "VULNWEB.COM",              // Registered domain name (uppercase)
    referral_url: null,                      // Referral URL if any
    reseller: null,                          // Reseller if any
    
    // Name servers (moved from separate node)
    name_servers: ["NS-105-A.GANDI.NET", "NS-105-B.GANDI.NET", "NS-105-C.GANDI.NET"],

    // OSINT enrichment (set by VirusTotal and OTX)
    vt_enriched: true,
    vt_reputation: -5,                      // VirusTotal community reputation score
    vt_malicious_count: 2,                  // Number of malicious engine detections
    vt_suspicious_count: 1,                 // Suspicious engine detections
    vt_harmless_count: 60,                  // Harmless engine detections
    vt_undetected_count: 10,                // Undetected (no verdict) engine count
    vt_categories: "{\"Forcepoint ThreatSeeker\": \"malicious\"}",  // JSON-serialised categories dict
    vt_registrar: "MarkMonitor Inc.",       // Registrar from VirusTotal
    vt_tags: ["malware", "phishing"],       // VirusTotal threat/category tags
    vt_community_malicious: 5,             // Community malicious votes (distinct from engine count)
    vt_community_harmless: 120,            // Community harmless votes
    vt_last_analysis_date: 1704067200,     // Unix timestamp of last VirusTotal scan
    vt_jarm: "27d40d40d00040d00042d43d000000aa99ce1b3cb6b454ab1b5c65c8df16f4",  // JARM TLS fingerprint
    vt_popularity_alexa: 15234,            // Alexa popularity rank
    vt_popularity_umbrella: 8901,          // Cisco Umbrella rank
    otx_pulse_count: 3,                    // AlienVault OTX threat pulse count
    otx_url_count: 12,                     // Number of URLs associated with domain (from OTX url_list)
    otx_adversaries: ["APT28"],            // Named threat actors from OTX pulses
    otx_malware_families: ["PlugX"],       // Malware family names from OTX pulses
    otx_tlp: "white",                      // Most restrictive TLP across OTX pulses
    otx_attack_ids: ["T1566"],             // MITRE ATT&CK IDs from OTX pulses
    criminalip_enriched: true,
    criminalip_risk_score: "high",         // Domain risk score from Criminal IP
    criminalip_risk_grade: "A",            // Domain risk grade from Criminal IP
    criminalip_abuse_count: 3,             // Number of abuse reports for this domain
    criminalip_current_service: "web"      // Current service classification from Criminal IP
})

Constraints:

CREATE CONSTRAINT domain_unique IF NOT EXISTS
FOR (d:Domain) REQUIRE (d.name, d.user_id, d.project_id) IS UNIQUE;

CREATE INDEX idx_domain_tenant IF NOT EXISTS
FOR (d:Domain) ON (d.user_id, d.project_id);

2. Subdomain

Discovered subdomains/hostnames under a domain.

(:Subdomain {
    name: "testphp.vulnweb.com",           // Full hostname (UNIQUE per tenant)
    has_dns_records: true,
    status: "200",                          // Primary HTTP status code as string, or "resolved"/"no_http"
    status_codes: [200, 301],               // All unique HTTP status codes seen (set after HTTP probe)
    http_live_url_count: 3,                 // Count of URLs with status < 500
    http_probed_at: datetime,               // When last HTTP-probed
    discovered_at: datetime,
    source: "crt.sh"                        // Discovery source: "crt.sh", "hackertarget", "knockpy",
                                            // "subfinder", "amass", "shodan_rdns", "shodan_dns",
                                            // "urlscan", "fofa", "otx_passive_dns", "censys_rdns",
                                            // "uncover"
    uncover_sources: ['shodan', 'censys'],  // Uncover engines that contributed (when source='uncover')
    uncover_total_raw: 42,                  // Uncover raw results count
    uncover_total_deduped: 30,              // Uncover deduplicated results count
})

Status values:

  • resolved โ€” Has DNS records, not yet HTTP-probed
  • no_http โ€” DNS resolves but no HTTP service responded
  • "200", "301", "403", "404", "500", etc. โ€” Primary HTTP status code (lowest non-5xx, or lowest overall)

Constraints:

CREATE CONSTRAINT subdomain_unique IF NOT EXISTS
FOR (s:Subdomain) REQUIRE (s.name, s.user_id, s.project_id) IS UNIQUE;

3. IP

IP addresses discovered through DNS resolution.

(:IP {
    address: "44.228.249.3",               // IP address (UNIQUE per tenant)
    version: "ipv4",                        // ipv4 or ipv6
    is_cdn: true,
    cdn_name: "aws",
    asn: "AS16509",                         // Autonomous System Number
    asn_org: "Amazon.com, Inc.",

    // Geolocation (set by Shodan, Censys)
    country: "United States",
    country_code: "US",
    city: "Ashburn",
    timezone: "America/New_York",
    registered_country: "United States",
    latitude: 39.0469,
    longitude: -77.4903,

    // ASN enrichment (set by Censys)
    autonomous_system_name: "AMAZON-AES",
    autonomous_system_number: 14618,
    asn_bgp_prefix: "44.224.0.0/11",
    asn_description: "Amazon.com, Inc.",
    asn_country_code: "US",
    asn_rir: "ARIN",

    // OSINT enrichment flags and metadata
    censys_enriched: true,
    censys_last_seen: "2026-03-01T12:00:00Z",
    fofa_enriched: true,
    fofa_last_seen: "2026-03-20T08:00:00Z",  // last time FOFA indexed this asset (lastupdatetime)
    country_code: "US",                    // 2-letter country code (set by FOFA country field)
    os: "Linux",                           // OS fingerprint (set by FOFA os field)
    region: "Virginia",                    // Region/province (set by FOFA region field)
    netlas_enriched: true,
    isp: "Amazon.com, Inc.",               // set by Netlas (isp field) or FOFA (isp field)
    country: "US",                         // set by Netlas (geo.country) or FOFA (country_name) โ€” may also be set by Shodan/Censys
    city: "Ashburn",                       // set by Netlas (geo.city) or FOFA (city)
    latitude: 39.04,                       // set by Netlas (geo.latitude)
    longitude: -77.49,                     // set by Netlas (geo.longitude)
    timezone: "America/New_York",          // set by Netlas (geo.time_zone)
    asn_org: "Amazon.com, Inc.",           // set by Netlas (whois.asn.name) or FOFA (as_organization)
    asn: "AS16509",                        // set by Netlas (geo.asn.number) or FOFA (as_number, normalised to "AS<n>")
    asn_bgp_prefix: "44.224.0.0/11",      // set by Netlas (geo.asn.route)
    zoomeye_enriched: true,
    zoomeye_last_seen: "2026-03-01T12:00:00Z",  // update_time from ZoomEye host record
    // ZoomEye also sets (when available): os, country, city, latitude, longitude, asn, isp
    otx_enriched: true,
    otx_pulse_count: 3,
    otx_reputation: 0,
    otx_url_count: 8,                      // Number of URLs associated with this IP (from OTX url_list)
    otx_adversaries: ["APT28"],            // Named threat actors from OTX pulses
    otx_malware_families: ["PlugX"],       // Malware family names from OTX pulses
    otx_tlp: "amber",                      // Most restrictive TLP across OTX pulses
    otx_attack_ids: ["T1059", "T1566"],    // MITRE ATT&CK IDs from OTX pulses
    country_name: "United States",         // Set by OTX geo (only when not already present)
    vt_enriched: true,
    vt_reputation: -5,
    vt_malicious_count: 2,
    vt_suspicious_count: 0,
    vt_harmless_count: 55,
    vt_undetected_count: 8,
    vt_tags: ["scanner", "vpn"],            // VirusTotal threat tags
    vt_community_malicious: 2,             // Community malicious votes
    vt_community_harmless: 80,             // Community harmless votes
    vt_last_analysis_date: 1704067200,     // Unix timestamp of last VirusTotal scan
    vt_network: "44.224.0.0/11",           // CIDR network range from VirusTotal
    vt_rir: "ARIN",                        // Regional Internet Registry (ARIN, RIPE NCC, APNIC, LACNIC, AFRINIC)
    vt_continent: "NA",                    // Continent code from VirusTotal
    vt_jarm: "27d40d40d00040d00042d43d000000aa99ce1b3cb6b454ab1b5c65c8df16f4",  // JARM TLS fingerprint
    criminalip_enriched: true,
    criminalip_score_inbound: "dangerous",     // Risk score for inbound traffic (integer 0-5 or label)
    criminalip_score_outbound: "safe",         // Risk score for outbound traffic
    criminalip_is_vpn: false,
    criminalip_is_proxy: false,
    criminalip_is_tor: false,
    criminalip_is_hosting: false,              // Hosting/datacenter IP
    criminalip_is_cloud: false,                // Cloud provider IP
    criminalip_is_mobile: false,               // Mobile carrier IP
    criminalip_is_darkweb: false,              // Associated with dark web activity
    criminalip_is_scanner: false,              // Known scanning/crawling source
    criminalip_is_snort: false,                // Listed in Snort/IDS rules
    criminalip_org_name: "DMZHOST",           // Organization name (WHOIS)
    criminalip_country: "nl",                  // Country code from WHOIS
    criminalip_city: "Amsterdam",              // City from WHOIS
    criminalip_latitude: 52.3716,              // Latitude from WHOIS
    criminalip_longitude: 4.8883,              // Longitude from WHOIS
    criminalip_asn_name: "Pptechnology Limited",  // AS name from WHOIS
    criminalip_asn_no: 48090,                  // AS number from WHOIS
    criminalip_ids_count: 2,                   // Count of IDS/Snort alerts for this IP
    criminalip_scanning_count: 20,             // Count of inbound scanning events recorded
    criminalip_categories: "[\"malware\", \"attack (Low)\"]"  // JSON list of IP category labels

    // Uncover discovery flags (set by ProjectDiscovery uncover multi-engine search)
    uncover_discovered: true,               // IP was first found via uncover target expansion
    uncover_enriched: true,                 // Uncover has processed this IP
    uncover_sources: ['shodan', 'censys'],  // Search engines that found results
    uncover_source_counts: '{"shodan": 5}', // JSON-encoded engine->count map
    uncover_total_raw: 42,                  // Total raw results before dedup
    uncover_total_deduped: 30,              // Total results after dedup
})

Constraints:

CREATE CONSTRAINT ip_unique IF NOT EXISTS
FOR (i:IP) REQUIRE (i.address, i.user_id, i.project_id) IS UNIQUE;

4. Port

Open ports discovered on IPs/hosts.

(:Port {
    number: 80,                             // Port number
    protocol: "tcp",                        // tcp or udp
    state: "open",
    source: "naabu",                        // Discovery source: "naabu", "masscan", "shodan",
                                            // "censys", "fofa", "netlas", "zoomeye", "criminalip",
                                            // "uncover"

    // Nmap service version enrichment (set by update_graph_from_nmap)
    product: "nginx",                       // Product name from Nmap -sV
    version: "1.19.0",                      // Version from Nmap -sV
    cpe: "cpe:/a:f5:nginx:1.19.0",         // CPE string from Nmap
    nmap_scanned: true,                     // Flag: Nmap has probed this port
})

Constraints:

CREATE CONSTRAINT port_unique IF NOT EXISTS
FOR (p:Port) REQUIRE (p.number, p.protocol, p.ip_address, p.user_id, p.project_id) IS UNIQUE;

Note: Port nodes are connected to both IP and Subdomain to show which host has which port open.


5. Service

Services running on ports.

(:Service {
    name: "http",                           // Service name
    product: "nginx",                       // Product name (if detected) โ€” set by Nmap -sV, ZoomEye, Netlas
    version: "1.19.0",                      // Version (if detected) โ€” set by Nmap -sV, ZoomEye, Netlas
    cpe: "cpe:/a:f5:nginx:1.19.0",         // CPE string โ€” set by Nmap -sV
    banner: "nginx/1.19.0",                 // Raw banner (set by Censys, ZoomEye, Nmap, Netlas)
    extra_info: "Ubuntu",
    source: "censys",                       // Discovery source: "nmap", "censys", "fofa", "netlas", "zoomeye", "criminalip"

    // Censys-specific enrichment
    extended_service_name: "HTTPS",         // More specific service label (e.g. HTTPS vs HTTP)
    labels: ["HTTPS", "TLS"],              // Service classification tags from Censys
    http_title: "Example Domain",          // HTML title from HTTP response (Censys, Netlas, FOFA, ZoomEye portinfo.title)
    http_status_code: 200,                 // HTTP status code from HTTP probe (Censys, Netlas)
    software_products: ["nginx 1.23.4"],   // Detected software products and versions (Censys)

    // FOFA-specific enrichment
    app_protocol: "https",                 // Application-layer protocol (FOFA protocol field)
    jarm: "27d40d40d00040d00042d43d000000", // JARM TLS fingerprint (FOFA jarm field)
    tls_version: "TLSv1.3",               // TLS version (FOFA tls_version field)
})

Constraints:

CREATE CONSTRAINT service_unique IF NOT EXISTS
FOR (svc:Service) REQUIRE (svc.name, svc.port_number, svc.ip_address, svc.user_id, svc.project_id) IS UNIQUE;

6. BaseURL

Root/base web endpoints discovered through HTTP probing. These represent the entry points discovered by httpx. Specific paths and endpoints discovered during vulnerability scanning are stored in separate Endpoint nodes.

(:BaseURL {
    url: "http://testphp.vulnweb.com",     // Full base URL (UNIQUE per tenant)
    scheme: "http",                         // http or https
    host: "testphp.vulnweb.com",            // Hostname
    status_code: 200,
    content_type: "text/html",
    content_length: 2295,
    title: "Acunetix Test Site",
    server: "nginx/1.19.0",
    is_live: true,
    response_time_ms: null,                 // Response time in milliseconds

    // Discovery source
    source: "http_probe",                   // http_probe

    // Network info
    resolved_ip: "44.228.249.3",
    cname: null,                            // CNAME if any
    cdn: "aws",
    is_cdn: true,
    asn: null,

    // Fingerprints
    favicon_hash: "-1187092235",
    body_sha256: "a42521a54c7bcc2dbc2f7010dd22c17c566f3bda167e662c6086c94bf9ebfb62",
    header_sha256: "fbbea705962aa40edced75d2fb430f4a8295b7ab79345a272d1376dd150460cd",

    // Response metadata
    word_count: 11,
    line_count: 6
})

Constraints:

CREATE CONSTRAINT baseurl_unique IF NOT EXISTS
FOR (u:BaseURL) REQUIRE (u.url, u.user_id, u.project_id) IS UNIQUE;

7. Certificate

TLS/SSL certificates discovered during HTTP probing, GVM scanning, or Censys enrichment. Contains certificate metadata for security analysis.

(:Certificate {
    cert_key: "sha256:a1b2c3...",             // Certificate identity (UNIQUE per tenant).
                                              //   "sha256:<fp>" when a fingerprint is known
                                              //   (tlsx/Censys/GVM); "surrogate:<sha1>" over
                                              //   subject_cn|issuer|not_before|not_after otherwise
                                              //   (httpx, FOFA). NOT subject_cn: a CN is neither
                                              //   unique nor always present (SAN-only certs).
    subject_cn: "*.beta80group.it",          // Common Name (nullable; empty on SAN-only certs)
    user_id: "samgiam",                       // Owner/user identifier
    project_id: "project_2",                  // Project identifier
    issuer: "DigiCert Inc",                   // Certificate issuer (CN + org)
    not_before: "2025-09-02T00:00:00Z",       // Valid from date
    not_after: "2026-10-03T23:59:59Z",        // Expiration date
    san: ["*.beta80group.it", "beta80group.it"],  // Subject Alternative Names (full list, CN included)
    cipher: "TLS_AES_128_GCM_SHA256",         // TLS cipher suite
    tls_version: "TLSv1.3",                   // TLS version (if detected)
    subject_org: "Example Org",               // Certificate subject organization (set by FOFA certs_subject_org)
    is_valid: true,                           // Certificate validity flag (set by FOFA certs_valid)
    source: "http_probe",                     // FIRST writer (ON CREATE only): "http_probe","tlsx","gvm","censys","fofa"
    observed_by: ["http_probe", "gvm"],       // ALL writers that observed this cert (append-only).
                                              //   A cross-source clear preserves a cert with any
                                              //   other scanner still in this list.
    fingerprint_sha256: "a1b2c3...",          // SHA-256 fingerprint (single canonical name across writers)

    // GVM-specific properties (when source = "gvm")
    serial: "01:AB:CD:...",                   // Certificate serial number
    scan_timestamp: "2026-02-12T23:10:29Z",   // When GVM scan ran

    // tlsx verdict/posture booleans (also read by the TLS-hygiene security checks)
    expired: false, self_signed: false, mismatched: false,
    revoked: false, untrusted: false, wildcard: false,
    jarm: "...", ja3: "...", ja3s: "..."      // fingerprints (flag-gated in tlsx)
})

Constraints:

CREATE CONSTRAINT certificate_key_unique IF NOT EXISTS
FOR (c:Certificate) REQUIRE (c.cert_key, c.user_id, c.project_id) IS UNIQUE;

8. Endpoint

Specific web application endpoints (paths) discovered through Katana crawling, Hakrawler crawling, ZAP Ajax Spider browser-driven crawling, ParamSpider parameter mining, jsluice JavaScript analysis, or vulnerability scanning. These are linked to their parent BaseURL and contain discovered parameters.

(:Endpoint {
    // Core properties
    path: "/artists.php",                   // Path without query string
    method: "GET",                          // HTTP method (GET, POST, PUT, DELETE, etc.)
    baseurl: "http://testphp.vulnweb.com",  // Parent base URL
    has_parameters: true,                   // Does this endpoint have parameters?
    full_url: "http://testphp.vulnweb.com/artists.php",  // Full URL without query params
    source: "katana_crawl",                 // katana_crawl, vuln_scan, resource_enum
    category: "dynamic",                    // dynamic, static, authentication, search, api, other
    query_param_count: 1,                   // Number of query parameters
    body_param_count: 0,                    // Number of body parameters
    path_param_count: 0,                    // Number of path parameters
    urls_found: 3,                          // Number of URLs pointing to this endpoint

    // Form properties (for POST endpoints discovered via HTML forms)
    is_form: true,                          // True if this endpoint receives form submissions
    form_enctype: "application/x-www-form-urlencoded",  // Form encoding type
    form_found_at_pages: [                  // Pages where this form was discovered
        "http://testphp.vulnweb.com/login.php",
        "http://testphp.vulnweb.com/index.php"
    ],
    form_input_names: ["username", "password"],  // Input field names from the form
    form_count: 2,                          // Number of pages containing this form

    // GraphQL enrichment (set by graphql_scan when an endpoint is detected as GraphQL)
    is_graphql: true,                                // True = endpoint is a GraphQL endpoint
    graphql_introspection_enabled: true,             // True = __schema introspection query succeeded
    graphql_schema_extracted: true,                  // True = full schema was successfully retrieved
    graphql_schema_hash: "sha256:...",               // SHA-256 of normalized schema JSON (change detection)
    graphql_schema_extracted_at: "2026-04-20T18:00:00+00:00", // ISO timestamp of last schema extraction
    graphql_queries: ["me", "users", "orders"],      // Up to 50 query operation names
    graphql_mutations: ["login", "createOrder"],     // Up to 50 mutation operation names
    graphql_subscriptions: ["onMessage"],            // Up to 50 subscription operation names
    graphql_queries_count: 23,                       // Total queries (not just the capped array)
    graphql_mutations_count: 8,                      // Total mutations
    graphql_subscriptions_count: 1,                  // Total subscriptions

    // graphql-cop external scanner capability flags (set regardless of result -- captures
    // negative signals like "GraphiQL exposed=false" as explicit state rather than silence)
    graphql_cop_ran: true,                           // True if graphql-cop executed against this endpoint
    graphql_cop_scanned_at: "2026-04-20T18:00:00+00:00", // ISO timestamp of last graphql-cop run
    graphql_graphiql_exposed: false,                 // GraphiQL / Playground IDE detected
    graphql_tracing_enabled: false,                  // Apollo tracing extension enabled
    graphql_get_allowed: false,                      // GET-method queries accepted (CSRF vector)
    graphql_field_suggestions_enabled: true,         // "Did you mean X?" errors leak schema fields
    graphql_batching_enabled: false                  // Array-based batched queries accepted
})

Constraints:

CREATE CONSTRAINT endpoint_unique IF NOT EXISTS
FOR (e:Endpoint) REQUIRE (e.path, e.method, e.baseurl, e.user_id, e.project_id) IS UNIQUE;

9. Parameter

URL parameters that represent potential attack vectors. These are discovered through Katana crawling, Hakrawler crawling, ZAP Ajax Spider browser-driven crawling, ParamSpider passive parameter mining, jsluice JavaScript analysis, and marked as injectable when vulnerabilities are found through DAST scanning.

(:Parameter {
    name: "artist",                         // Parameter name
    position: "query",                      // query, body, header, path
    endpoint_path: "/artists.php",          // Parent endpoint path
    baseurl: "http://testphp.vulnweb.com",  // Parent base URL
    sample_value: "1",                      // Example value seen
    is_injectable: true                     // Marked true if vuln found affecting this param
})

Constraints:

CREATE CONSTRAINT parameter_unique IF NOT EXISTS
FOR (p:Parameter) REQUIRE (p.name, p.position, p.endpoint_path, p.baseurl, p.user_id, p.project_id) IS UNIQUE;

10. Technology

Detected technologies, frameworks, and software.

(:Technology {
    name: "PHP",                            // Technology name
    version: "5.6.40",                      // Primary version, '' when undetected
    categories: ["Programming languages"],  // Technology categories
    category: "ai-vector-db",               // Single-valued category (AI-surface writer)
    confidence: 100,                        // Detection confidence (0-100)

    // Source tracking
    detected_by: "httpx",                   // httpx, wappalyzer, nmap, gvm,
                                            // ai-surface-recon-probe
    source: "ai-surface-recon",             // Producing module, where the writer sets it

    // For CVE lookup matching
    product: "php",                         // Normalized product name (Nmap -sV)
    cpe: "cpe:/a:php:php:5.6.40",           // Full CPE (GVM)
    cpe_vendor: "php",                      // CPE vendor (if known)
    cpe_product: "php"                      // CPE product (if known)
})

Constraints:

CREATE CONSTRAINT technology_unique IF NOT EXISTS
FOR (t:Technology) REQUIRE (t.name, t.version, t.user_id, t.project_id) IS UNIQUE;

Note: version uses empty string '' (not NULL) when no version is detected, because composite constraints require all fields to be present.

There is no denormalised CVE-count property on Technology and no name_version / versions_all property. versions_all exists only in the recon JSON (recon/main_recon_modules/http_probe.py) and is not carried onto the node. Count CVEs by traversing -[:HAS_KNOWN_CVE]->(:CVE) instead.


11. Vulnerability

Discovered vulnerabilities. Seven sources produce Vulnerability nodes, each with different property sets.

Common properties (all sources):

(:Vulnerability {
    id: String,                              // Unique identifier (for source="osv": the CVE-/GHSA- advisory id)
    user_id: String,                         // Multi-tenant isolation
    project_id: String,                      // Multi-tenant isolation
    source: "nuclei" | "gvm" | "security_check" | "netlas" | "nmap_nse" | "graphql_scan" | "takeover_scan" | "vhost_sni_enum" | "cache_poisoning" | "osv",  // Scanner source
    name: String,                            // Vulnerability name
    description: String,                     // Description
    severity: "critical" | "high" | "medium" | "low" | "info",  // Always lowercase
    cvss_score: Float,                       // 0.0 to 10.0
})

Nuclei-specific properties (source = "nuclei"):

(:Vulnerability {
    // Example
    id: "sqli-error-based-artists-artist",
    source: "nuclei",
    name: "Error based SQL Injection",
    severity: "critical",

    // Template info
    template_id: "sqli-error-based",
    template_path: "dast/vulnerabilities/sqli/sqli-error-based.yaml",
    template_url: "https://cloud.projectdiscovery.io/public/sqli-error-based",
    category: "sqli",                        // xss, sqli, rce, lfi, ssrf, exposure, etc.
    tags: ["sqli", "error", "dast", "vuln"],
    authors: ["geeknik", "pdteam"],
    references: [],

    // Classification
    cwe_ids: ["CWE-89"],
    cves: ["CVE-2021-12345"],                // Associated CVEs (as property)
    cvss_metrics: "CVSS:3.1/AV:N/...",

    // Attack details
    matched_at: "http://testphp.vulnweb.com/artists.php?artist=3'",
    matcher_name: "",
    matcher_status: true,
    extractor_name: "mysql",
    extracted_results: ["SQL syntax; check the manual..."],

    // Request/Response details
    request_type: "http",                    // http, dns, tcp, etc.
    scheme: "http",
    host: "testphp.vulnweb.com",
    port: "80",
    path: "/artists.php",
    matched_ip: "44.228.249.3",

    // DAST specific
    is_dast_finding: true,
    fuzzing_method: "GET",
    fuzzing_parameter: "artist",
    fuzzing_position: "query",               // query, body, header, path

    // Template metadata
    max_requests: 3,

    // Reproduction
    curl_command: "curl -X 'GET' ...",
    raw_request: "GET /artists.php?artist=3' HTTP/1.1\nHost: ...",
    raw_response: "HTTP/1.1 200 OK\nConnection: close\n...",

    // Timestamp
    timestamp: datetime,
    discovered_at: datetime,
})

GVM-specific properties (source = "gvm"):

(:Vulnerability {
    // Example
    id: "gvm-1.3.6.1.4.1.25623.1.0.11213-15.160.68.117-8080",
    source: "gvm",
    name: "HTTP Debugging Methods (TRACE/TRACK) Enabled",
    severity: "medium",

    // OpenVAS NVT info
    oid: "1.3.6.1.4.1.25623.1.0.11213",     // NVT Object Identifier
    family: "Web Servers",                    // NVT family
    threat: "Medium",                         // GVM threat level

    // Target info
    target_ip: "15.160.68.117",
    target_port: 8080,
    target_hostname: "ec2-15-160-68-117.eu-south-1.compute.amazonaws.com",
    port_protocol: "tcp",

    // Remediation
    solution: "Disable the TRACE and TRACK methods...",
    solution_type: "Mitigation",
    cvss_vector: "AV:N/AC:M/Au:N/C:P/I:P/A:N",

    // Detection quality
    qod: 99,                                  // Quality of Detection (0-100)
    qod_type: "remote_vul",                   // Detection method type

    // CVE references (stored as property, no CVE node relationships)
    cve_ids: ["CVE-2003-1567", "CVE-2004-2320", "..."],

    // CISA & remediation status
    cisa_kev: false,                          // Listed in CISA Known Exploited Vulnerabilities
    remediated: false,                        // Marked as closed/patched by GVM re-scan

    // Scanner metadata
    scanner: "OpenVAS",
    scan_timestamp: "2026-02-12T23:09:59.655089",
})

Netlas-specific properties (source = "netlas"):

(:Vulnerability {
    // Example โ€” passive NVD-based vulnerability detection (version fingerprinting)
    id: "CVE-2021-44228",
    source: "netlas",
    name: "CVE-2021-44228",
    severity: "critical",
    cvss_score: 10.0,
    has_exploit: true,               // Known public exploit exists (from Netlas NVD data)
})

Nmap NSE-specific properties (source = "nmap_nse"):

(:Vulnerability {
    // Example -- NSE script vulnerability detection
    id: "nmap-nse-ftp-vsftpd-backdoor-21-192.168.1.10",
    source: "nmap_nse",
    name: "ftp-vsftpd-backdoor",               // NSE script ID
    severity: "critical",                       // Mapped from NSE state (VULNERABLE = critical)
    type: "nmap_nse",                           // Vulnerability type
    state: "VULNERABLE",                        // NSE script state (VULNERABLE, NOT VULNERABLE, etc.)
    output: "vsFTPd version 2.3.4 backdoor...", // Full NSE script output
    cve_id: "CVE-2011-2523",                    // CVE extracted from NSE output (regex CVE-\d{4}-\d+)
})

GraphQL-specific properties (source = "graphql_scan"):

(:Vulnerability {
    // Example -- introspection exposure
    id: "graphql_graphql_introspection_enabled_https___api_target_com__graphql",
    source: "graphql_scan",
    vulnerability_type: "graphql_introspection_enabled",  // or "graphql_sensitive_data_exposure"
    name: "GraphQL Introspection Enabled",
    severity: "medium",                                   // graphql_introspection_enabled = medium; sensitive_data = medium/high
    endpoint: "https://api.target.com/graphql",
    title: "GraphQL Introspection Query Enabled",
    description: "Introspection query returned full schema (23 queries, 8 mutations, 1 subscription)",
    evidence: "{\"queries_count\":23,\"mutations_count\":8,\"subscriptions_count\":1,\"sensitive_fields\":[\"User.hashedPassword\",\"User.apiKey\"],\"schema_hash\":\"sha256:...\"}",
    timestamp: datetime,
})

Vulnerability types emitted by graphql_scan:

  • graphql_introspection_enabled โ€” introspection query succeeded; schema extracted and stored on the Endpoint node (see the Endpoint block for graphql_queries, graphql_mutations, etc.)
  • graphql_sensitive_data_exposure โ€” schema contains fields matching sensitive-keyword heuristic (password, secret, token, apiKey, ssn, credit, cvv, etc.). Created as a separate Vulnerability when the introspection-enabled finding has โ‰ฅ1 sensitive field.

Vulnerability types emitted by graphql_cop (external scanner, Phase 2):

  • graphql_field_suggestions_enabled (LOW) โ€” "Did you mean X?" field suggestions leak schema even with introspection off
  • graphql_ide_exposed (LOW) โ€” GraphiQL / GraphQL Playground UI reachable
  • graphql_get_method_allowed (MEDIUM) โ€” GraphQL queries accepted via GET (CSRF vector)
  • graphql_get_based_mutation (MEDIUM) โ€” mutations executable via GET
  • graphql_post_csrf (MEDIUM) โ€” POST with application/x-www-form-urlencoded accepted (CSRF)
  • graphql_tracing_enabled (INFO) โ€” Apollo tracing extension leaks execution metadata
  • graphql_unhandled_error (INFO) โ€” exception stack traces returned to client
  • graphql_alias_overloading (HIGH, DoS) โ€” 101-alias query accepted, rate-limit bypass
  • graphql_batch_query_allowed (HIGH, DoS) โ€” 10+ queries batched in one POST
  • graphql_directive_overloading (HIGH, DoS) โ€” many repeated directives accepted
  • graphql_circular_introspection (HIGH, DoS) โ€” deep nested introspection triggers recursion

Each graphql_cop Vulnerability's evidence field is a JSON blob containing curl_verify (a reproducer cURL command), raw_severity (graphql-cop's uppercase severity), and graphql_cop_key (internal test identifier).

Deterministic ID pattern: graphql_{vulnerability_type}_{baseurl}_{path} (colons, slashes, dots replaced with _). Enables MERGE-based deduplication across re-scans AND across scanners โ€” if graphql_cop and the native scanner both detect introspection, they merge into one Vulnerability node.

Subdomain-takeover-specific properties (source = "takeover_scan"):

(:Vulnerability {
    id: "takeover_<sha1-hex16>",              // hash(hostname+provider+method)
    source: "takeover_scan",
    type: "subdomain_takeover",
    name: "Subdomain Takeover โ€” Heroku (CNAME)",
    severity: "high" | "medium" | "info",     // driven by verdict + scorer

    // Takeover-specific
    hostname: "promo.acme.com",                // subdomain flagged as takeover-prone
    cname_target: "acme-spring.herokuapp.com", // destination when CNAME-based (nullable)
    takeover_provider: "heroku",               // github-pages | heroku | aws-s3 | fastly | azure-* | ...
    takeover_method: "cname" | "dns" | "ns" | "mx" | "stale_a",
    confidence: 85,                            // 0..100 integer
    sources: ["subjack", "nuclei_takeover"],   // tools that confirmed the finding
    confirmation_count: 2,
    verdict: "confirmed" | "likely" | "manual_review",
    evidence: "Subjack confirmed Heroku takeover",
    tool_raw: "{...}",                         // JSON-encoded raw per-tool output (truncated to 50KB)
    first_seen: "2026-04-21T12:34:56Z",
    last_seen:  "2026-04-21T12:34:56Z",
})

Layered scanner: Subjack (DNS-first, Apache-2.0 Go binary baked into the recon image) + Nuclei with -t http/takeovers/ -t dns/ against alive URLs. Findings are deduplicated by (hostname, takeover_provider, takeover_method), then scored. A verdict of manual_review implies severity="info" to keep low-confidence findings out of the main alert stream unless the project's takeoverManualReviewAutoPublish setting is true. Relationship: (:Subdomain)-[:HAS_VULNERABILITY]->(:Vulnerability); falls back to (:Domain) for the apex.

VHost & SNI properties (source = "vhost_sni_enum"):

(:Vulnerability {
    id: "vhost_sni_{hostname}_{ip}_{port}_{layer}",  // Deterministic
    source: "vhost_sni_enum",
    type: "hidden_vhost" | "hidden_sni_route" | "host_header_bypass",
    name: "Hidden Virtual Host: admin.acme.com",
    severity: "high" | "medium" | "low" | "info",

    // VHost/SNI specific
    hostname: "admin.acme.com",                // Discovered hidden FQDN
    ip: "1.2.3.4",                              // Target IP that hosts it
    port: 443,
    scheme: "https" | "http",
    layer: "L7" | "L4" | "both",                // L7=Host header trick, L4=SNI trick, both=disagreement
    baseline_status: 403,                       // Status when curling raw IP (no Host)
    baseline_size: 548,                         // Body bytes for baseline
    observed_status: 200,                       // Status when host/SNI lie applied
    observed_size: 4823,                        // Body bytes for observed response
    size_delta: 4275,                           // observed_size - baseline_size
    internal_pattern_match: "admin",            // matched internal-keyword (admin/jenkins/k8s/...) or null
    matched_at: "https://admin.acme.com",
    first_seen: "2026-04-25T14:30:00Z",
    last_seen:  "2026-04-25T14:30:00Z",
})

Subdomain enrichment (set by VHost/SNI on existing Subdomain nodes):

(:Subdomain {
    vhost_tested: true,
    vhost_hidden: true,                        // Confirmed hidden vhost
    vhost_routing_layer: "L7" | "L4" | "both",
    vhost_status_code: 200,
    vhost_size_delta: 4275,
    sni_routed: true,                          // Proxy decided routing at TLS SNI
    vhost_tested_at: "2026-04-25T14:30:00Z",
})

IP enrichment (set by VHost/SNI on existing IP nodes):

(:IP {
    vhost_sni_tested: true,
    vhost_baseline_status: 403,
    vhost_baseline_size: 548,
    vhost_candidates_tested: 247,              // total candidate hostnames probed against this IP
    vhost_ports_tested: 2,                     // number of (port, scheme) pairs with a usable baseline
    hosts_hidden_vhosts: true,
    hidden_vhost_count: 3,
    is_reverse_proxy: true,                    // SNI routing differs from default โ†’ likely k8s ingress / NGINX / Cloudflare
    vhost_sni_tested_at: "2026-04-25T14:30:00Z",
})

VHost/SNI also creates a BaseURL node for each newly discovered hidden vhost (so Katana / Nuclei can scan it via partial recon follow-up). Relationships used: (:Subdomain)-[:HAS_VULNERABILITY]->(:Vulnerability), (:IP)-[:HAS_VULNERABILITY]->(:Vulnerability) (for host_header_bypass only), (:Subdomain)-[:HAS_BASE_URL]->(:BaseURL). No new node labels, no new relationship types.

Web cache poisoning properties (source = "cache_poisoning"):

(:Vulnerability {
    id: "cache_{user_id}_{project_id}_{technique}_{baseurl}_{path}_{vector}",  // Deterministic, MERGE-safe
    source: "cache_poisoning",
    vulnerability_type: "web_cache_poisoning",
    name: "Web Cache Poisoning via X-Forwarded-Host",
    severity: "critical" | "high" | "medium",
    cvss_score: 9.3,

    // Cache-poisoning specific
    cache_header: "X-Forwarded-Host",          // unkeyed header vector (or "" if param)
    cache_param: "",                            // unkeyed query-param vector (or "" if header)
    cache_vector_type: "header",                // "header" | "param" | "path" (path = deception)
    cache_impact: "stored_xss" | "open_redirect" | "deception" | "dos" | "reflected",
    cache_technique: "unkeyed_header" | "unkeyed_param" | "cache_deception" | "framework_next" | "framework_remix" | ...,
    confidence: 0.97,                           // 0โ€“1
    confidence_tier: "Confirmed" | "Strong" | "Tentative",
    cache_signals: ["x-cache: hit", "age: 30"], // cache fingerprint evidence
    cache_buster: "rdmncb=cb9f1a2b",            // isolated buster used (never poisoned the real entry)
    source_engine: "wcvs" | "hypothesis",       // WCVS breadth vs native pack
    poc_link: "https://target/home?rdmncb=...", // reproduction URL
    curl_verify: "curl -sk -H 'X-Forwarded-Host: ...' '...'",
    evidence: "{...}",                          // JSON: baseline/poisoned/clean hashes, canary
    matched_at: "https://target/home",
})

The cache module reuses the Vulnerability node (no new label) and wires (:Endpoint)-[:HAS_VULNERABILITY]->(:Vulnerability) plus (:BaseURL)-[:HAS_VULNERABILITY]->(:Vulnerability) (host-level), creating the BaseURL/Endpoint via MERGE if upstream crawling missed them. Only findings at or above WEB_CACHE_POISON_MIN_CONFIDENCE are persisted.

Constraints:

CREATE INDEX vuln_severity IF NOT EXISTS
FOR (v:Vulnerability) ON (v.severity);

CREATE INDEX vuln_category IF NOT EXISTS
FOR (v:Vulnerability) ON (v.category);

12. CVE

Known CVEs from technology-based lookup.

(:CVE {
    id: "CVE-2021-3618",                   // CVE ID (UNIQUE)
    cvss: 7.4,                              // CVSS score
    severity: "HIGH",                       // CRITICAL, HIGH, MEDIUM, LOW
    description: "ALPACA is an application layer...",
    published: datetime,
    source: "nvd",                          // Data source
    url: "https://nvd.nist.gov/vuln/detail/CVE-2021-3618",
    references: ["https://alpaca-attack.com/"]
})

Constraints:

CREATE CONSTRAINT cve_unique IF NOT EXISTS
FOR (c:CVE) REQUIRE c.id IS UNIQUE;

CREATE INDEX cve_severity IF NOT EXISTS
FOR (c:CVE) ON (c.severity);

CREATE INDEX cve_cvss IF NOT EXISTS
FOR (c:CVE) ON (c.cvss);

13. MitreData

CWE (Common Weakness Enumeration) data from MITRE enrichment. Each CVE can have a hierarchical chain of CWE nodes representing the weakness hierarchy from root to leaf CWE.

(:MitreData {
    id: "CVE-2021-3618-CWE-295",           // Unique ID (CVE + CWE combination)
    cve_id: "CVE-2021-3618",               // Parent CVE ID
    cwe_id: "CWE-295",                      // CWE identifier
    cwe_name: "Improper Certificate Validation",
    cwe_description: "The software does not validate, or incorrectly validates...",
    cwe_url: "https://cwe.mitre.org/data/definitions/295.html",
    abstraction: "Base",                    // Pillar, Class, Base, Variant
    is_leaf: true,                          // Is this the most specific CWE?
    platforms: ["Not Language-Specific"]    // Applicable platforms
})

Constraints:

CREATE CONSTRAINT mitredata_unique IF NOT EXISTS
FOR (m:MitreData) REQUIRE m.id IS UNIQUE;

A global reference node: no user_id / project_id. The old idx_mitredata_tenant indexed keys these nodes do not carry and is dropped by init_schema.


14. Capec

CAPEC (Common Attack Pattern Enumeration and Classification) nodes linked to CWE weaknesses. Only created when a CWE has non-empty related_capec data.

(:Capec {
    capec_id: "CAPEC-94",                  // CAPEC identifier (UNIQUE)
    numeric_id: 94,                         // Numeric ID
    name: "Man in the Middle Attack",
    description: "This type of attack targets the communication between two parties...",
    url: "https://capec.mitre.org/data/definitions/94.html",
    likelihood: "Medium",                   // High, Medium, Low
    severity: "Very High",                  // Very High, High, Medium, Low, Very Low
    prerequisites: "There are two components communicating with each other...",
    execution_flow: "[JSON stringified attack phases]",  // Attack execution steps
    related_cwes: ["CWE-295", "CWE-300"]   // Related CWE IDs
})

Constraints:

CREATE CONSTRAINT capec_unique IF NOT EXISTS
FOR (cap:Capec) REQUIRE cap.capec_id IS UNIQUE;

CREATE INDEX capec_id IF NOT EXISTS
FOR (c:Capec) ON (c.capec_id);

A global reference node: no user_id / project_id. The old idx_capec_tenant indexed keys these nodes do not carry and is dropped by init_schema.


15. DNSRecord

DNS records for subdomains.

(:DNSRecord {
    type: "A",                              // A, AAAA, MX, NS, TXT, CNAME, SOA
    value: "44.228.249.3",                  // Record value
    ttl: 300                                // Time to live (if available)
})

Constraints:

CREATE CONSTRAINT dnsrecord_unique IF NOT EXISTS
FOR (dns:DNSRecord) REQUIRE (dns.type, dns.value, dns.subdomain, dns.user_id, dns.project_id) IS UNIQUE;

16. Header

HTTP response headers (all captured headers).

(:Header {
    name: "X-Powered-By",                   // Header name
    value: "PHP/5.6.40-38+ubuntu20.04.1+deb.sury.org+1",
    is_security_header: false,              // Is this a security header?
    reveals_technology: true                // Does this reveal server tech?
})

Common headers to capture:

  • Server - Web server identification
  • X-Powered-By - Backend technology
  • X-AspNet-Version - .NET version
  • Content-Type - Content type info
  • Content-Encoding - Compression info
  • Security headers: X-Frame-Options, X-XSS-Protection, Content-Security-Policy, Strict-Transport-Security

Constraints:

CREATE CONSTRAINT header_unique IF NOT EXISTS
FOR (h:Header) REQUIRE (h.name, h.value, h.baseurl, h.user_id, h.project_id) IS UNIQUE;

17. Traceroute

Label: Traceroute Created by: GVM/OpenVAS scanner (log-level finding) Source: Network route discovery via ICMP/TCP traceroute

PropertyTypeDescription
target_ipStringTarget IP address
scanner_ipStringScanner/source IP address
hopsString[]Ordered list of hop IP addresses (scanner โ†’ target)
distanceIntegerNumber of network hops between scanner and target
sourceStringAlways "gvm"
scan_timestampStringWhen the GVM scan was performed
user_idStringTenant user ID
project_idStringTenant project ID

Relationships:

(IP)-[:HAS_TRACEROUTE]->(Traceroute)

Constraints:

CREATE CONSTRAINT traceroute_unique IF NOT EXISTS
FOR (tr:Traceroute) REQUIRE (tr.target_ip, tr.user_id, tr.project_id) IS UNIQUE;

Visual: Circle, dark cyan (#164e63), network layer family.


18. ExploitGvm

GVM/OpenVAS confirmed active exploitation. Created when a GVM "Active Check" NVT achieves QoD=100, meaning it actually executed a payload and received proof of compromise (e.g., command output showing uid=0(root)).

(:ExploitGvm {
    id: "gvm-exploit-{oid}-{ip}-{port}",       // Deterministic ID
    user_id: String,                             // Tenant user ID
    project_id: String,                          // Tenant project ID
    attack_type: "cve_exploit",                  // Always cve_exploit for GVM
    severity: "critical",                        // Always critical - confirmed compromise
    name: "Apache HTTP Server ... - Active Check",
    target_ip: "15.160.68.117",
    target_port: 8080,
    target_hostname: "ec2-...",
    port_protocol: "tcp",
    cve_ids: ["CVE-2021-42013"],
    cisa_kev: true,                              // CISA Known Exploited Vulnerabilities flag
    description: "By doing the following HTTP request: ... uid=0(root)",
    evidence: "By doing the following HTTP request: ... uid=0(root)",
    solution: "Update to version 2.4.52 or later.",
    oid: "1.3.6.1.4.1.25623.1.0.146871",        // OpenVAS NVT OID
    family: "Web Servers",
    qod: 100,                                    // Quality of Detection (always 100)
    cvss_score: 9.8,
    cvss_vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
    source: "gvm",
    scanner: "OpenVAS",
    scan_timestamp: "2026-02-12T23:09:59.655089"
})

Constraints:

CREATE CONSTRAINT exploitgvm_tenant_unique IF NOT EXISTS
FOR (e:ExploitGvm) REQUIRE (e.id, e.user_id, e.project_id) IS UNIQUE;

CREATE INDEX idx_exploitgvm_tenant IF NOT EXISTS
FOR (e:ExploitGvm) ON (e.user_id, e.project_id);

Relationships:

(ExploitGvm)-[:EXPLOITED_CVE]->(CVE)   // Only connection โ€” links to the exploited CVE

Visual: Diamond shape, orange-600 color (#ea580c), always-on glow, lightning bolt icon.


19. ExternalDomain

Foreign domains encountered during recon that are outside the target scope. These are informational only โ€” they are never scanned, probed, or attacked. They provide situational awareness about where the target's infrastructure redirects to or what domains share certificates/hosting with the target.

(:ExternalDomain {
    domain: "evil.com",                           // Foreign domain name (UNIQUE per tenant)
    user_id: "...",
    project_id: "...",

    // Discovery context
    sources: ["http_probe_redirect", "urlscan", "hakrawler", "jsluice"],  // How discovered (array)
    first_seen_at: datetime(),

    // Redirect context (from http_probe)
    redirect_from_urls: ["https://target.com/login"],   // In-scope URLs that redirected here
    redirect_to_urls: ["https://evil.com/landing"],     // The actual foreign URLs

    // Basic metadata (from whatever source provided it)
    status_codes_seen: ["200", "301"],
    titles_seen: ["Evil Landing Page"],
    servers_seen: ["nginx"],
    ips_seen: ["1.2.3.4"],
    countries_seen: ["CN"],

    times_seen: 3,                                // Total encounters across all sources
    updated_at: datetime()
})
PropertyTypeDescription
domainStringForeign domain name (UNIQUE per tenant)
sourcesString[]Discovery sources: http_probe_redirect, urlscan, gau, katana, hakrawler, jsluice, zap_ajax_spider, cert_discovery
first_seen_atDateTimeWhen first encountered
redirect_from_urlsString[]In-scope URLs that redirected to this domain
redirect_to_urlsString[]The actual foreign URLs encountered
status_codes_seenString[]HTTP status codes seen
titles_seenString[]Page titles seen
servers_seenString[]Server headers seen
ips_seenString[]IP addresses seen (from URLScan)
countries_seenString[]Countries seen (from URLScan)
times_seenIntegerTotal encounters across all sources
updated_atDateTimeLast updated

Constraints:

CREATE CONSTRAINT externaldomain_unique IF NOT EXISTS
FOR (ed:ExternalDomain) REQUIRE (ed.domain, ed.user_id, ed.project_id) IS UNIQUE;

CREATE INDEX idx_externaldomain_tenant IF NOT EXISTS
FOR (ed:ExternalDomain) ON (ed.user_id, ed.project_id);

Relationship:

(Domain)-[:HAS_EXTERNAL_DOMAIN]->(ExternalDomain)

Visual: Dashed circle, warm stone gray (#8b8178).


20. UserInput

User-provided values for partial recon runs. When a user triggers a partial recon (e.g., subdomain discovery) and adds custom input values, a UserInput node is created to track the provenance of those inputs and the results they produced.

Created by: recon/partial_recon.py (partial recon pipeline)

Properties:

PropertyTypeDescription
idString (UUID)Unique identifier (UNIQUE constraint)
input_typeStringType of input: "subdomains", "ips", "urls", "domains"
valuesString[]User-provided values
tool_idStringTool that was run (e.g., "SubdomainDiscovery")
sourceStringAlways "user"
statusString"running", "completed", "error"
statsStringJSON-encoded run statistics
created_atDateTimeWhen the partial recon was started
completed_atDateTimeWhen it finished (null if still running)

Constraints:

CREATE CONSTRAINT userinput_tenant_unique IF NOT EXISTS
FOR (ui:UserInput) REQUIRE (ui.id, ui.user_id, ui.project_id) IS UNIQUE;

CREATE INDEX idx_userinput_tenant IF NOT EXISTS
FOR (ui:UserInput) ON (ui.user_id, ui.project_id);

Relationships:

(Domain)-[:HAS_USER_INPUT]->(UserInput)    -- Domain has user-provided partial recon input
(UserInput)-[:PRODUCED]->(Subdomain)       -- Partial recon run produced this subdomain
(UserInput)-[:PRODUCED]->(IP)              -- Partial recon run produced this IP

21. Secret

Secrets discovered in live web resources (JavaScript files, configuration files, etc.) during reconnaissance. This is a generic, source-agnostic node: jsluice populates it now, but any future secret discovery tool can create the same node type.

Created by: resource_enum phase (jsluice secrets extraction)

Properties:

PropertyTypeDescription
idstringUnique identifier: secret-{user_id}-{project_id}-{dedup_hash}
user_idstringTenant isolation
project_idstringTenant isolation
secret_typestringType of secret: AWSAccessKey, APIKey, GCPCredential, GitHubToken, etc. (from jsluice kind)
severitystringhigh, medium, low, info
sourcestringDiscovery tool: jsluice (extensible to future tools)
source_urlstringURL of the file containing the secret (e.g., https://example.com/js/app.js)
base_urlstringParent BaseURL
samplestringRedacted sample โ€” first 6 characters + ...
discovered_atstringScan timestamp
updated_atdatetimeLast update time

Deduplication: secret_type + source_url + hash(data) + user_id + project_id. Every unique secret value gets its own node. Re-scans update existing nodes via MERGE.

Constraint:

CREATE CONSTRAINT secret_tenant_unique IF NOT EXISTS FOR (s:Secret) REQUIRE (s.id, s.user_id, s.project_id) IS UNIQUE

Relationship:

(BaseURL)-[:HAS_SECRET]->(Secret)

Visual: Circle, rose-600 (#e11d48) โ€” danger/attention-grabbing.


22. ThreatPulse

OTX threat intelligence pulses โ€” named threat reports associating indicators (IPs, domains) with adversaries, malware families, and attack patterns. Each pulse represents a community-published threat report on AlienVault OTX. Up to 10 pulses per indicator are stored.

Created by: recon/main_recon_modules/otx_enrich.py + graph_db/mixins/osint_mixin.py::update_graph_from_otx()

(:ThreatPulse {
    pulse_id: "5a5b3a4f7e09f83e10b4a123",     // OTX pulse ID (UNIQUE per tenant)
    user_id: "...",
    project_id: "...",
    name: "Lazarus Group C2 Infrastructure",  // Pulse title
    adversary: "Lazarus Group",               // Named threat actor (APT group, etc.)
    malware_families: ["BLINDINGCAN", "HOPLIGHT"],  // Associated malware names
    attack_ids: ["T1566", "T1059"],           // MITRE ATT&CK technique IDs
    tags: ["apt", "north-korea", "banking"],  // Free-form community tags (up to 10)
    tlp: "white",                             // Traffic Light Protocol: "white","green","amber","red"
    author_name: "researcher@example.com",    // Pulse author
    targeted_countries: ["US", "UK", "JP"],   // Countries targeted by this threat
    modified: "2026-01-15T10:00:00Z",         // Last modified timestamp from OTX
    created_at: datetime(),
    updated_at: datetime()
})

Second writer: supply-chain incident correlation. The same label is reused for an incident from the public supplychainattack.org catalog, written by recon/main_recon_modules/sca_intel_correlate.py + graph_db/mixins/supply_chain_mixin.py::update_graph_from_sca_intel(). Those nodes carry pulse_id: "sca-<incident_id>" and a distinct property set:

(:ThreatPulse {
    pulse_id: "sca-SCA-0001",         // "sca-" prefix distinguishes the writer
    name: "Compromised CDN script",   // incident title
    tags: ["compromised-cdn"],        // the incident's attack vectors
    author_name: "supplychainattack.org",
    sca_incident_id, sca_incident_url, sca_status,
    sca_summary,                      // THIRD-PARTY prose, attacker-influenceable
    sca_blast_radius,
    sca_remediation,                  // list of steps, capped at 20
    sca_feed_revised,                 // feed revision that produced this
    user_id, project_id, created_at, updated_at
})

adversary is deliberately left unset on these: the incident feed has no threat-actor field, and both the Red Zone route and the report roll pulse.adversary into an adversary list, so a fabricated value would propagate into a headline.

Constraints:

CREATE CONSTRAINT threatpulse_unique IF NOT EXISTS
FOR (tp:ThreatPulse) REQUIRE (tp.pulse_id, tp.user_id, tp.project_id) IS UNIQUE;

CREATE INDEX idx_threatpulse_tenant IF NOT EXISTS
FOR (tp:ThreatPulse) ON (tp.user_id, tp.project_id);

Relationships:

(IP)-[:APPEARS_IN_PULSE]->(ThreatPulse)
(Domain)-[:APPEARS_IN_PULSE]->(ThreatPulse)

// Supply-chain incident correlation. A DIFFERENT claim from the two above.
(BaseURL)-[:CONTACTS_MALICIOUS_HOST {matched_host, evidence, source_url, updated_at}]->(ThreatPulse)

APPEARS_IN_PULSE means "this asset of mine is named in the report". CONTACTS_MALICIOUS_HOST means "my target reached a third-party host that a published incident names" โ€” the host belongs to someone else. The two must not be conflated: reusing APPEARS_IN_PULSE for the second case would inject supply-chain incidents into the Red Zone's Domain/IP arms and the report's OTX section, where they would read as "your host is a known threat indicator".

The attacker host is never a node. It is not part of the target's attack surface, so it lives on the relationship in matched_host, which is part of the relationship's MERGE key โ€” one incident often names several attacker domains, and keying on the two nodes alone silently collapsed them onto one edge. evidence is graph-host-match (recon) or captured-traffic.

Visual: Circle, red-orange (#dc4a22) โ€” threat intelligence context.


23. Malware

Malware samples (file hashes) associated with IPs or domains as reported by OSINT tools (OTX, VirusTotal). This is a cross-tool node type: OTX malware and VirusTotal malware samples both produce Malware nodes, allowing correlation across sources.

Created by: graph_db/mixins/osint_mixin.py::update_graph_from_otx() (source=otx), and future VirusTotal malware integration (source=virustotal)

(:Malware {
    hash: "d41d8cd98f00b204e9800998ecf8427e",  // File hash (MD5 or SHA256, UNIQUE per tenant)
    user_id: "...",
    project_id: "...",
    hash_type: "md5",              // "md5", "sha256", "sha1", "unknown"
    file_type: "pe32",             // File type/class (e.g., "pe32", "pdf", "elf")
    file_name: "payload.exe",      // Original file name if available
    source: "otx",                 // Discovery source: "otx", "virustotal"
    first_seen: datetime(),
    updated_at: datetime()
})

Constraints:

CREATE CONSTRAINT malware_unique IF NOT EXISTS
FOR (m:Malware) REQUIRE (m.hash, m.user_id, m.project_id) IS UNIQUE;

CREATE INDEX idx_malware_tenant IF NOT EXISTS
FOR (m:Malware) ON (m.user_id, m.project_id);

Relationships:

(IP)-[:ASSOCIATED_WITH_MALWARE]->(Malware)
(Domain)-[:ASSOCIATED_WITH_MALWARE]->(Malware)

Visual: Circle, deep red (#991b1b) โ€” confirmed malware indicator.



๐Ÿ”— Relationships

Domain Relationships

// Domain owns subdomains. The inverse edge is written too, by most OSINT and
// recon writers, so a traversal may start from either end.
(Domain)-[:HAS_SUBDOMAIN]->(Subdomain)
(Subdomain)-[:BELONGS_TO]->(Domain)

// Domain resolves directly to an IP (keeps OSINT-discovered IPs from orphaning
// when no Subdomain sits between them)
(Domain)-[:HAS_IP]->(IP)

// Domain encountered foreign domains during recon. The ExternalDomain also
// points back at the Domain whose scan surfaced it.
(Domain)-[:HAS_EXTERNAL_DOMAIN]->(ExternalDomain)
(ExternalDomain)-[:DISCOVERED_BY]->(Domain)

// OTX: historical IP resolutions (domain/passive_dns endpoint)
(Domain)-[:HISTORICALLY_RESOLVED_TO {first_seen: "...", last_seen: "...", record_type: "A"}]->(IP)

// OTX threat intelligence
(Domain)-[:APPEARS_IN_PULSE]->(ThreatPulse)
(Domain)-[:ASSOCIATED_WITH_MALWARE]->(Malware)

// Partial recon user inputs
(Domain)-[:HAS_USER_INPUT]->(UserInput)

Subdomain Relationships

// Subdomain resolves to IP addresses
(Subdomain)-[:RESOLVES_TO {record_type: "A"}]->(IP)

// Subdomain has DNS records
(Subdomain)-[:HAS_DNS_RECORD]->(DNSRecord)

UserInput Relationships

// Partial recon run produced subdomains and IPs
(UserInput)-[:PRODUCED]->(Subdomain)
(UserInput)-[:PRODUCED]->(IP)

IP Relationships

// IP has open ports
(IP)-[:HAS_PORT]->(Port)

// Port runs a service
(Port)-[:RUNS_SERVICE]->(Service)

// Service serves URLs (web endpoints)
(Service)-[:SERVES_URL]->(BaseURL)

// Subdomain links directly to BaseURL (fallback when Service -[:SERVES_URL]-> is absent,
// e.g. port 80 redirected to HTTPS so httpx didn't probe it, but crawlers discovered URLs under it)
(Subdomain)-[:HAS_BASE_URL]->(BaseURL)

// OTX threat intelligence
(IP)-[:APPEARS_IN_PULSE]->(ThreatPulse)
(IP)-[:ASSOCIATED_WITH_MALWARE]->(Malware)

BaseURL Relationships

// Subdomain/Domain owns a BaseURL.
// NOTE the underscore: writers emit HAS_BASE_URL. `HAS_BASEURL` is a LEGACY
// spelling still on disk in older graphs (vhost and AI-surface data), so reads
// that must cover both write `[:HAS_BASE_URL|HAS_BASEURL]`.
(Subdomain)-[:HAS_BASE_URL]->(BaseURL)
(Domain)-[:HAS_BASE_URL]->(BaseURL)

// BaseURL has endpoints (discovered paths from vuln_scan)
(BaseURL)-[:HAS_ENDPOINT]->(Endpoint)

// Endpoint has parameters
(Endpoint)-[:HAS_PARAMETER]->(Parameter)

// BaseURL uses technologies (detected by httpx/wappalyzer)
(BaseURL)-[:USES_TECHNOLOGY {confidence: 100, detected_by: "httpx"}]->(Technology)

// BaseURL has TLS certificate (httpx over HTTPS)
(BaseURL)-[:HAS_CERTIFICATE]->(Certificate)

// IP has TLS certificate (GVM/Censys/FOFA/tlsx-discovered, incl. non-HTTP TLS ports)
(IP)-[:HAS_CERTIFICATE]->(Certificate)

// Certificate covers a hostname listed in its SAN (tlsx; wildcard-stripped).
// Makes SAN data traversable instead of a dead list property.
(Certificate)-[:COVERS_HOST]->(Subdomain)

// BaseURL has HTTP headers
(BaseURL)-[:HAS_HEADER]->(Header)

// BaseURL has discovered secrets (from jsluice or future tools)
(BaseURL)-[:HAS_SECRET]->(Secret)

// Security check vulnerabilities (missing headers, etc.) connect to BaseURL
(BaseURL)-[:HAS_VULNERABILITY]->(Vulnerability)
(Package)-[:HAS_VULNERABILITY]->(Vulnerability)   // supply-chain: CVE/GHSA, source='osv'

// Note: DAST vulnerabilities connect via Endpoint (FOUND_AT) and Parameter (AFFECTS_PARAMETER)
// rather than directly to BaseURL, to avoid redundant connections in the graph.
// Path: BaseURL -> Endpoint <- Vulnerability -> Parameter

Vulnerability Relationships

IMPORTANT: No Redundant Connections & No Isolated Nodes

Each vulnerability connects to exactly ONE existing parent node based on its context. This ensures vulnerabilities are always connected to the graph (no isolated nodes).

Finding TypeConnects ToWhy
IP-based URL (http://15.161.171.153)IP onlyURL host is an IP - connect to existing IP node
Hostname URL (https://example.com)BaseURL (existing)Connect to existing BaseURL from http_probe
Hostname URL (no BaseURL exists)Subdomain/DomainFallback to host node if BaseURL not found
Host-only (SSL issues on example.com:443)Subdomain onlyIt's about the host, not a specific URL
DAST findings (SQLi, XSS)Endpoint (via FOUND_AT)It's about the specific path/parameter

Key Rules:

  1. Never create isolated BaseURL nodes - only connect to existing nodes
  2. IP-based URLs connect to IP nodes - keeps direct IP access findings connected
  3. Hostname URLs try BaseURL first - falls back to Subdomain/Domain if not found

This avoids:

โŒ Subdomain -[:HAS_VULNERABILITY]-> Vulnerability
โŒ BaseURL -[:HAS_VULNERABILITY]-> Vulnerability  (same vuln, redundant!)

โŒ Creating isolated BaseURL nodes for IP-based URLs like http://15.161.171.153
   (These would have no connection to IP nodes in the graph)

Instead, use graph traversal to find related entities:

// Find all vulnerabilities for a subdomain (via BaseURL)
MATCH (s:Subdomain)-[:RESOLVES_TO]->(:IP)-[:HAS_PORT]->(:Port)
      -[:RUNS_SERVICE]->(:Service)-[:SERVES_URL]->(bu:BaseURL)
      -[:HAS_VULNERABILITY]->(v:Vulnerability)
WHERE s.name = $hostname
RETURN v

// Find direct IP access vulnerabilities
MATCH (s:Subdomain)-[:RESOLVES_TO]->(ip:IP)-[:HAS_VULNERABILITY]->(v:Vulnerability)
WHERE v.type IN ['direct_ip_http', 'direct_ip_https']
RETURN s.name, ip.address, v.name, v.severity
// Vulnerability affects parameter (the injectable parameter that was fuzzed)
(Vulnerability)-[:AFFECTS_PARAMETER]->(Parameter)

// Vulnerability found at endpoint (the path where the vulnerability was discovered)
(Vulnerability)-[:FOUND_AT]->(Endpoint)

// NOTE: Vulnerability nodes store CVE IDs as properties (cves list for nuclei,
// cve_ids list for GVM), NOT as relationships to CVE nodes.

// Security check vulnerabilities connect to the most specific EXISTING entity:
// Priority: IP (for IP-based URLs) > BaseURL > Subdomain/Domain

// - IP for IP-based URL findings (e.g., http://15.161.171.153 direct access)
//   Connects to existing IP node to stay integrated with graph
(IP)-[:HAS_VULNERABILITY]->(Vulnerability)

// - BaseURL for hostname URL findings (e.g., https://example.com missing headers)
//   Only connects to EXISTING BaseURL nodes (from http_probe)
(BaseURL)-[:HAS_VULNERABILITY]->(Vulnerability)

// - Subdomain/Domain for host-level findings (fallback when BaseURL doesn't exist)
(Subdomain)-[:HAS_VULNERABILITY]->(Vulnerability)
(Domain)-[:HAS_VULNERABILITY]->(Vulnerability)

Technology Relationships

// Technology has known CVEs (from CVE lookup via NVD, or from Nmap NSE scripts)
(Technology)-[:HAS_KNOWN_CVE]->(CVE)

// Only TWO edge types point at Technology, but each has several source labels
// depending on which scanner detected it.

// USES_TECHNOLOGY โ€” carries {confidence, detected_by} where the writer sets it
(Service)-[:USES_TECHNOLOGY]->(Technology)   // Nmap -sV, e.g. Service:ftp:21 -> vsftpd/2.3.4
(Port)-[:USES_TECHNOLOGY]->(Technology)      // GVM
(IP)-[:USES_TECHNOLOGY]->(Technology)        // GVM
(Endpoint)-[:USES_TECHNOLOGY]->(Technology)  // httpx / Wappalyzer, detected per-path-response
(BaseURL)-[:USES_TECHNOLOGY]->(Technology)   // AI-surface fallback when no Port/IP matched

// HAS_TECHNOLOGY
(Port)-[:HAS_TECHNOLOGY]->(Technology)       // Nmap -sV, e.g. Port:21/tcp -> vsftpd/2.3.4
(IP)-[:HAS_TECHNOLOGY]->(Technology)         // AI-surface fallback when the Port wasn't matched

// NSE vulnerability found on technology (e.g. ftp-vsftpd-backdoor -> vsftpd/2.3.4)
(Vulnerability)-[:FOUND_ON]->(Technology)

// NSE vulnerability affects port (e.g. ftp-vsftpd-backdoor -> Port:21/tcp)
(Vulnerability)-[:AFFECTS]->(Port)

// NSE vulnerability has CVE (e.g. ftp-vsftpd-backdoor -> CVE-2011-2523)
(Vulnerability)-[:HAS_CVE]->(CVE)

Nmap attack chain traversal -- find all exploitable services in one query:

MATCH (svc:Service)-[:USES_TECHNOLOGY]->(t:Technology)-[:HAS_KNOWN_CVE]->(c:CVE)
WHERE svc.project_id = $projectId
RETURN svc.name, svc.port_number, t.name, c.id

CVE/MITRE Relationships

// A finding cites a CVE. Note the CVE is a GLOBAL reference node, matched on
// its natural id with no tenant key (see "Global Reference Nodes" above).
(Vulnerability)-[:INCLUDES_CVE]->(CVE)

// CVE has CWE weakness data
(CVE)-[:HAS_CWE]->(MitreData)

// MitreData (CWE) links to CAPEC attack patterns
// Only created when CWE has non-empty related_capec
(MitreData)-[:HAS_CAPEC]->(Capec)

Supply-Chain Relationships

// A live target serves this dependency (Supply-Chain Recon, L2). Created only
// when the BaseURL already exists - the writer never invents a target node.
(BaseURL)-[:DEPENDS_ON]->(Package)

// A scanned repository depends on this package (Supply-Chain scan, repo input)
(GithubRepository)-[:DEPENDS_ON]->(Package)

// Both L1 anchors hang off the project's Domain, so a standalone supply-chain
// scan is part of the graph rather than a detached island. Created only when a
// Domain already exists; the writer never invents one.
(Domain)-[:HAS_SBOM_DOCUMENT]->(SbomDocument)
(Domain)-[:HAS_REPOSITORY]->(GithubRepository)

// An uploaded SBOM / lockfile listed this package (Supply-Chain scan, upload
// input). EVERY Package has one of these three parents: uploaded packages used
// to have none, which left them - and all of their Vulnerability nodes -
// unreachable, against the "No Isolated Nodes" rule above.
(SbomDocument)-[:DEPENDS_ON]->(Package)

// A package carries a malicious (OSV MAL-) or suspicious (GuardDog) verdict
(Package)-[:FLAGGED_AS]->(MalPackageFinding)

๐Ÿ“ Complete Graph Visualization

                                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                    โ”‚   Domain    โ”‚
                                    โ”‚ (user_id,   โ”‚
                                    โ”‚ project_id) โ”‚
                                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                           โ”‚
                                    HAS_SUBDOMAIN
                                           โ”‚
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚                 Subdomain                    โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                           โ”‚                         โ”‚
                    HAS_DNS_RECORD              RESOLVES_TO
                           โ”‚                         โ”‚
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”            โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚ DNSRecord   โ”‚            โ”‚    IP     โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜            โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                                                     โ”‚
                                                HAS_PORT
                                                     โ”‚
                                               โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”
                                               โ”‚   Port    โ”‚
                                               โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                                                     โ”‚
                                               RUNS_SERVICE
                                                     โ”‚
                                               โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”
                                               โ”‚  Service  โ”‚
                                               โ””โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”˜
                                                  โ”‚     โ”‚
                                            SERVES_URL  USES_TECHNOLOGY
                                                  โ”‚     โ”‚
                                            โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ” โ”‚
                                            โ”‚ BaseURL โ”‚โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                            โ””โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”˜ โ”‚             โ”‚
                                              โ”‚   โ”‚     โ”‚         HAS_HEADER
                                     HAS_ENDPOINT โ”‚     โ”‚             โ”‚
                                              โ”‚   โ”‚ USES_TECHNOLOGY   โ”‚
                                              โ”‚   โ”‚     โ”‚         โ”Œโ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”
                                              โ”‚   โ”‚     โ”‚         โ”‚Header โ”‚
                                              โ”‚   โ”‚     โ”‚         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                              โ”‚   โ”‚     โ”‚
                                        โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”โ”‚     โ”‚
                                        โ”‚Endpointโ”‚โ”‚     โ”‚
                                        โ””โ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”˜โ”‚ โ”Œโ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                           โ”‚  โ”‚   โ”‚ โ”‚  Technology  โ”‚
                                    HAS_PARAMETER โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                           โ”‚  โ”‚   โ”‚         โ”‚
                                           โ”‚  โ”‚FOUND_AT     โ”‚
                                           โ”‚  โ”‚   โ”‚   HAS_KNOWN_CVE
                                     โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”ผโ”€โ” โ”‚         โ”‚
                                     โ”‚Parameterโ”‚ โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”
                                     โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”˜ โ”‚  โ”‚     CVE     โ”‚
                                           โ”‚  โ”‚   โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                 AFFECTS_PARAMETERโ”‚
                                           โ”‚  โ”‚   โ”‚
                                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ–ผโ”€โ”€โ”€โ”ค
                                    โ”‚ Vulnerabilityโ”‚ (CVE IDs stored as properties)
                                    โ”‚  (DAST)      โ”‚
                                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜


Security Check Vulnerabilities connect to EXISTING nodes only:

    IP-based URL findings (http://15.161.171.153):
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  HAS_VULNERABILITY   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚   IP   โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ Vulnerability โ”‚
    โ”‚(exists)โ”‚                      โ”‚ (direct_ip_*) โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

    Hostname URL findings (https://example.com):
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  HAS_VULNERABILITY   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚ BaseURL โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ Vulnerability โ”‚
    โ”‚ (exists)โ”‚                      โ”‚ (missing_hdr) โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Note: Each vulnerability connects to exactly ONE EXISTING parent:
  - IP-based URL โ†’ IP node (keeps direct IP findings connected to graph)
  - Hostname URL โ†’ existing BaseURL (from http_probe)
  - Hostname URL (no BaseURL) โ†’ Subdomain/Domain (fallback)

Note: Subdomain -[:HAS_BASE_URL]-> BaseURL is a fallback relationship created when
resource_enum discovers URLs under a base URL that httpx didn't probe (e.g. port 80
redirected to HTTPS). This prevents orphaned BaseURL clusters in the graph.
  - No isolated nodes are created!

๐Ÿ” Key Query Patterns

Most of these walk the full chain Domain -> Subdomain -> IP -> Port -> Service -> BaseURL. That chain is not guaranteed: a Subdomain may link straight to a BaseURL when SERVES_URL is absent (port 80 redirected to HTTPS, so httpx never probed it but a crawler still found URLs under it). Widen the middle to -[:RESOLVES_TO|HAS_PORT|RUNS_SERVICE|SERVES_URL|HAS_BASE_URL*1..4]-> when a query must not miss those hosts. Technologies attach to Endpoint (httpx, Wappalyzer), Service/Port/IP (Nmap, GVM) โ€” not to BaseURL except as an AI-surface fallback; see "Technology Relationships".

1. Get All Assets for a Project

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
MATCH path = (d)-[*1..5]->(n)
RETURN d, path

2. Find Attack Surface (All Parameters)

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->(s:Subdomain)
      -[:RESOLVES_TO]->(ip:IP)
      -[:HAS_PORT]->(p:Port)
      -[:RUNS_SERVICE]->(svc:Service)
      -[:SERVES_URL]->(u:BaseURL)
      -[:HAS_ENDPOINT]->(e:Endpoint)
      -[:HAS_PARAMETER]->(param:Parameter)
RETURN s.name AS host, svc.name AS service, p.number AS port, e.path AS endpoint, param.name AS parameter

3. Find All Critical Vulnerabilities

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->(s)
      -[:RESOLVES_TO]->(:IP)
      -[:HAS_PORT]->(:Port)
      -[:RUNS_SERVICE]->(svc:Service)
      -[:SERVES_URL]->(u:BaseURL)
      -[:HAS_ENDPOINT]->(e:Endpoint)<-[:FOUND_AT]-(v:Vulnerability {severity: "critical"})
RETURN s.name AS host, svc.name AS service, u.url AS url, v.name AS vulnerability, v.matched_at AS proof

4. Technology to CVE Mapping (Risk Assessment)

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->(s)
      -[:RESOLVES_TO]->(:IP)
      -[:HAS_PORT]->(port:Port)
      -[:RUNS_SERVICE]->(svc:Service)
      -[:SERVES_URL]->(u:BaseURL)
      -[:HAS_ENDPOINT]->(e:Endpoint)
      -[:USES_TECHNOLOGY]->(t:Technology)
      -[:HAS_KNOWN_CVE]->(c:CVE)
WHERE c.cvss >= 7.0
RETURN t.name AS technology, t.version AS version, svc.name AS service, port.number AS port,
       collect({cve: c.id, cvss: c.cvss, severity: c.severity}) AS cves,
       max(c.cvss) AS top_cvss
ORDER BY top_cvss DESC

5. Find Potential Attack Paths (SQLi to Database)

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->(s)
      -[:RESOLVES_TO]->(:IP)
      -[:HAS_PORT]->(port:Port)
      -[:RUNS_SERVICE]->(svc:Service)
      -[:SERVES_URL]->(u:BaseURL)
      -[:HAS_ENDPOINT]->(e:Endpoint)<-[:FOUND_AT]-(v:Vulnerability)
WHERE v.category = "sqli"
MATCH (e)-[:USES_TECHNOLOGY]->(t:Technology)
WHERE t.name IN ["MySQL", "PostgreSQL", "MSSQL", "Oracle"]
RETURN s.name AS host, svc.name AS service, port.number AS port, v.matched_at AS injection_point,
       v.extracted_results AS evidence, t.name AS database

6. Get Complete Host Profile

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->(s:Subdomain {name: $hostname})
OPTIONAL MATCH (s)-[:RESOLVES_TO]->(ip:IP)
OPTIONAL MATCH (ip)-[:HAS_PORT]->(port:Port)-[:RUNS_SERVICE]->(svc:Service)
OPTIONAL MATCH (svc)-[:SERVES_URL]->(u:BaseURL)
OPTIONAL MATCH (u)-[:HAS_ENDPOINT]->(e:Endpoint)
OPTIONAL MATCH (e)-[:USES_TECHNOLOGY]->(tech:Technology)
OPTIONAL MATCH (e)<-[:FOUND_AT]-(vuln:Vulnerability)
RETURN s, collect(DISTINCT ip) AS ips,
       collect(DISTINCT {port: port.number, service: svc.name}) AS services,
       collect(DISTINCT tech.name) AS technologies,
       collect(DISTINCT {name: vuln.name, severity: vuln.severity}) AS vulnerabilities

7. Vulnerability Summary by Category

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->()
      -[:RESOLVES_TO]->(:IP)
      -[:HAS_PORT]->(:Port)
      -[:RUNS_SERVICE]->(:Service)
      -[:SERVES_URL]->(:BaseURL)
      -[:HAS_ENDPOINT]->(:Endpoint)<-[:FOUND_AT]-(v:Vulnerability)
RETURN v.category AS category,
       count(v) AS count,
       collect(DISTINCT v.severity) AS severities
ORDER BY count DESC

8. Most Common Vulnerability Types

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->()
      -[:RESOLVES_TO]->(:IP)
      -[:HAS_PORT]->(:Port)
      -[:RUNS_SERVICE]->(:Service)
      -[:SERVES_URL]->(:BaseURL)
      -[:HAS_ENDPOINT]->(:Endpoint)<-[:FOUND_AT]-(v:Vulnerability)
RETURN v.template_id, v.name, v.severity, count(v) AS findings_count
ORDER BY findings_count DESC
LIMIT 10

9. Find All Injectable Parameters (Attack Surface)

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->(s)
      -[:RESOLVES_TO]->(:IP)
      -[:HAS_PORT]->(port:Port)
      -[:RUNS_SERVICE]->(svc:Service)
      -[:SERVES_URL]->(u:BaseURL)
      -[:HAS_ENDPOINT]->(e)
      -[:HAS_PARAMETER]->(p:Parameter {is_injectable: true})
OPTIONAL MATCH (v:Vulnerability)-[:AFFECTS_PARAMETER]->(p)
RETURN s.name AS host, svc.name AS service, port.number AS port, e.path AS endpoint, p.name AS parameter,
       p.position AS position, collect(v.category) AS vuln_types

10. HTTP Headers Analysis (Security Headers Check)

MATCH (d:Domain {user_id: $user_id, project_id: $project_id})
      -[:HAS_SUBDOMAIN]->(s)
      -[:RESOLVES_TO]->(:IP)
      -[:HAS_PORT]->(:Port)
      -[:RUNS_SERVICE]->(svc:Service)
      -[:SERVES_URL]->(u:BaseURL)
      -[:HAS_HEADER]->(h:Header)
WHERE h.is_security_header = true OR h.reveals_technology = true
RETURN s.name AS host, svc.name AS service, u.url AS url,
       collect({header: h.name, value: h.value, security: h.is_security_header}) AS headers

๐Ÿ“‹ Node Property Summary

NodeKey PropertiesIndexed
Domainname, user_id, project_id, target, modules_executed, whois_*, anonymous_mode, bruteforce_modeโœ… Unique (tenant), โœ… Tenant index
Subdomainname, has_dns_recordsโœ… Unique (tenant), โœ… Tenant index
IPaddress, version, is_cdn, cdn_name, asnโœ… Unique (tenant), โœ… Tenant index
Portnumber, protocol, state, ip_addressโœ… Unique (tenant), โœ… Tenant index
Servicename, product, version, banner, port_number, ip_addressโœ… Unique (tenant), โœ… Tenant index
BaseURLurl, scheme, host, status_code, is_live, body_sha256โœ… Unique (tenant), โœ… Tenant index
Endpointpath, method, baseurl, has_parameters, sourceโœ… Unique (tenant), โœ… Tenant index
Parametername, position, endpoint_path, baseurl, is_injectable, sample_valueโœ… Unique (tenant), โœ… Tenant index
Technologyname, version, categories, confidence, product, known_cve_countโœ… Unique (tenant), โœ… Tenant index
Certificatecert_key, subject_cn, issuer, not_before, not_after, source, observed_byโœ… Unique (tenant), โœ… Tenant index
DNSRecordtype, value, subdomain, ttlโœ… Unique (tenant), โœ… Tenant index
Headername, value, baseurl, is_security_headerโœ… Unique (tenant), โœ… Tenant index
Traceroutetarget_ip, scanner_ip, hops, distance, sourceโœ… Unique (tenant), โœ… Tenant index
Vulnerabilityid, template_id, severity, category, matched_at, fuzzing_*, raw_request, raw_response, matched_ipโœ… Unique (tenant), โœ… Tenant index
CVEid, cvss, severity, description, publishedโœ… Unique (global) โ€” reference node, no tenant keys
MitreDataid, cve_id, cwe_id, cwe_name, cwe_description, abstraction, is_leafโœ… Unique (global) โ€” reference node, no tenant keys
Capeccapec_id, name, description, likelihood, severity, prerequisitesโœ… Unique (global) โ€” reference node, no tenant keys
ExploitGvmid, sourceโœ… Unique (tenant), โœ… Tenant index
GithubHuntid, target, scan_start_time, status, repos_scanned, secrets_foundโœ… Unique (tenant), โœ… Tenant index
GithubRepositoryid, nameโœ… Unique (tenant), โœ… Tenant index
SbomDocumentid, nameโœ… Unique (tenant), โœ… Tenant index
GithubPathid, repository, pathโœ… Unique (tenant), โœ… Tenant index
GithubSecretid, repository, path, secret_type, sampleโœ… Unique (tenant), โœ… Tenant index
GithubSensitiveFileid, repository, path, secret_typeโœ… Unique (tenant), โœ… Tenant index
MultiscannerScanid, source, target, status, total_findings, validated_findings, assets_scannedโœ… Unique (tenant), โœ… Tenant index
MultiscannerRepositoryid, name, source, asset_kindโœ… Unique (tenant), โœ… Tenant index
MultiscannerImageid, name, source, asset_kindโœ… Unique (tenant), โœ… Tenant index
MultiscannerModelid, name, source, asset_kindโœ… Unique (tenant), โœ… Tenant index
MultiscannerBucketid, name, source, asset_kindโœ… Unique (tenant), โœ… Tenant index
MultiscannerEndpointid, name, source, asset_kindโœ… Unique (tenant), โœ… Tenant index
MultiscannerFindingid, source, detector_name, validation_status, finding_kind, asset, location, lineโœ… Unique (tenant), โœ… Tenant index
Secretid, secret_type, severity, source, source_url, base_url, sampleโœ… Unique (tenant), โœ… Tenant index
JsReconFindingid, finding_type, severity, confidence, title, detail, source_url, package_name, package_versionโœ… Unique (tenant), โœ… Tenant index
Packagepurl, ecosystem, name, version, source, source_path, first_seen, last_seenโœ… Unique (tenant), โœ… Tenant index
MalPackageFindingfinding_id, verdict, source_tool, advisory_id, severity, confidence, title, detail, soft_error, aliases, incident_id, incident_url, incident_summary, incident_blast_radius, incident_remediation, incident_status, incident_feed_revisedโœ… Unique (tenant), โœ… Tenant index
ExternalDomaindomain, sources, first_seen_at, ips_seen, times_seen, status_codes_seenโœ… Unique (tenant), โœ… Tenant index
UserInputid, input_type, values, tool_id, source, status, statsโœ… Unique (tenant), โœ… Tenant index
ThreatPulsepulse_id, name, adversary, malware_families, attack_ids, tags, tlpโœ… Unique (tenant), โœ… Tenant index
Malwarehash, hash_type, file_type, file_name, source, first_seenโœ… Unique (tenant), โœ… Tenant index
AttackChainchain_id, title, objective, status, attack_path_type, total_steps, final_outcomeโœ… Unique (global, UUID), โœ… Tenant index
ChainStepstep_id, chain_id, tool_name, phase, success, fireteam_id, agent_idโœ… Unique (global, UUID), โœ… Tenant index
ChainFindingfinding_id, chain_id, finding_type, severity, confidence, phase, fireteam_id, agent_id, source_agentโœ… Unique (global, UUID), โœ… Tenant index
ChainDecisiondecision_id, chain_idโœ… Unique (global, UUID), โœ… Tenant index
ChainFailurefailure_id, failure_type, chain_idโœ… Unique (global, UUID), โœ… Tenant index
KBChunkchunk_idโœ… Unique (global) โ€” knowledge base, content is universal

The Chain* keys are UUIDs (uuid.uuid4()), so a global uniqueness constraint carries no cross-project collision risk; the tenant index is what serves the per-project reads. Every other per-project label is keyed on the tenant triple โ€” see the G2 note in DROP_LEGACY_CONSTRAINTS (graph_db/schema.py) for what an id-only key did to two projects scanning the same target.


๐Ÿš€ Initialization Cypher

init_schema (graph_db/schema.py) applies all of this automatically: it runs from BaseMixin.__init__, so every scan-container spawn and every agent graph call re-asserts it. Every statement is guarded by IF NOT EXISTS / IF EXISTS and is therefore idempotent. The block below is that schema reproduced for reference โ€” graph_db/schema.py is the source of truth, not this listing.

Four migrations run BEFORE the DDL, because a constraint on a new label or key cannot be satisfied while data still carries the old one: migrate_legacy_labels (Trufflehog -> Multiscanner), backfill_updated_at, strip_reference_node_tenant (removes tenant keys from CVE / MitreData / Capec) and backfill_cert_key. Each is guarded by a :RedamonSchemaMigration {id} marker node, so the steady-state cost is one lookup rather than a full scan per label on every client construction. That marker describes the DATABASE, not a project, so like the reference labels it carries no tenant key. The marker is written only after every step of a migration succeeded, so a partial run retries on the next connection instead of silently stopping half-way.

// =============================================================================
// DROP โ€” legacy constraints/indexes superseded by the tenant-scoped ones
// =============================================================================

DROP CONSTRAINT subdomain_unique IF EXISTS;

DROP CONSTRAINT ip_unique IF EXISTS;

DROP CONSTRAINT baseurl_unique IF EXISTS;

DROP CONSTRAINT trufflehogscan_unique IF EXISTS;

DROP CONSTRAINT trufflehogrepository_unique IF EXISTS;

DROP CONSTRAINT trufflehogfinding_unique IF EXISTS;

DROP CONSTRAINT trufflehogimage_unique IF EXISTS;

DROP CONSTRAINT trufflehogmodel_unique IF EXISTS;

DROP CONSTRAINT trufflehogbucket_unique IF EXISTS;

DROP CONSTRAINT trufflehogendpoint_unique IF EXISTS;

DROP INDEX idx_trufflehogscan_tenant IF EXISTS;

DROP INDEX idx_trufflehogrepository_tenant IF EXISTS;

DROP INDEX idx_trufflehogfinding_tenant IF EXISTS;

DROP INDEX idx_trufflehogimage_tenant IF EXISTS;

DROP INDEX idx_trufflehogmodel_tenant IF EXISTS;

DROP INDEX idx_trufflehogbucket_tenant IF EXISTS;

DROP INDEX idx_trufflehogendpoint_tenant IF EXISTS;

DROP INDEX idx_trufflehogfinding_detector IF EXISTS;

DROP INDEX idx_trufflehogfinding_source IF EXISTS;

DROP INDEX idx_trufflehogfinding_validation IF EXISTS;

DROP INDEX idx_trufflehogscan_source IF EXISTS;

DROP INDEX idx_trufflehogrepository_name IF EXISTS;

DROP INDEX idx_trufflehogimage_name IF EXISTS;

DROP INDEX idx_trufflehogmodel_name IF EXISTS;

DROP INDEX idx_trufflehogbucket_name IF EXISTS;

DROP INDEX idx_trufflehogendpoint_name IF EXISTS;

DROP CONSTRAINT certificate_unique IF EXISTS;

DROP CONSTRAINT vulnerability_unique IF EXISTS;

DROP CONSTRAINT exploitgvm_unique IF EXISTS;

DROP CONSTRAINT githubhunt_unique IF EXISTS;

DROP CONSTRAINT githubrepo_unique IF EXISTS;

DROP CONSTRAINT githubpath_unique IF EXISTS;

DROP CONSTRAINT githubsecret_unique IF EXISTS;

DROP CONSTRAINT githubsensitivefile_unique IF EXISTS;

DROP CONSTRAINT sbomdoc_unique IF EXISTS;

DROP CONSTRAINT jsreconfinding_unique IF EXISTS;

DROP CONSTRAINT secret_unique IF EXISTS;

DROP CONSTRAINT userinput_unique IF EXISTS;

DROP CONSTRAINT exploit_unique IF EXISTS;

DROP INDEX idx_cve_tenant IF EXISTS;

DROP INDEX idx_mitredata_tenant IF EXISTS;

DROP INDEX idx_capec_tenant IF EXISTS;

DROP INDEX idx_exploit_type IF EXISTS;

// =============================================================================
// CONSTRAINTS โ€” tenant-scoped, except the global reference labels
// =============================================================================

CREATE CONSTRAINT domain_unique IF NOT EXISTS
FOR (d:Domain) REQUIRE (d.name, d.user_id, d.project_id) IS UNIQUE;

CREATE CONSTRAINT subdomain_unique IF NOT EXISTS
FOR (s:Subdomain) REQUIRE (s.name, s.user_id, s.project_id) IS UNIQUE;

CREATE CONSTRAINT ip_unique IF NOT EXISTS
FOR (i:IP) REQUIRE (i.address, i.user_id, i.project_id) IS UNIQUE;

CREATE CONSTRAINT baseurl_unique IF NOT EXISTS
FOR (u:BaseURL) REQUIRE (u.url, u.user_id, u.project_id) IS UNIQUE;

CREATE CONSTRAINT port_unique IF NOT EXISTS
FOR (p:Port) REQUIRE (p.number, p.protocol, p.ip_address, p.user_id, p.project_id) IS UNIQUE;

CREATE CONSTRAINT service_unique IF NOT EXISTS
FOR (svc:Service) REQUIRE (svc.name, svc.port_number, svc.ip_address, svc.user_id, svc.project_id) IS UNIQUE;

CREATE CONSTRAINT technology_unique IF NOT EXISTS
FOR (t:Technology) REQUIRE (t.name, t.version, t.user_id, t.project_id) IS UNIQUE;

CREATE CONSTRAINT endpoint_unique IF NOT EXISTS
FOR (e:Endpoint) REQUIRE (e.path, e.method, e.baseurl, e.user_id, e.project_id) IS UNIQUE;

CREATE CONSTRAINT parameter_unique IF NOT EXISTS
FOR (p:Parameter) REQUIRE (p.name, p.position, p.endpoint_path, p.baseurl, p.user_id, p.project_id) IS UNIQUE;

CREATE CONSTRAINT header_unique IF NOT EXISTS
FOR (h:Header) REQUIRE (h.name, h.value, h.baseurl, h.user_id, h.project_id) IS UNIQUE;

CREATE CONSTRAINT dnsrecord_unique IF NOT EXISTS
FOR (dns:DNSRecord) REQUIRE (dns.type, dns.value, dns.subdomain, dns.user_id, dns.project_id) IS UNIQUE;

CREATE CONSTRAINT certificate_key_unique IF NOT EXISTS
FOR (c:Certificate) REQUIRE (c.cert_key, c.user_id, c.project_id) IS UNIQUE;

CREATE CONSTRAINT traceroute_unique IF NOT EXISTS
FOR (tr:Traceroute) REQUIRE (tr.target_ip, tr.user_id, tr.project_id) IS UNIQUE;

CREATE CONSTRAINT cve_unique IF NOT EXISTS
FOR (c:CVE) REQUIRE c.id IS UNIQUE;

CREATE CONSTRAINT mitredata_unique IF NOT EXISTS
FOR (m:MitreData) REQUIRE m.id IS UNIQUE;

CREATE CONSTRAINT capec_unique IF NOT EXISTS
FOR (cap:Capec) REQUIRE cap.capec_id IS UNIQUE;

CREATE CONSTRAINT vulnerability_tenant_unique IF NOT EXISTS
FOR (v:Vulnerability) REQUIRE (v.id, v.user_id, v.project_id) IS UNIQUE;

CREATE CONSTRAINT exploitgvm_tenant_unique IF NOT EXISTS
FOR (e:ExploitGvm) REQUIRE (e.id, e.user_id, e.project_id) IS UNIQUE;

CREATE CONSTRAINT githubhunt_tenant_unique IF NOT EXISTS
FOR (gh:GithubHunt) REQUIRE (gh.id, gh.user_id, gh.project_id) IS UNIQUE;

CREATE CONSTRAINT githubrepo_tenant_unique IF NOT EXISTS
FOR (gr:GithubRepository) REQUIRE (gr.id, gr.user_id, gr.project_id) IS UNIQUE;

CREATE CONSTRAINT githubpath_tenant_unique IF NOT EXISTS
FOR (gp:GithubPath) REQUIRE (gp.id, gp.user_id, gp.project_id) IS UNIQUE;

CREATE CONSTRAINT package_unique IF NOT EXISTS
FOR (p:Package) REQUIRE (p.purl, p.user_id, p.project_id) IS UNIQUE;

CREATE CONSTRAINT sbomdoc_tenant_unique IF NOT EXISTS
FOR (d:SbomDocument) REQUIRE (d.id, d.user_id, d.project_id) IS UNIQUE;

CREATE CONSTRAINT malpackagefinding_unique IF NOT EXISTS
FOR (mf:MalPackageFinding) REQUIRE (mf.finding_id, mf.user_id, mf.project_id) IS UNIQUE;

CREATE CONSTRAINT githubsecret_tenant_unique IF NOT EXISTS
FOR (gs:GithubSecret) REQUIRE (gs.id, gs.user_id, gs.project_id) IS UNIQUE;

CREATE CONSTRAINT githubsensitivefile_tenant_unique IF NOT EXISTS
FOR (gsf:GithubSensitiveFile) REQUIRE (gsf.id, gsf.user_id, gsf.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannerscan_unique IF NOT EXISTS
FOR (ts:MultiscannerScan) REQUIRE (ts.id, ts.user_id, ts.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannerrepository_unique IF NOT EXISTS
FOR (tr:MultiscannerRepository) REQUIRE (tr.id, tr.user_id, tr.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannerfinding_unique IF NOT EXISTS
FOR (tf:MultiscannerFinding) REQUIRE (tf.id, tf.user_id, tf.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannerimage_unique IF NOT EXISTS
FOR (ti:MultiscannerImage) REQUIRE (ti.id, ti.user_id, ti.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannermodel_unique IF NOT EXISTS
FOR (tm:MultiscannerModel) REQUIRE (tm.id, tm.user_id, tm.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannerbucket_unique IF NOT EXISTS
FOR (tb:MultiscannerBucket) REQUIRE (tb.id, tb.user_id, tb.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannerendpoint_unique IF NOT EXISTS
FOR (te:MultiscannerEndpoint) REQUIRE (te.id, te.user_id, te.project_id) IS UNIQUE;

CREATE CONSTRAINT jsreconfinding_tenant_unique IF NOT EXISTS
FOR (jf:JsReconFinding) REQUIRE (jf.id, jf.user_id, jf.project_id) IS UNIQUE;

CREATE CONSTRAINT secret_tenant_unique IF NOT EXISTS
FOR (s:Secret) REQUIRE (s.id, s.user_id, s.project_id) IS UNIQUE;

CREATE CONSTRAINT externaldomain_unique IF NOT EXISTS
FOR (ed:ExternalDomain) REQUIRE (ed.domain, ed.user_id, ed.project_id) IS UNIQUE;

CREATE CONSTRAINT threatpulse_unique IF NOT EXISTS
FOR (tp:ThreatPulse) REQUIRE (tp.pulse_id, tp.user_id, tp.project_id) IS UNIQUE;

CREATE CONSTRAINT malware_unique IF NOT EXISTS
FOR (m:Malware) REQUIRE (m.hash, m.user_id, m.project_id) IS UNIQUE;

CREATE CONSTRAINT attack_chain_id IF NOT EXISTS
FOR (ac:AttackChain) REQUIRE ac.chain_id IS UNIQUE;

CREATE CONSTRAINT chain_step_id IF NOT EXISTS
FOR (s:ChainStep) REQUIRE s.step_id IS UNIQUE;

CREATE CONSTRAINT chain_finding_id IF NOT EXISTS
FOR (f:ChainFinding) REQUIRE f.finding_id IS UNIQUE;

CREATE CONSTRAINT chain_decision_id IF NOT EXISTS
FOR (d:ChainDecision) REQUIRE d.decision_id IS UNIQUE;

CREATE CONSTRAINT chain_failure_id IF NOT EXISTS
FOR (fl:ChainFailure) REQUIRE fl.failure_id IS UNIQUE;

CREATE CONSTRAINT kb_chunk_id IF NOT EXISTS
FOR (c:KBChunk) REQUIRE c.chunk_id IS UNIQUE;

CREATE CONSTRAINT userinput_tenant_unique IF NOT EXISTS
FOR (ui:UserInput) REQUIRE (ui.id, ui.user_id, ui.project_id) IS UNIQUE;

// =============================================================================
// TENANT COMPOSITE INDEXES โ€” one per per-project label
// =============================================================================

CREATE INDEX idx_domain_tenant IF NOT EXISTS
FOR (d:Domain) ON (d.user_id, d.project_id);

CREATE INDEX idx_subdomain_tenant IF NOT EXISTS
FOR (s:Subdomain) ON (s.user_id, s.project_id);

CREATE INDEX idx_ip_tenant IF NOT EXISTS
FOR (i:IP) ON (i.user_id, i.project_id);

CREATE INDEX idx_port_tenant IF NOT EXISTS
FOR (p:Port) ON (p.user_id, p.project_id);

CREATE INDEX idx_dnsrecord_tenant IF NOT EXISTS
FOR (dns:DNSRecord) ON (dns.user_id, dns.project_id);

CREATE INDEX idx_baseurl_tenant IF NOT EXISTS
FOR (u:BaseURL) ON (u.user_id, u.project_id);

CREATE INDEX idx_technology_tenant IF NOT EXISTS
FOR (t:Technology) ON (t.user_id, t.project_id);

CREATE INDEX idx_header_tenant IF NOT EXISTS
FOR (h:Header) ON (h.user_id, h.project_id);

CREATE INDEX idx_endpoint_tenant IF NOT EXISTS
FOR (e:Endpoint) ON (e.user_id, e.project_id);

CREATE INDEX idx_parameter_tenant IF NOT EXISTS
FOR (p:Parameter) ON (p.user_id, p.project_id);

CREATE INDEX idx_vulnerability_tenant IF NOT EXISTS
FOR (v:Vulnerability) ON (v.user_id, v.project_id);

CREATE INDEX idx_exploit_tenant IF NOT EXISTS
FOR (e:Exploit) ON (e.user_id, e.project_id);

CREATE INDEX idx_exploitgvm_tenant IF NOT EXISTS
FOR (e:ExploitGvm) ON (e.user_id, e.project_id);

CREATE INDEX idx_githubhunt_tenant IF NOT EXISTS
FOR (gh:GithubHunt) ON (gh.user_id, gh.project_id);

CREATE INDEX idx_githubrepo_tenant IF NOT EXISTS
FOR (gr:GithubRepository) ON (gr.user_id, gr.project_id);

CREATE INDEX idx_sbomdoc_tenant IF NOT EXISTS
FOR (d:SbomDocument) ON (d.user_id, d.project_id);

CREATE INDEX idx_githubpath_tenant IF NOT EXISTS
FOR (gp:GithubPath) ON (gp.user_id, gp.project_id);

CREATE INDEX idx_githubsecret_tenant IF NOT EXISTS
FOR (gs:GithubSecret) ON (gs.user_id, gs.project_id);

CREATE INDEX idx_githubsensitivefile_tenant IF NOT EXISTS
FOR (gsf:GithubSensitiveFile) ON (gsf.user_id, gsf.project_id);

CREATE INDEX idx_multiscannerscan_tenant IF NOT EXISTS
FOR (ts:MultiscannerScan) ON (ts.user_id, ts.project_id);

CREATE INDEX idx_multiscannerrepository_tenant IF NOT EXISTS
FOR (tr:MultiscannerRepository) ON (tr.user_id, tr.project_id);

CREATE INDEX idx_multiscannerfinding_tenant IF NOT EXISTS
FOR (tf:MultiscannerFinding) ON (tf.user_id, tf.project_id);

CREATE INDEX idx_multiscannerimage_tenant IF NOT EXISTS
FOR (ti:MultiscannerImage) ON (ti.user_id, ti.project_id);

CREATE INDEX idx_multiscannermodel_tenant IF NOT EXISTS
FOR (tm:MultiscannerModel) ON (tm.user_id, tm.project_id);

CREATE INDEX idx_multiscannerbucket_tenant IF NOT EXISTS
FOR (tb:MultiscannerBucket) ON (tb.user_id, tb.project_id);

CREATE INDEX idx_multiscannerendpoint_tenant IF NOT EXISTS
FOR (te:MultiscannerEndpoint) ON (te.user_id, te.project_id);

CREATE INDEX idx_jsreconfinding_tenant IF NOT EXISTS
FOR (jf:JsReconFinding) ON (jf.user_id, jf.project_id);

CREATE INDEX idx_secret_tenant IF NOT EXISTS
FOR (s:Secret) ON (s.user_id, s.project_id);

CREATE INDEX idx_externaldomain_tenant IF NOT EXISTS
FOR (ed:ExternalDomain) ON (ed.user_id, ed.project_id);

CREATE INDEX idx_threatpulse_tenant IF NOT EXISTS
FOR (tp:ThreatPulse) ON (tp.user_id, tp.project_id);

CREATE INDEX idx_malware_tenant IF NOT EXISTS
FOR (m:Malware) ON (m.user_id, m.project_id);

CREATE INDEX idx_attackchain_tenant IF NOT EXISTS
FOR (ac:AttackChain) ON (ac.user_id, ac.project_id);

CREATE INDEX idx_chainstep_tenant IF NOT EXISTS
FOR (s:ChainStep) ON (s.user_id, s.project_id);

CREATE INDEX idx_chainfinding_tenant IF NOT EXISTS
FOR (f:ChainFinding) ON (f.user_id, f.project_id);

CREATE INDEX idx_chaindecision_tenant IF NOT EXISTS
FOR (d:ChainDecision) ON (d.user_id, d.project_id);

CREATE INDEX idx_chainfailure_tenant IF NOT EXISTS
FOR (fl:ChainFailure) ON (fl.user_id, fl.project_id);

CREATE INDEX idx_userinput_tenant IF NOT EXISTS
FOR (ui:UserInput) ON (ui.user_id, ui.project_id);

CREATE INDEX idx_traceroute_tenant IF NOT EXISTS
FOR (tr:Traceroute) ON (tr.user_id, tr.project_id);

CREATE INDEX idx_package_tenant IF NOT EXISTS
FOR (p:Package) ON (p.user_id, p.project_id);

CREATE INDEX idx_malpackagefinding_tenant IF NOT EXISTS
FOR (mf:MalPackageFinding) ON (mf.user_id, mf.project_id);

CREATE INDEX idx_certificate_tenant IF NOT EXISTS
FOR (c:Certificate) ON (c.user_id, c.project_id);

// =============================================================================
// ADDITIONAL FUNCTIONAL INDEXES
// =============================================================================

CREATE INDEX subdomain_name IF NOT EXISTS
FOR (s:Subdomain) ON (s.name);

CREATE INDEX idx_subdomain_status IF NOT EXISTS
FOR (s:Subdomain) ON (s.status);

CREATE INDEX ip_address IF NOT EXISTS
FOR (i:IP) ON (i.address);

CREATE INDEX idx_service_tenant IF NOT EXISTS
FOR (svc:Service) ON (svc.user_id, svc.project_id);

CREATE INDEX tech_name IF NOT EXISTS
FOR (t:Technology) ON (t.name);

CREATE INDEX tech_name_version IF NOT EXISTS
FOR (t:Technology) ON (t.name, t.version);

CREATE INDEX vuln_severity IF NOT EXISTS
FOR (v:Vulnerability) ON (v.severity);

CREATE INDEX vuln_category IF NOT EXISTS
FOR (v:Vulnerability) ON (v.category);

CREATE INDEX vuln_template IF NOT EXISTS
FOR (v:Vulnerability) ON (v.template_id);

CREATE INDEX param_injectable IF NOT EXISTS
FOR (p:Parameter) ON (p.is_injectable);

CREATE INDEX cve_severity IF NOT EXISTS
FOR (c:CVE) ON (c.severity);

CREATE INDEX cve_cvss IF NOT EXISTS
FOR (c:CVE) ON (c.cvss);

CREATE INDEX capec_id IF NOT EXISTS
FOR (c:Capec) ON (c.capec_id);

CREATE INDEX idx_githubrepo_name IF NOT EXISTS
FOR (gr:GithubRepository) ON (gr.name);

CREATE INDEX idx_sbomdoc_name IF NOT EXISTS
FOR (d:SbomDocument) ON (d.name);

CREATE INDEX idx_githubpath_path IF NOT EXISTS
FOR (gp:GithubPath) ON (gp.path);

CREATE INDEX idx_githubsecret_secret_type IF NOT EXISTS
FOR (gs:GithubSecret) ON (gs.secret_type);

CREATE INDEX idx_multiscannerfinding_detector IF NOT EXISTS
FOR (tf:MultiscannerFinding) ON (tf.detector_name);

CREATE INDEX idx_multiscannerfinding_source IF NOT EXISTS
FOR (tf:MultiscannerFinding) ON (tf.source);

CREATE INDEX idx_multiscannerfinding_validation IF NOT EXISTS
FOR (tf:MultiscannerFinding) ON (tf.validation_status);

CREATE INDEX idx_multiscannerscan_source IF NOT EXISTS
FOR (ts:MultiscannerScan) ON (ts.source);

CREATE INDEX idx_multiscannerrepository_name IF NOT EXISTS
FOR (tr:MultiscannerRepository) ON (tr.name);

CREATE INDEX idx_multiscannerimage_name IF NOT EXISTS
FOR (ti:MultiscannerImage) ON (ti.name);

CREATE INDEX idx_multiscannermodel_name IF NOT EXISTS
FOR (tm:MultiscannerModel) ON (tm.name);

CREATE INDEX idx_multiscannerbucket_name IF NOT EXISTS
FOR (tb:MultiscannerBucket) ON (tb.name);

CREATE INDEX idx_multiscannerendpoint_name IF NOT EXISTS
FOR (te:MultiscannerEndpoint) ON (te.name);

CREATE INDEX idx_secret_type IF NOT EXISTS
FOR (s:Secret) ON (s.secret_type);

CREATE INDEX idx_secret_severity IF NOT EXISTS
FOR (s:Secret) ON (s.severity);

CREATE INDEX idx_secret_source IF NOT EXISTS
FOR (s:Secret) ON (s.source);

CREATE INDEX idx_chainstep_chain IF NOT EXISTS
FOR (s:ChainStep) ON (s.chain_id);

CREATE INDEX idx_chainfinding_type IF NOT EXISTS
FOR (f:ChainFinding) ON (f.finding_type);

CREATE INDEX idx_chainfinding_severity IF NOT EXISTS
FOR (f:ChainFinding) ON (f.severity);

CREATE INDEX idx_chainfailure_type IF NOT EXISTS
FOR (fl:ChainFailure) ON (fl.failure_type);

CREATE INDEX idx_attackchain_status IF NOT EXISTS
FOR (ac:AttackChain) ON (ac.status);

CREATE INDEX idx_chainstep_by_fireteam IF NOT EXISTS
FOR (s:ChainStep) ON (s.fireteam_id);

CREATE INDEX idx_chainfinding_by_fireteam IF NOT EXISTS
FOR (f:ChainFinding) ON (f.fireteam_id);

CREATE INDEX idx_muted_tenant IF NOT EXISTS
FOR (n:Muted) ON (n.project_id);

๐Ÿ“ Notes for Implementation

  1. Deduplication: Before creating nodes, check if they exist (MERGE vs CREATE)
  2. Timestamps: Store as Neo4j datetime type for proper querying
  3. Arrays: Neo4j supports array properties (tags, references, etc.)
  4. Large Text: Keep descriptions under 10KB, store curl_command and request/response separately if needed
  5. Batch Import: For large scans, use APOC procedures for batch imports

๐Ÿ—บ๏ธ JSON to Graph Mapping Reference

JSON PathNode TypeKey Properties
metadata.*Domainscan_timestamp, scan_type, target, modules_executed, anonymous_mode, bruteforce_mode
whois.*Domainregistrar, creation_date, expiration_date, organization, country, city, state, address, registrant_postal_code, domain_name, referral_url, reseller
subdomains[]Subdomainname
dns.subdomains.<host>.records.*DNSRecordtype, value
dns.subdomains.<host>.ips.*IPaddress, version
port_scan.by_host.<host>.port_details[]Portnumber, protocol
port_scan.by_host.<host>.port_details[].serviceServicename
port_scan.ip_to_hostnames.*(relationship data)IP โ†” Subdomain mapping
http_probe.by_url.<url>.*BaseURLurl, status_code, content_*, server, cdn, *_hash, word_count, line_count, cname, asn
http_probe.by_url.<url>.headers.*Headername, value
http_probe.by_url.<url>.technologies[]Technologyname, version
http_probe.wappalyzer.all_technologies.*Technologycategories, confidence, versions_found
vuln_scan.discovered_urls.dast_urls_with_params[]Endpointpath, method, baseurl, has_parameters, source
vuln_scan.discovered_urls.dast_urls_with_params[]Parametername, position, endpoint_path, baseurl, sample_value, is_injectable
resource_enum.by_base_url.<url>.endpoints[]Endpointpath, method, category, query_param_count, body_param_count, path_param_count, urls_found
resource_enum.by_base_url.<url>.endpoints[].parameters.body[]Parametername, position='body', type, input_type, required
resource_enum.forms[]Endpoint (update)is_form, form_enctype, form_found_at_pages, form_input_names, form_count
vuln_scan.by_target.<host>.findings[]Vulnerabilitytemplate_id, severity, matched_at, fuzzing_*, raw_request, raw_response, matched_ip, matcher_status, max_requests
vuln_scan.by_target.<host>.findings[].raw.*Vulnerabilitycurl_command, extracted_results, extractor_name, authors (from raw.info.author)
nmap_scan.services_detected[]Technologyname (product/version), version, cpe, source='nmap'
nmap_scan.by_host.<host>.port_details[]Port (enriched)product, version, cpe, nmap_scanned=true
nmap_scan.by_host.<host>.port_details[]Service (enriched)product, version, cpe
nmap_scan.nse_vulns[]Vulnerabilityname (script_id), severity, type='nmap_nse', output, state, cve_id
nmap_scan.nse_vulns[].cveCVEid, source='nmap_nse'
technology_cves.by_technology.<tech>.*Technologyproduct, version, cve_count, critical_cve_count, high_cve_count
technology_cves.by_technology.<tech>.cves[]CVEid, cvss, severity, description, published, source, url, references
technology_cves.by_technology.<tech>.cves[].mitre_attack.cwe_hierarchyMitreDatacwe_id, cwe_name, cwe_description, abstraction, is_leaf
technology_cves.by_technology.<tech>.cves[].mitre_attack.cwe_hierarchy.childMitreData(nested CWE hierarchy)
technology_cves.by_technology.<tech>.cves[].mitre_attack.cwe_hierarchy.*.related_capec[]Capecid, name, description, likelihood, severity, prerequisites, execution_flow

Relationship Mapping

JSON ContextRelationshipFrom โ†’ To
dns.subdomains.<host>.ips.ipv4[]RESOLVES_TOSubdomain โ†’ IP
port_scan.by_host.<host>.port_details[]HAS_PORTIP โ†’ Port
port_scan.by_host.<host>.port_details[].serviceRUNS_SERVICEPort โ†’ Service
port_scan.ip_to_hostnames.<ip>[]RESOLVES_TOSubdomain โ†’ IP
http_probe.by_url.<url>SERVES_URLService โ†’ BaseURL
resource_enum.by_base_url.<url> (orphan fallback)HAS_BASE_URLSubdomain โ†’ BaseURL
http_probe.by_url.<url>.technologies[]USES_TECHNOLOGYEndpoint โ†’ Technology
vuln_scan.discovered_urls.dast_urls_with_params[]HAS_ENDPOINTBaseURL โ†’ Endpoint
vuln_scan.discovered_urls.dast_urls_with_params[]HAS_PARAMETEREndpoint โ†’ Parameter
vuln_scan.by_target.<host>.findings[]FOUND_ATVulnerability โ†’ Endpoint
vuln_scan.by_target.<host>.findings[].raw.fuzzing_parameterAFFECTS_PARAMETERVulnerability โ†’ Parameter
nmap_scan.services_detected[]USES_TECHNOLOGYService โ†’ Technology
nmap_scan.services_detected[]HAS_TECHNOLOGYPort โ†’ Technology
nmap_scan.nse_vulns[]AFFECTSVulnerability โ†’ Port
nmap_scan.nse_vulns[]FOUND_ONVulnerability โ†’ Technology
nmap_scan.nse_vulns[].cveHAS_CVEVulnerability โ†’ CVE
nmap_scan.nse_vulns[].cve (+ services_detected match)HAS_KNOWN_CVETechnology โ†’ CVE
technology_cves.by_technology.<tech>.cves[]HAS_KNOWN_CVETechnology โ†’ CVE
technology_cves.by_technology.<tech>.cves[].mitre_attack.cwe_hierarchyHAS_CWECVE โ†’ MitreData
technology_cves.by_technology.<tech>.cves[].mitre_attack.cwe_hierarchy.*.related_capec[]HAS_CAPECMitreData โ†’ Capec

๐Ÿ“Š Derived/Aggregation Data (No Dedicated Nodes)

The JSON contains several aggregation structures that don't need dedicated nodes since they can be computed from the graph:

JSON PathDescriptionQuery Alternative
http_probe.by_host.<host>.*Per-host summary (urls, technologies, servers, status_codes)MATCH (s:Subdomain)-[:RESOLVES_TO]->(:IP)-[:HAS_PORT]->(:Port)-[:RUNS_SERVICE]->(svc)-[:SERVES_URL]->(u)...
http_probe.servers_found.*Server โ†’ URLs mappingMATCH (u:BaseURL) RETURN u.server, collect(u.url)
http_probe.technologies_found.*Technology โ†’ URLs mappingMATCH (u:BaseURL)-[:HAS_ENDPOINT]->(e)-[:USES_TECHNOLOGY]->(t) RETURN t.name, t.version, collect(u.url)
http_probe.summary.by_status_code.*Count by status codeMATCH (u:BaseURL) RETURN u.status_code, count(*)
vuln_scan.by_category.*Vulnerabilities grouped by categoryMATCH (v:Vulnerability) RETURN v.category, collect(v)
vuln_scan.by_target.<host>.severity_countsSeverity counts per targetMATCH (s:Subdomain {name: $host})-[*1..4]->(:Endpoint)<-[:FOUND_AT]-(v:Vulnerability) RETURN v.severity, count(*)
vuln_scan.vulnerabilities.critical[]Critical vulns listMATCH (v:Vulnerability {severity: "critical"}) RETURN v
port_scan.all_ports[]All open ports listMATCH (p:Port) RETURN DISTINCT p.number

These are pre-computed for convenience in the JSON but the graph stores the source data.


๐Ÿ•ต๏ธ GitHub Intelligence Nodes

GitHub Secret Hunt findings are stored in a 5-level node hierarchy linked to the Domain root. Only SECRET and SENSITIVE_FILE findings are ingested; HIGH_ENTROPY is excluded (too noisy). Findings are deduplicated across commit history (same repo + path + secret_type = one node).

GithubHunt (Scan Metadata)

(:GithubHunt {
    id: "github-hunt-<user_id>-<project_id>",
    user_id: "samgiam",
    project_id: "first_test",
    target: "samugit83",
    scan_start_time: "2026-02-12T23:10:04.193830",
    scan_end_time: "2026-02-13T02:35:05.335142",
    duration_seconds: 12301.14,
    status: "completed",
    repos_scanned: 16,
    files_scanned: 15695,
    commits_scanned: 247,
    secrets_found: 952,
    sensitive_files: 18
})

Relationship: Domain -[:HAS_GITHUB_HUNT]-> GithubHunt

GithubRepository (Scanned Repository)

(:GithubRepository {
    id: "github-repo-<user_id>-<project_id>-<org/repo>",
    name: "samugit83/ai-superagent",
    user_id: "samgiam",
    project_id: "first_test"
})

Relationships: GithubHunt -[:HAS_REPOSITORY]-> GithubRepository (secret hunt), and Domain -[:HAS_REPOSITORY]-> GithubRepository when an L1 supply-chain scan targeted the repo. Both use the same node id, so a repo scanned each way is one node, not two.

SbomDocument (Uploaded SBOM / lockfile)

The parent of every package read out of a file the operator uploaded in Project Settings -> Other Scans -> Supply Chain Scanner. One node per uploaded filename per project.

(:SbomDocument {
    id: "sbom-<user_id>-<project_id>-<filename>",
    name: "requirements.txt",
    user_id: "samgiam",
    project_id: "first_test"
})

Relationships: Domain -[:HAS_SBOM_DOCUMENT]-> SbomDocument -[:DEPENDS_ON]-> Package

Why it exists: an upload has no target to hang off, so its packages originally floated - they and their Vulnerability nodes were reachable from nothing, contradicting the "No Isolated Nodes" rule. The file itself is the honest parent: it is what the operator supplied and what the packages were read out of. It also keeps per-file attribution, which was otherwise lost - every upload collapsed into an indistinguishable set of source='osv' packages.

Note this node is NOT created for the repository input; that anchors to GithubRepository instead.

Anchoring alone was not enough: the anchor itself started out parentless, so the whole L1 scan rendered as one detached cluster. Both L1 anchors are therefore linked to the project's Domain, the same way a GitHub Secret Hunt uses Domain -[:HAS_GITHUB_HUNT]-> GithubHunt. A project with no Domain yet (an SBOM upload can be the first thing done) keeps the anchor as a root rather than fabricating a target that was never scanned.

GithubPath (File Path Within Repository)

Groups all findings from the same file path together.

(:GithubPath {
    id: "github-path-<user_id>-<project_id>-<hash>",
    user_id: "samgiam",
    project_id: "first_test",
    repository: "samugit83/ai-superagent",
    path: "websocket_server/.env"
})

Relationship: GithubRepository -[:HAS_PATH]-> GithubPath

GithubSecret (Leaked Secret Finding)

Leaf node for type: "SECRET" findings โ€” API keys, credentials, tokens, connection strings.

(:GithubSecret {
    id: "github-secret-<user_id>-<project_id>-<hash>",
    user_id: "samgiam",
    project_id: "first_test",
    repository: "samugit83/ai-superagent",
    path: "websocket_server/.env",
    secret_type: "Twilio Account SID",
    timestamp: "2026-02-12T23:10:31.917308",
    matches: 2,                            // (optional) number of matches
    sample: "AC5dt8wSP3BQ..."              // (optional) redacted sample
})

Relationship: GithubPath -[:CONTAINS_SECRET]-> GithubSecret

GithubSensitiveFile (Sensitive File Finding)

Leaf node for type: "SENSITIVE_FILE" findings โ€” .env files, config files, key files.

(:GithubSensitiveFile {
    id: "github-sensitivefi-<user_id>-<project_id>-<hash>",
    user_id: "samgiam",
    project_id: "first_test",
    repository: "samugit83/ai-superagent",
    path: ".env",
    secret_type: "Environment Configuration File",
    timestamp: "2026-02-12T23:10:31.917308",
    matches: 1
})

Relationship: GithubPath -[:CONTAINS_SENSITIVE_FILE]-> GithubSensitiveFile

Full Chain

Domain -[:HAS_GITHUB_HUNT]-> GithubHunt
    -[:HAS_REPOSITORY]-> GithubRepository
        -[:HAS_PATH]-> GithubPath
            -[:CONTAINS_SECRET]-> GithubSecret
            -[:CONTAINS_SENSITIVE_FILE]-> GithubSensitiveFile

Constraints & Indexes

CREATE CONSTRAINT githubhunt_tenant_unique IF NOT EXISTS
FOR (gh:GithubHunt) REQUIRE (gh.id, gh.user_id, gh.project_id) IS UNIQUE;

CREATE CONSTRAINT githubrepo_tenant_unique IF NOT EXISTS
FOR (gr:GithubRepository) REQUIRE (gr.id, gr.user_id, gr.project_id) IS UNIQUE;

CREATE CONSTRAINT githubpath_tenant_unique IF NOT EXISTS
FOR (gp:GithubPath) REQUIRE (gp.id, gp.user_id, gp.project_id) IS UNIQUE;

CREATE CONSTRAINT githubsecret_tenant_unique IF NOT EXISTS
FOR (gs:GithubSecret) REQUIRE (gs.id, gs.user_id, gs.project_id) IS UNIQUE;

CREATE CONSTRAINT githubsensitivefile_tenant_unique IF NOT EXISTS
FOR (gsf:GithubSensitiveFile) REQUIRE (gsf.id, gsf.user_id, gsf.project_id) IS UNIQUE;

CREATE INDEX idx_githubhunt_tenant IF NOT EXISTS
FOR (gh:GithubHunt) ON (gh.user_id, gh.project_id);

CREATE INDEX idx_githubrepo_tenant IF NOT EXISTS
FOR (gr:GithubRepository) ON (gr.user_id, gr.project_id);

CREATE INDEX idx_githubpath_tenant IF NOT EXISTS
FOR (gp:GithubPath) ON (gp.user_id, gp.project_id);

CREATE INDEX idx_githubsecret_tenant IF NOT EXISTS
FOR (gs:GithubSecret) ON (gs.user_id, gs.project_id);

CREATE INDEX idx_githubsensitivefile_tenant IF NOT EXISTS
FOR (gsf:GithubSensitiveFile) ON (gsf.user_id, gsf.project_id);

Example Queries

// Full chain: all GitHub findings for a project
MATCH (d:Domain {user_id: $userId, project_id: $projectId})
      -[:HAS_GITHUB_HUNT]->(gh:GithubHunt)
      -[:HAS_REPOSITORY]->(gr:GithubRepository)
      -[:HAS_PATH]->(gp:GithubPath)
OPTIONAL MATCH (gp)-[:CONTAINS_SECRET]->(gs:GithubSecret)
OPTIONAL MATCH (gp)-[:CONTAINS_SENSITIVE_FILE]->(gsf:GithubSensitiveFile)
RETURN gr.name AS repository, gp.path AS path, gs.secret_type AS secret, gsf.secret_type AS sensitive_file

// Only leaked secrets (API keys, credentials, tokens)
MATCH (gs:GithubSecret {user_id: $userId, project_id: $projectId})
RETURN gs.repository, gs.secret_type, gs.path, gs.sample

// Only sensitive files (.env, config, key files)
MATCH (gsf:GithubSensitiveFile {user_id: $userId, project_id: $projectId})
RETURN gsf.repository, gsf.secret_type, gsf.path

// Count findings per repository
MATCH (gr:GithubRepository {user_id: $userId, project_id: $projectId})
      -[:HAS_PATH]->(gp:GithubPath)
OPTIONAL MATCH (gp)-[:CONTAINS_SECRET]->(gs:GithubSecret)
OPTIONAL MATCH (gp)-[:CONTAINS_SENSITIVE_FILE]->(gsf:GithubSensitiveFile)
WITH gr, count(gs) AS secrets, count(gsf) AS sensitive_files
RETURN gr.name AS repo, secrets, sensitive_files ORDER BY secrets + sensitive_files DESC

๐Ÿ” Secret Multiscanner Nodes

Secret Multiscanner scan findings are stored in a 3-level node hierarchy linked to the Domain root. Secret Multiscanner uses detector-based credential verification and deep git history analysis. Findings are deduplicated by {repository}:{file}:{line}:{detector_name}.

MultiscannerScan (Scan Metadata)

One scan node per project + source. The source is part of the id, which is what lets a Docker run and a HuggingFace run coexist instead of overwriting each other's metadata.

(:MultiscannerScan {
    id: "multiscanner-scan-<user_id>-<project_id>-<source>",
    user_id: "samgiam",
    project_id: "first_test",
    source: "docker",                    // git | github | github_experimental | gitlab
                                         // | docker | huggingface | s3 | gcs | filesystem
                                         // | jenkins | elasticsearch | postman
                                         // | circleci | travisci
    source_label: "Docker registry",
    run_id: "docker",                    // == source: the run key (one run per source)
    target: "acme/app:1.0",
    verification_enabled: true,          // false => every finding is `unverified`
    scan_start_time: "2026-03-20T14:10:04.193830",
    scan_end_time: "2026-03-20T16:35:05.335142",
    duration_seconds: 8701.14,
    status: "completed",
    total_findings: 47,
    verified_findings: 12,
    unverified_findings: 35,
    validated_findings: 12,              // confirmed live by the owning API
    assets_scanned: 16,
    repositories_scanned: 16,            // deprecated alias of assets_scanned
    updated_at: "2026-03-20T16:35:05.335142"
})

Relationship: Domain -[:HAS_MULTISCANNER_SCAN]-> MultiscannerScan

Asset nodes (five labels, not fourteen)

The graph renderer draws a node from labels[0] โ€” a single label, and Neo4j does not guarantee label ordering โ€” so every node carries exactly one. One label per source (there are fourteen in SOURCES, scanners/trufflehog_scan/sources.py) would be unmaintainable, and one generic label would make every source the same colour, so assets are grouped by shape:

LabelSourcesasset_kindname holds
MultiscannerRepositorygit, github, github_experimental, gitlabrepositoryorg/repo or clone URL
MultiscannerImagedockerimagenamespace/image:tag
MultiscannerModelhuggingfacemodeluser/model
MultiscannerBuckets3, gcsbucketbucket name
MultiscannerEndpointjenkins, elasticsearch, postman, circleci, travisci, filesystemendpointURL, node, workspace or scan root
(:MultiscannerImage {
    id: "multiscanner-asset-<user_id>-<project_id>-<source>-<digest12>",
    name: "acme/app:1.0",
    source: "docker",                    // required: the scoped clear matches on it
    asset_kind: "image",
    scan_id: "multiscanner-scan-<user_id>-<project_id>-docker",
    user_id: "samgiam",
    project_id: "first_test",
    updated_at: "2026-03-20T16:35:05.335142"
})

Relationship: MultiscannerScan -[:HAS_ASSET]-> <asset label>

MultiscannerFinding (Detected Credential Finding)

Leaf node for individual credential findings. One label regardless of source: a secret is a secret wherever it was found.

(:MultiscannerFinding {
    id: "multiscanner-finding-<user_id>-<project_id>-<source>-<digest12>",
    user_id: "samgiam",
    project_id: "first_test",
    source: "docker",
    scan_id: "multiscanner-scan-<user_id>-<project_id>-docker",
    detector_name: "AWS",
    detector_description: "Amazon Web Services access key",
    verified: true,                      // raw Secret Multiscanner bool
    validation_status: "validated",      // the load-bearing attribute, see below
    finding_kind: "secret",              // secret | image_history
    redacted: "AKIA2E0A8F3B1...",
    asset: "acme/app:1.0",               // generalises `repository`
    location: "/app/deploy/config.yml",  // generalises `file`: layer path, object key, URL
    repository: "acme/app:1.0",          // deprecated alias of asset
    file: "/app/deploy/config.yml",      // deprecated alias of location
    commit: "a3b2c1d",                   // empty for non-git sources
    line: 42,
    link: "https://...",
    extra_data: "{\"Tag\": \"1.0\", \"Layer\": \"sha256:...\"}",
    timestamp: "2026-03-20T14:12:31.917308",
    updated_at: "2026-03-20T14:12:31.917308"
})

validation_status โ€” the same vocabulary the :Secret nodes use, so the ValidationChip and VALIDATION_RANK are reused verbatim:

ValueMeaning
validatedthe owning API confirmed the credential is LIVE
unvalidatedverification ran and the API said it is not live
verify_errorthe verify call itself failed โ€” not proof it is dead
unverifiedverification was switched off โ€” never checked, not safe

unvalidated and unverified must never be collapsed: one means "checked, dead", the other "we never looked", and a pentest report cannot treat them alike.

finding_kind โ€” image_history marks a secret found in a Docker image's build history (RUN/ENV directives), whose location is the synthetic path image-metadata:history:{index}:created-by rather than a real file.

Dedup key: {source}:{asset}:{location}:{line}:{detector_name} โ€” source-scoped, so the same secret found by two sources stays two findings.

Node ids use a stable sha1(...)[:12] digest, never Python's builtin hash(), which is randomised per process (PYTHONHASHSEED) and gave the same asset a different id on every run.

Relationship: <asset label> -[:HAS_FINDING]-> MultiscannerFinding

Full Chain

Domain -[:HAS_MULTISCANNER_SCAN]-> MultiscannerScan          (one per project+source)
    -[:HAS_ASSET]-> MultiscannerRepository|Image|Model|Bucket|Endpoint
        -[:HAS_FINDING]-> MultiscannerFinding

Scoped clearing

clear_trufflehog_data(user_id, project_id, source=...) deletes ONLY that source's subgraph. Ingest always passes a source; only project deletion passes None. An unscoped clear at ingest time would mean the Docker scan finishing erases every HuggingFace finding โ€” silently.

Constraints & Indexes

-- Tenant-scoped, matching the MERGE key. Applies to all six labels.
CREATE CONSTRAINT multiscannerscan_unique IF NOT EXISTS
FOR (ts:MultiscannerScan) REQUIRE (ts.id, ts.user_id, ts.project_id) IS UNIQUE;

CREATE CONSTRAINT multiscannerfinding_unique IF NOT EXISTS
FOR (tf:MultiscannerFinding) REQUIRE (tf.id, tf.user_id, tf.project_id) IS UNIQUE;
-- ... plus MultiscannerRepository / Image / Model / Bucket / Endpoint

CREATE INDEX idx_multiscannerfinding_tenant IF NOT EXISTS
FOR (tf:MultiscannerFinding) ON (tf.user_id, tf.project_id);

-- Carries the scoped clear, which matches on (user_id, project_id, source).
CREATE INDEX idx_multiscannerfinding_source IF NOT EXISTS
FOR (tf:MultiscannerFinding) ON (tf.source);

CREATE INDEX idx_multiscannerfinding_validation IF NOT EXISTS
FOR (tf:MultiscannerFinding) ON (tf.validation_status);

Example Queries

// Full chain: all Secret Multiscanner findings for a project, any source
MATCH (d:Domain {user_id: $userId, project_id: $projectId})
      -[:HAS_MULTISCANNER_SCAN]->(ts:MultiscannerScan)-[:HAS_ASSET]->(a)-[:HAS_FINDING]->(tf:MultiscannerFinding)
RETURN ts.source AS source, a.name AS asset, tf.detector_name AS detector,
       tf.location AS location, tf.validation_status AS validation

// Live credentials only โ€” the ones that need acting on now
MATCH (tf:MultiscannerFinding {user_id: $userId, project_id: $projectId,
                             validation_status: 'validated'})
RETURN tf.source, tf.asset, tf.location, tf.detector_name, tf.redacted

// Findings per source
MATCH (tf:MultiscannerFinding {user_id: $userId, project_id: $projectId})
RETURN tf.source AS source, count(*) AS total,
       sum(CASE WHEN tf.validation_status = 'validated' THEN 1 ELSE 0 END) AS live
ORDER BY live DESC

// Secrets baked into a Docker image's build history
MATCH (tf:MultiscannerFinding {user_id: $userId, project_id: $projectId,
                             finding_kind: 'image_history'})
RETURN tf.asset AS image, tf.detector_name AS detector, tf.redacted

๐Ÿ” JS Recon Scanner

JS Recon performs deep JavaScript analysis beyond inline jsluice. It uses a hierarchical graph structure where each analyzed JS file is a node, and all findings from that file are linked to it.

JsReconFinding (JS File + Analysis Findings)

The JsReconFinding label is used for two sub-types:

1. JS File nodes (finding_type='js_file') -- one per analyzed JavaScript file:

(:JsReconFinding {
  id: "jsrf-{user_id}-{project_id}-file-{url_hash}",
  user_id: "{user_id}",
  project_id: "{project_id}",
  finding_type: "js_file",
  title: "app.js",                          // filename
  detail: "https://target.com/js/app.js",   // full URL or upload://filename
  is_uploaded: false,                        // true for manually uploaded files
  source_url: "https://target.com/js/app.js",
  base_url: "https://target.com",           // or 'upload' for uploaded files
  source: "js_recon",
  severity: "info",
  confidence: "high",
  discovered_at: "ISO timestamp"
})

2. Finding nodes (finding_type != 'js_file') -- individual findings linked to their parent file:

(:JsReconFinding {
  id: "jsrf-{user_id}-{project_id}-{hash}",
  user_id: "{user_id}",
  project_id: "{project_id}",
  finding_type: "dependency_confusion|source_map_exposure|dom_sink|framework|dev_comment|source_map_reference
                | ai-sdk-client | ai-sdk-key-literal | ai-sdk-browser-allowed | ai-frontend-detected | ai-provider-url",
  severity: "critical|high|medium|low|info",
  confidence: "high|medium|low",
  title: "Human-readable finding title",
  detail: "Full finding detail",
  evidence: "Matched pattern or code snippet (max 500 chars)",
  source_url: "JS file URL where finding was discovered",
  base_url: "Parent BaseURL or 'upload'",
  source: "js_recon",
  discovered_at: "ISO timestamp",
  -- ai-sdk-* findings carry additional fields:
  sdk_name: "OpenAI | Anthropic | LangChain Core | Pinecone | Open WebUI | ..." -- canonical product name
  ai_provider: "<same as sdk_name>" -- mirror property for prefix-consistent queries (`WHERE jf.ai_provider IS NOT NULL`)
  sample: "abc123...xyz9" -- redacted key (first6 + last4), empty for non-key categories. Never the full secret.
  byte_offset: 12345 -- position in source JS, stable across re-scans (id is derived from sig including offset)
  detection_method: "ai_sdk_catalogue" -- distinguishes from "regex" used by the legacy secret pass
})

Relationships (hierarchical: parent -> file -> findings):

  • BaseURL -[:HAS_JS_FILE]-> JsReconFinding(js_file) -- pipeline-crawled JS files
  • Domain -[:HAS_JS_FILE]-> JsReconFinding(js_file) -- uploaded JS files
  • JsReconFinding(js_file) -[:HAS_JS_FINDING]-> JsReconFinding -- findings from that file (includes ai-sdk-* findings)
  • JsReconFinding(js_file) -[:HAS_SECRET]-> Secret -- secrets found in that file
  • JsReconFinding(js_file) -[:HAS_ENDPOINT]-> Endpoint -- endpoints extracted from that file

Extended Secret Properties (source='js_recon')

JS Recon secrets extend the existing Secret node with:

  • validation_status: validated, invalid, unvalidated, skipped, incomplete
  • validation_info: JSON string with validator response (scope, account info)
  • confidence: high, medium, low
  • detection_method: regex
  • key_type: category (cloud, payment, auth, js_service, etc.)
  • ai_provider: (Phase 6) canonical AI provider product name when the secret matches an AI key shape AND the parent JS file also has an ai-sdk-key-literal finding for the same captured value. Lets generic Secret queries pivot directly into the AI-context taxonomy. Example values: "OpenAI SDK constructor", "Anthropic SDK constructor", "Langfuse Secret Key". Only set on Secret nodes whose matched_text starts with a known AI provider prefix (sk-, hf_, lsv2_, gsk_, r8_, pcsk_, pplx-, xai-, csk-, tgp_, pa-, AIzaSy, co_, rpa_, pk-lf-, fw_) -- guarded to avoid enriching Stripe / Slack / AWS literals that happen to co-locate in the same JS file.
  • ai_finding_id: foreign key into the matching JsReconFinding(finding_type='ai-sdk-key-literal') node for full provenance (SDK constructor span, byte offset, sdk_name).

Constraints & Indexes

CREATE CONSTRAINT jsreconfinding_tenant_unique IF NOT EXISTS
FOR (jf:JsReconFinding) REQUIRE (jf.id, jf.user_id, jf.project_id) IS UNIQUE;

CREATE INDEX idx_jsreconfinding_tenant IF NOT EXISTS
FOR (jf:JsReconFinding) ON (jf.user_id, jf.project_id);

Supply-Chain Constraints

Tenant-scoped composite keys (unlike the global-id scanner nodes above), so the same package discovered by different layers in the same project dedups, while two projects keep independent copies. Mirrors graph_db/schema.py.

CREATE CONSTRAINT package_unique IF NOT EXISTS
FOR (p:Package) REQUIRE (p.purl, p.user_id, p.project_id) IS UNIQUE;

CREATE CONSTRAINT malpackagefinding_unique IF NOT EXISTS
FOR (mf:MalPackageFinding) REQUIRE (mf.finding_id, mf.user_id, mf.project_id) IS UNIQUE;

Example Queries

-- All analyzed JS files
MATCH (file:JsReconFinding {finding_type: 'js_file', user_id: $userId, project_id: $projectId})
RETURN file.title as filename, file.source_url as url, file.is_uploaded as uploaded

-- All findings from a specific JS file
MATCH (file:JsReconFinding {finding_type: 'js_file'})-[:HAS_JS_FINDING]->(jf:JsReconFinding)
WHERE file.title CONTAINS 'app.js'
RETURN jf.finding_type, jf.severity, jf.title, jf.detail

-- Secrets found in JS files (traverses file hierarchy)
MATCH (file:JsReconFinding {finding_type: 'js_file'})-[:HAS_SECRET]->(s:Secret)
WHERE file.user_id = $userId AND file.project_id = $projectId
RETURN file.title as js_file, s.secret_type, s.sample, s.severity, s.validation_status

-- Full hierarchy: Domain -> JS files -> findings
MATCH (d:Domain)-[:HAS_JS_FILE]->(file:JsReconFinding {finding_type: 'js_file'})-[:HAS_JS_FINDING]->(jf:JsReconFinding)
RETURN d.name, file.title, jf.finding_type, jf.severity, jf.title
ORDER BY CASE jf.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END

โš”๏ธ Attack Chain Graph (EvoGraph)

The Attack Chain Graph is an evolutionary, persistent graph that runs parallel to the recon graph. Every agent conversation maps 1:1 to an AttackChain node (chain_id = session ID). Steps, findings, decisions, and failures are first-class nodes with typed intra-chain relationships and bridge relationships to the recon graph.

This replaces the standalone agent-created Exploit node with the richer ChainFinding(finding_type="exploit_success").

Design Principles

  1. 1:1 Session Mapping: Each agent conversation = one AttackChain node
  2. Causal Linking: ChainStep nodes connected via NEXT_STEP for temporal ordering
  3. Typed Intelligence: Findings, failures, and decisions are first-class nodes (not just strings)
  4. Bridge Relationships: Chain nodes link to recon graph entities (IP, CVE, Service) via typed edges
  5. Cross-Session Memory: Agent queries prior chains to avoid repeating failed approaches
  6. Async-Safe Writes: Step writes are synchronous (blocking) to prevent race conditions; finding/failure/decision writes are fire-and-forget (async) โ€” errors logged but never crash the agent
  7. MERGE Idempotency: All writes use MERGE for checkpoint recovery safety

AttackChain (Attack Session Root)

Root node for an agent attack session. Created when the agent starts a new conversation.

(:AttackChain {
    chain_id: "session-abc123",              // Unique, equals agent session ID
    user_id: "samgiam",
    project_id: "first_test",
    title: "Exploit CVE-2021-41773 on target",  // First message excerpt
    objective: "Test Apache path traversal",
    status: "completed",                     // active | completed | aborted
    attack_path_type: "cve_exploit",         // cve_exploit | brute_force_credential_guess | <term>-unclassified
    total_steps: 8,
    successful_steps: 6,
    failed_steps: 2,
    phases_reached: ["informational", "exploitation"],
    final_outcome: "Exploitation successful via CVE-2021-41773",
    created_at: datetime(),
    updated_at: datetime()
})

Relationships:

  • AttackChain -[:HAS_STEP {order: N}]-> ChainStep โ€” Chain contains step (order = iteration)
  • AttackChain -[:CHAIN_TARGETS]-> Domain โ€” Always (project root)
  • AttackChain -[:CHAIN_TARGETS]-> IP โ€” When objective mentions an IP
  • AttackChain -[:CHAIN_TARGETS]-> Subdomain โ€” When objective mentions a hostname
  • AttackChain -[:CHAIN_TARGETS]-> Port โ€” When objective mentions a port
  • AttackChain -[:CHAIN_TARGETS]-> CVE โ€” When objective mentions CVE IDs

ChainStep (Tool Execution Step)

Each tool execution in an attack chain. Contains the agent's thought process, tool output, and analysis.

(:ChainStep {
    step_id: "step-uuid-123",               // Unique UUID
    chain_id: "session-abc123",
    user_id: "samgiam",
    project_id: "first_test",
    iteration: 3,
    phase: "exploitation",                   // informational | exploitation | post_exploitation
    tool_name: "metasploit_console",
    tool_args_summary: "{command: 'search CVE-2021-41773'}",
    thought: "Need to find a Metasploit module for this CVE...",
    reasoning: "CVE-2021-41773 is a path traversal in Apache 2.4.49",
    output_summary: "1 result: exploit/multi/http/apache_normalize_path_rce",
    output_analysis: "Found matching module. Rank: excellent.",
    success: true,
    error_message: null,
    duration_ms: 1200,

    // Fireteam (multi-agent) attribution. Indexed by idx_chainstep_by_fireteam,
    // because report queries assemble per-member sections from it.
    fireteam_id: "ft-uuid-7",
    agent_id: "agent-2",
    agent_name: "recon-specialist",

    created_at: datetime(),
    updated_at: datetime()
})

Relationships:

  • AttackChain -[:HAS_STEP]-> ChainStep โ€” The chain owns its steps
  • ChainStep -[:NEXT_STEP]-> ChainStep โ€” Sequential step ordering
  • ChainStep -[:PRODUCED]-> ChainFinding โ€” Step produced a finding
  • ChainStep -[:FAILED_WITH]-> ChainFailure โ€” Step failed with error
  • ChainStep -[:LED_TO]-> ChainDecision โ€” Step led to a strategic decision
  • ChainStep -[:STEP_TARGETED]-> IP โ€” Step targeted an IP (bridge to recon)
  • ChainStep -[:STEP_TARGETED]-> Subdomain โ€” Step targeted a hostname (bridge to recon)
  • ChainStep -[:STEP_TARGETED]-> Port โ€” Step targeted a port (bridge to recon)
  • ChainStep -[:STEP_EXPLOITED]-> CVE โ€” Step exploited a CVE (bridge to recon)
  • ChainStep -[:STEP_IDENTIFIED]-> Technology โ€” Step identified a technology (bridge to recon)

ChainFinding (Discovery / Exploit Result)

A discovery made during an attack chain. Replaces the standalone Exploit node when finding_type="exploit_success".

(:ChainFinding {
    finding_id: "finding-uuid-456",          // Unique UUID
    chain_id: "session-abc123",
    user_id: "samgiam",
    project_id: "first_test",
    finding_type: "exploit_success",         // See finding_type enum below
    severity: "critical",                    // critical | high | medium | low | info
    title: "Meterpreter session opened via CVE-2021-41773",
    description: "Apache path traversal exploited for RCE",
    evidence: "Meterpreter session 1 opened (10.0.0.1:4444 -> 10.0.0.5:443)",
    confidence: 95,                          // 0-100
    phase: "exploitation",
    // Exploit-specific properties (only for finding_type="exploit_success"):
    attack_type: "cve_exploit",
    target_ip: "10.0.0.5",
    target_port: 443,
    cve_ids: ["CVE-2021-41773"],
    metasploit_module: "exploit/multi/http/apache_normalize_path_rce",
    payload: "linux/x64/meterpreter/reverse_tcp",
    session_id: 1,
    report: "Structured exploitation report...",
    commands_used: ["search CVE-2021-41773", "use 0", "set RHOSTS ...", "exploit"],
    created_at: datetime()
})

Finding types: vulnerability_confirmed, credential_found, exploit_success, access_gained, privilege_escalation, service_identified, exploit_module_found, defense_detected, configuration_found, information_disclosure, outbound_fetch, boolean_differential, data_exfiltration, lateral_movement, persistence_established, denial_of_service_success, social_engineering_success, remote_code_execution, session_hijacked, custom

Relationships:

  • ChainFinding -[:FOUND_ON]-> IP โ€” Finding discovered on IP (bridge to recon)
  • ChainFinding -[:FOUND_ON]-> Subdomain โ€” Finding discovered on subdomain (bridge to recon)
  • ChainFinding -[:FINDING_RELATES_CVE]-> CVE โ€” Finding relates to CVE (bridge to recon)
  • ChainFinding -[:FINDING_AFFECTS_ENDPOINT]-> Endpoint โ€” regex-matched from evidence
  • ChainFinding -[:FINDING_AFFECTS_PORT]-> Port โ€” regex-matched from evidence
  • ChainFinding -[:FINDING_AFFECTS_TECH]-> Technology โ€” name found in evidence
  • ChainFinding -[:CONFIRMS]-> Vulnerability | Secret | MultiscannerFinding | GithubSecret | GithubSensitiveFile | JsReconFinding | MalPackageFinding | ExploitGvm โ€” the agent proved this specific recon finding, so the Priority Board scores it as proven (K1). Written only from a finding id the agent explicitly reported, tenant-scoped

ChainDecision (Strategic Pivot)

A strategic decision point in an attack chain โ€” phase transitions, strategy changes, target switches.

(:ChainDecision {
    decision_id: "decision-uuid-789",        // Unique UUID
    chain_id: "session-abc123",
    user_id: "samgiam",
    project_id: "first_test",
    decision_type: "phase_transition",       // phase_transition | strategy_change | target_switch
    from_state: "informational",
    to_state: "exploitation",
    reason: "Found exploitable CVE-2021-41773 on Apache 2.4.49",
    made_by: "user",                         // agent | user
    approved: true,
    created_at: datetime()
})

Relationships:

  • ChainStep -[:LED_TO]-> ChainDecision โ€” Step triggered this decision
  • ChainDecision -[:DECISION_PRECEDED]-> ChainStep โ€” Decision preceded this next step (connects decision into the sequential flow)

ChainFailure (Failed Attempt with Lesson)

A failed attempt with a lesson learned, enabling the agent to avoid repeating mistakes across sessions.

(:ChainFailure {
    failure_id: "failure-uuid-012",          // Unique UUID
    chain_id: "session-abc123",
    user_id: "samgiam",
    project_id: "first_test",
    failure_type: "exploit_failed",          // exploit_failed | authentication_failed | tool_error | timeout | connection_refused
    tool_name: "metasploit_console",
    error_category: "connection",
    error_message: "Connection refused on port 80",
    lesson_learned: "Target filters HTTP traffic, try HTTPS (443) instead",
    retry_possible: true,
    phase: "exploitation",
    created_at: datetime()
})

Relationship: ChainStep -[:FAILED_WITH]-> ChainFailure

Full Chain

AttackChain -[:HAS_STEP {order: N}]-> ChainStep
    -[:NEXT_STEP]-> ChainStep (sequential linking)
    -[:PRODUCED]-> ChainFinding
    -[:FAILED_WITH]-> ChainFailure
    -[:LED_TO]-> ChainDecision
        -[:DECISION_PRECEDED]-> ChainStep (connects decision into the flow)

Bridge to Recon Graph (static, no animation):
Note: Bridges are only created for tool-execution steps. query_graph steps (read-only) create NO bridges.
    AttackChain -[:CHAIN_TARGETS]-> Domain / IP / Subdomain / Port / CVE  (extracted from objective text by LLM)
    ChainStep -[:STEP_TARGETED]-> IP / Subdomain / Port  (IP vs Subdomain depends on whether primary_target is an IP or hostname)
    ChainStep -[:STEP_EXPLOITED]-> CVE
    ChainStep -[:STEP_IDENTIFIED]-> Technology  (case-insensitive match on Technology.name)
    ChainFinding -[:FOUND_ON]-> IP / Subdomain  (IP vs Subdomain depends on whether related_ips value is an IP or hostname)
    ChainFinding -[:FINDING_RELATES_CVE]-> CVE
    ChainFinding -[:FINDING_AFFECTS_ENDPOINT|FINDING_AFFECTS_PORT|FINDING_AFFECTS_TECH]-> Endpoint / Port / Technology
    ChainFinding -[:CONFIRMS]-> Vulnerability / Secret / MultiscannerFinding / GithubSecret / GithubSensitiveFile / JsReconFinding / MalPackageFinding / ExploitGvm  (the recon finding the agent proved, tenant-scoped, from a reported id only)

Constraints & Indexes

CREATE CONSTRAINT attack_chain_id IF NOT EXISTS
FOR (ac:AttackChain) REQUIRE ac.chain_id IS UNIQUE;

CREATE CONSTRAINT chain_step_id IF NOT EXISTS
FOR (s:ChainStep) REQUIRE s.step_id IS UNIQUE;

CREATE CONSTRAINT chain_finding_id IF NOT EXISTS
FOR (f:ChainFinding) REQUIRE f.finding_id IS UNIQUE;

CREATE CONSTRAINT chain_decision_id IF NOT EXISTS
FOR (d:ChainDecision) REQUIRE d.decision_id IS UNIQUE;

CREATE CONSTRAINT chain_failure_id IF NOT EXISTS
FOR (fl:ChainFailure) REQUIRE fl.failure_id IS UNIQUE;

CREATE INDEX idx_attackchain_tenant IF NOT EXISTS
FOR (ac:AttackChain) ON (ac.user_id, ac.project_id);

CREATE INDEX idx_chainstep_tenant IF NOT EXISTS
FOR (s:ChainStep) ON (s.user_id, s.project_id);

CREATE INDEX idx_chainfinding_tenant IF NOT EXISTS
FOR (f:ChainFinding) ON (f.user_id, f.project_id);

CREATE INDEX idx_chaindecision_tenant IF NOT EXISTS
FOR (d:ChainDecision) ON (d.user_id, d.project_id);

CREATE INDEX idx_chainfailure_tenant IF NOT EXISTS
FOR (fl:ChainFailure) ON (fl.user_id, fl.project_id);

CREATE INDEX idx_chainstep_chain IF NOT EXISTS
FOR (s:ChainStep) ON (s.chain_id);

CREATE INDEX idx_chainfinding_type IF NOT EXISTS
FOR (f:ChainFinding) ON (f.finding_type);

CREATE INDEX idx_chainfinding_severity IF NOT EXISTS
FOR (f:ChainFinding) ON (f.severity);

CREATE INDEX idx_chainfailure_type IF NOT EXISTS
FOR (fl:ChainFailure) ON (fl.failure_type);

CREATE INDEX idx_attackchain_status IF NOT EXISTS
FOR (ac:AttackChain) ON (ac.status);

Example Queries

// All attack chains for a project
MATCH (ac:AttackChain {user_id: $userId, project_id: $projectId})
RETURN ac.chain_id, ac.title, ac.status, ac.attack_path_type, ac.total_steps, ac.created_at
ORDER BY ac.created_at DESC
LIMIT 10

// Steps in a specific chain (ordered)
MATCH (ac:AttackChain {chain_id: "session-123", user_id: $userId, project_id: $projectId})
      -[:HAS_STEP]->(s:ChainStep)
RETURN s.iteration, s.phase, s.tool_name, s.success, s.output_summary
ORDER BY s.iteration

// All high-severity findings across chains
MATCH (f:ChainFinding {user_id: $userId, project_id: $projectId})
WHERE f.severity IN ["critical", "high"]
RETURN f.finding_type, f.title, f.severity, f.evidence, f.chain_id
ORDER BY f.created_at DESC
LIMIT 20

// Exploit successes (replaces Exploit node queries)
MATCH (f:ChainFinding {user_id: $userId, project_id: $projectId, finding_type: "exploit_success"})
RETURN f.target_ip, f.target_port, f.cve_ids, f.metasploit_module, f.evidence
LIMIT 20

// Failed attempts with lessons learned
MATCH (fl:ChainFailure {user_id: $userId, project_id: $projectId})
RETURN fl.failure_type, fl.tool_name, fl.error_message, fl.lesson_learned, fl.chain_id
ORDER BY fl.created_at DESC
LIMIT 20

// Cross-session: what was tried against a specific IP
MATCH (s:ChainStep {user_id: $userId, project_id: $projectId})-[:STEP_TARGETED]->(i:IP {address: "10.0.0.5"})
RETURN s.chain_id, s.tool_name, s.success, s.output_summary
ORDER BY s.created_at DESC

// Chain with all findings and failures
MATCH (ac:AttackChain {chain_id: "session-123", user_id: $userId, project_id: $projectId})
OPTIONAL MATCH (ac)-[:HAS_STEP]->(s:ChainStep)-[:PRODUCED]->(f:ChainFinding)
OPTIONAL MATCH (s)-[:FAILED_WITH]->(fl:ChainFailure)
RETURN s.iteration, s.tool_name, f.title, fl.error_message
ORDER BY s.iteration

// Decisions made during a chain
MATCH (ac:AttackChain {chain_id: "session-123", user_id: $userId, project_id: $projectId})
      -[:HAS_STEP]->(s:ChainStep)-[:LED_TO]->(d:ChainDecision)
RETURN d.decision_type, d.from_state, d.to_state, d.reason, d.made_by, d.approved
ORDER BY s.iteration

๐Ÿค– AI Surface Annotations

The adversarial-AI surface recon (see AI_SURFACE_RECON_MODULE.md) lands as property additions on existing nodes plus new instances on existing labels. Zero new node labels are introduced.

Naming convention โ€” prefix-based discovery

Every property added by the AI recon path carries one of two prefixes so the agent's text-to-cypher path can identify AI annotations structurally instead of from an enumerated allow-list:

PatternWhenExample
ai_*Non-boolean AI property whose name alone wouldn't make its AI nature obviousai_framework_name, ai_runtime_version, ai_service_hint, ai_frontend_product_guess
is_ai_*AI boolean classifier (matches the existing is_* boolean convention)is_ai_framework_detected
Value-prefixed (no field rename)The host module already owns a generic field (Technology.category, etc.). We add new values whose own prefix (ai-, llm-, AML.T) signals the AI natureTechnology.category="ai-runtime"

Once this rule is in the agent's text-to-cypher prompt, future AI properties added by later laps inherit semantic accessibility for free:

// Any node carrying any AI annotation
MATCH (n) WHERE any(k IN keys(n) WHERE k STARTS WITH 'ai_' OR k STARTS WITH 'is_ai_')
  AND n.project_id = $pid
RETURN labels(n) AS label, count(*) AS n

Property additions (lap 1 โ€” domain_recon + port_scan + http_probe)

Node labelNew propertyTypeWritten by
Subdomainai_service_hintstring (provider name like "anthropic", "replicate", "huggingface", or "ai-hosting-candidate")domain_recon (TXT / NS regex against AI_TXT_PATTERNS and AI_NS_HINT_PATTERNS)
Serviceai_runtime_versionstring (e.g. "ollama", "vllm", "litellm", "tgi")nmap_scan (regex over nmap product/version field)
Endpointis_ai_framework_detectedboolhttp_probe (any of: header match, favicon hash hit, title regex hit)
Endpointai_framework_namestring (e.g. "langchain", "vllm", "litellm")http_probe (header signature winner)
Endpointai_frontend_product_guessstring (e.g. "open-webui", "flowise", "gradio")http_probe (favicon hash or title regex; favicon wins)
Endpointai_interface_typeenum ("llm-chat", "llm-completion", "llm-embedding", "llm-tool-call", "sse-stream", "mcp", "llm-graphql", "non-llm")resource_enum (path regex against AI_PATH_PATTERNS)
Endpointis_ai_rag_ingestboolresource_enum (path regex against AI_RAG_PATH_PATTERNS; ambiguous paths gated on parent BaseURL being AI-tagged)
Parameteris_ai_prompt_injectableboolresource_enum (name in AI_PARAM_NAMES AND parent Endpoint AI-classified)
Parameterai_tool_arg_pathstring (JSON Pointer e.g. "/parameters/properties/query")reserved โ€” the graph write path exists (graph_db/mixins/recon/resource_mixin.py, COALESCEd so it is never erased), but the resolver in recon/main_recon_modules/resource_enum.py is still a stub, so nothing populates it yet. Intended to resolve against discovered OpenAPI / ai-plugin.json / MCP tools/list specs

Patch D model split (May 2026): AI annotations now live on Endpoint rather than BaseURL. BaseURL was redefined as host-level (scheme://host:port) โ€” one per HTTP service โ€” and Endpoint carries each probed path's status/headers/title/AI signals. Endpoints reach their parent via (BaseURL)-[:HAS_ENDPOINT]->(Endpoint), or directly via Endpoint.baseurl property. The USES_TECHNOLOGY edge from http_probe also moved to Endpoint.

Value-prefixed reused fields (lap 1)

Node labelField (existing)New AI-prefixed values addedWritten by
Technologycategoryai-framework, ai-runtime, ai-vector-db, ai-proxy, ai-sdk-client, ai-frontend, ai-mlops (existing non-AI values untouched). ai-mlops covers experiment-tracking / observability surfaces: MLflow, Langfuse, Phoenix-Arize, Ray Dashboard, Argilla, AutoGen Studio.port_scan / http_probe
Technology relationship :USES_TECHNOLOGYdetected_bynaabu-ai-port, masscan-ai-port, httpx-ai-header, httpx-ai-favicon, httpx-ai-titleport_scan / masscan_scan / http_probe

Relationships reused โ€” no new edge types

  • (Service)-[:USES_TECHNOLOGY]->(Technology) and (Port)-[:HAS_TECHNOLOGY]->(Technology) โ€” existing relationships used by graph_db/mixins/recon/port_mixin.py. The AI hook MERGEs new Technology nodes with category in ai-* and links via the existing relationship type, distinguished by the new detected_by property value.
  • (Endpoint)-[:USES_TECHNOLOGY {confidence, detected_by}]->(Technology) โ€” existing relationship used by graph_db/mixins/recon/http_mixin.py. Patch D moved this edge from BaseURL to Endpoint so the per-path Technology signal lines up with the per-path AI annotations. The BaseURL carries no USES_TECHNOLOGY edges from http_probe.

Useful query patterns

// All AI endpoints (Patch D: AI annotations live on Endpoint, BaseURL hosts the service)
MATCH (b:BaseURL)-[:HAS_ENDPOINT]->(e:Endpoint)
WHERE e.is_ai_framework_detected = true
  AND e.project_id = $pid
RETURN b.url AS base_url, e.path, e.ai_framework_name, e.ai_frontend_product_guess

// AI Technology rollup (frameworks/runtimes/vector-dbs/frontends/proxies)
MATCH (t:Technology) WHERE t.category STARTS WITH 'ai-'
  AND t.project_id = $pid
RETURN t.category, t.name, count(*) AS instances

// Hosts with DNS evidence of an AI provider (before any port/HTTP probe)
MATCH (s:Subdomain) WHERE s.ai_service_hint IS NOT NULL
  AND s.project_id = $pid
RETURN s.name, s.ai_service_hint

// Vector databases exposed (via port_scan AI port catalogue)
MATCH (svc:Service)-[:USES_TECHNOLOGY]->(t:Technology)
WHERE t.category = 'ai-vector-db' AND svc.project_id = $pid
RETURN svc.port, t.name, svc.host

Property additions (central ai_surface_recon lap โ€” active probing)

Written by recon/main_recon_modules/ai_surface_recon.py (full pipeline, display Phase 4.5) and its partial-recon twin, via update_graph_from_ai_surface_recon. All COALESCE-merged. See AI_SURFACE_RECON_MODULE.md for the full module walkthrough (per-workload input/output nodes, settings, developer guide).

Node labelNew propertyTypeMeaning
Endpointai_supports_toolsbooltool-call schema present in a discovered OpenAPI / ai-plugin spec
Endpointai_supports_visionboolimage content type present in spec
Endpointai_supports_streamingboolSSE confirmed by chat-shape probe or advertised by a discovered OpenAPI spec
Endpointai_model_family_guessstring (gpt/claude/llama/โ€ฆ)from /v1/models, /api/tags, or Julius extract
Endpointai_model_idsstring[] (โ‰ค50)deployed model ids from /v1/models + Julius extract (deduped)
Endpointai_tool_schema_refstring (path)cached OpenAPI/manifest spec on disk; read by the resource_enum tool-arg resolver
Endpointai_latency_p50_msfloatp50 latency from the 1-token chat ping
Endpointai_mcp_server_name / ai_mcp_server_version / ai_mcp_protocol_versionstringMCP InitializeResult serverInfo + negotiated version
Endpointai_mcp_tool_count / ai_mcp_resource_count / ai_mcp_prompt_countintMCP manifest sizes
Endpointai_mcp_capsstring[]advertised MCP capabilities (tools/resources/prompts/โ€ฆ)
Endpointai_mcp_auth_requiredbool401 + WWW-Authenticate on the MCP endpoint
Endpointai_mcp_tools_hash / ai_mcp_instructions_hashstring (sha256)rug-pull pins โ€” change across scans flags a sleeper/rug-pull
Parameterai_tool_arg_pathstring (JSON Pointer)now populated for MCP tool args (/inputSchema/properties/<arg>)
Technologycategory="ai-vector-db" via USES_TECHNOLOGY {detected_by="ai-surface-recon-probe"}โ€”confirmed by a benign read against Chroma/Qdrant/Weaviate/Milvus
Technologycategory="ai-*" (e.g. ai-runtime) via USES_TECHNOLOGY {detected_by="ai-surface-recon-julius"}โ€”AI service software (Ollama, vLLM, LiteLLM, โ€ฆ) confirmed by the Julius fingerprint pack, linked to the host Endpoint

New Vulnerability source โ€” ai_surface_recon (MCP static findings): type โˆˆ {mcp_tool_poisoning, mcp_prompt_injection, mcp_data_exfiltration, mcp_command_injection, mcp_code_execution, mcp_credential_harvesting, mcp_annotation_mismatch}; carries ai_owasp_llm_id, ai_atlas_technique, ai_payload_class="mcp_static", evidence (YARA matched string + offset). Deterministic id (aisr_<sha16>); linked to the MCP Endpoint via HAS_VULNERABILITY (fallback BaseURL โ†’ Subdomain โ†’ Domain).

New Vulnerability sources โ€” garak / pyrit / giskard / promptfoo (AI Attack Surface, the operator-launched deterministic offensive-testing layer): tests the discovered LLM endpoints and writes normalized Vulnerability findings (zero new labels). source โˆˆ {garak, pyrit, giskard, promptfoo}. Properties:

PropertyMeaning
sourcethe tool: garak (single-shot probes) / pyrit (bounded multi-turn) / giskard (quality+safety scan) / promptfoo (broad red-team eval, per-plugin ASR โ€” corroboration)
typeai_attack_<chip>, e.g. ai_attack_jailbreak, ai_attack_prompt_injection
ai_owasp_llm_idLLM01..LLM10 (or safety for toxicity/harmful)
ai_asrattack success rate over trials (0โ€“1); giskard issues are binary (1.0); promptfoo = fails/total per plugin
ai_trialstrials / objectives / examples evaluated
ai_oracle_kindhow success was scored: classifier / judge_llm / contains / regex / length / latency
ai_payload_classe.g. garak-dan, pyrit-crescendo, giskard-LLMPromptInjectionDetector, promptfoo-beavertails
ai_probe_pack_versiontool+version for reproducibility, e.g. garak/0.15.1, pyrit/0.14.0, giskard/2.19.1, promptfoo/0.121.17
ai_transcript_refpath to the saved native report on disk (scanners/ai_attack_surface_scan/output/{run_id}/{tool}/โ€ฆ)
ai_target_urlthe attacked URL (so a custom off-graph target still displays a target)
ai_attack_synthetictrue on a BaseURL/Endpoint/Subdomain/Domain/IP node the normalizer created for a custom off-graph target (so it never overwrites or is confused with a recon-discovered node). Such nodes also carry source='ai_attack_target'.

Deterministic id (aiatk_<sha16>, keyed on source + OWASP-LLM id + payload_class + target โ†’ re-runs MERGE rather than duplicate; the same vuln found by two tools dedups on the payload class). A finding is never orphaned. It links to the attacked Endpoint via HAS_VULNERABILITY when recon already discovered it; otherwise the normalizer materialises the target node chain โ€” BaseURL -[:HAS_ENDPOINT]-> Endpoint anchored to Domain -[:HAS_SUBDOMAIN]-> Subdomain -[:HAS_BASE_URL]-> BaseURL for a hostname, or to an IP -[:HAS_VULNERABILITY]-> Vulnerability for a raw IP (nodes marked source='ai_attack_target', ai_attack_synthetic=true), mirroring how partial recon materialises user-typed inputs. So AI-attack findings always sit connected next to nuclei/GVM findings in the RedZone tables and the main report.

Properties reserved for later laps

Documented here so the prefix convention stays coherent as later laps land. Empty / IS NULL until the relevant lap ships.

Node labelReserved propertyLap
Vulnerabilityai_asr, ai_trials, ai_oracle_kind, ai_transcript_ref, ai_payload_class, ai_probe_pack_version, ai_target_urlโœ… SHIPPED โ€” AI Attack Surface (garak/pyrit/giskard/promptfoo, see section above)
CVEis_ai_libraryvuln_scan AI library lookup lap
Secret / MultiscannerFinding / GithubSecretai_providersecret-multiscanner / github-secret-hunt AI detector lap
JsReconFindingfinding_type values ai-sdk-client, ai-sdk-key-literal, ai-sdk-browser-allowed, ai-frontend-detectedjs_recon AI SDK lap
MitreDataid starting with AML.Tadd_mitre ATLAS lap

๐Ÿ•ฐ๏ธ Scan Timeline (versions live in Postgres, NOT in the graph)

The Neo4j schema does not change for the Scan Timeline. There is no version node, no version property, and no :VERSION_OF relationship โ€” nothing in this document is modified by the feature.

The model

  • The live Neo4j graph IS the current version. It behaves exactly as before: a full recon wipes and rebuilds it. Everything that reads or writes the graph (the agent, partial recon, the ~30 RedZone/analytics endpoints, saved views) therefore sees whichever version is active, unchanged.
  • A past version is a saved snapshot: the project's subgraph, serialized and gzipped into Postgres. Past versions are read-only and never present in Neo4j, so old data can never reach the agent or an analytics query.
  • Snapshots are taken before a new full scan overwrites the graph โ€” and only when the user chooses to keep it (the "new version vs overwrite" modal).

Postgres models

ModelPurpose
ScanVersionOne point-in-time identity for the recon graph. isCurrent = true on exactly one row per project โ€” that row IS the live graph and has snapshot = null. A frozen (past) version carries snapshot bytes. @@unique([projectId, seq]) keeps numbering from forking.
ScanJobRun history: trigger (manual/scheduled), mode, status (queued/running/completed/failed/canceled/deferred_ram), who started it, timings, ramReason. kind records WHICH scan the row is for (full_recon | partial_recon | gvm | github_hunt | trufflehog | supply_chain | supply_chain_repo | ai_attack) โ€” before it, a directly-started non-recon run had no record at all. runId is the orchestrator run id for the kinds that allow several concurrent runs per project (partial_recon, ai_attack), and is empty for the one-per-project kinds.
ScanScheduleA future/recurring full scan: once / interval / cron (UTC), plus the scanMode to use for the previous graph.

Project also gains the activation lock columns activation_state, activation_started_at, activation_version_id (see below).

Snapshot payload shape

Snapshots are stored in the export (restore-fidelity) format, not the UI render shape, because a snapshot must be restorable back into Neo4j:

// gzip( JSON ) in ScanVersion.snapshot
{
  "nodes": [
    { "labels": ["Subdomain"], "properties": { /* ALL properties, incl. project_id/user_id */ },
      "_exportId": "<uuid>" }
  ],
  "relationships": [
    { "startExportId": "<uuid>", "endExportId": "<uuid>", "type": "RESOLVES_TO", "properties": {} }
  ]
}

The graph screen renders a version by converting this to the same { nodes, links } payload /api/graph returns, so the canvas, the clustering and the node/link tables are unchanged.

Agent session nodes are excluded. The AttackChain family (AttackChain, ChainStep, ChainFinding, ChainDecision, ChainFailure) is agent-run state, not recon state: it is filtered out of every capture, and preserved (not deleted) when a version is activated. Chains stay conversation-scoped exactly as documented in the Attack Chain Graph section above.

Activation (switching the active version)

Viewing a version only renders its bytes. Activating it swaps the live graph:

  1. freeze the outgoing current version from the live graph (not from its old stored bytes โ€” partial recon may have edited it since),
  2. delete the live recon graph excluding the AttackChain family, and restore the target version through the same code path the project import uses,
  3. only then move the isCurrent pointer, and invalidate the graph cache.

A failure in step 1 aborts before anything is deleted; a failure in step 2 leaves both endpoints intact in Postgres, so the activation is simply retriable.

Activation holds a project activation lock (Project.activation_state) and is mutually exclusive with a full scan, a partial recon run, an agent session, and the scan scheduler โ€” in both directions.

Only the recon graph is versioned. GVM/secret-scan output files, remediations, reports and captured HTTP traffic are project-level and always reflect the latest scan, whichever version is active.


๐Ÿ”ฎ Future Extensions (Not Implemented Yet)

  • GVMScan, GVMVulnerability, DetectedProduct, OSFingerprint nodes (GVM integration - designed but not yet created by code; GVM vulns currently stored as Vulnerability nodes with source="gvm"; Traceroute nodes now implemented)
  • Screenshot nodes linking to stored images

๐Ÿ“ฆ Supply-Chain Nodes (Malicious-Package Detection)

Shared by L1 (standalone SBOM/repo scan) and L2 (live-target recon harvest), so both sources dedup into the same nodes. See docs/readmes/README.SUPPLY_CHAIN.md.

Package (a discovered dependency)

(:Package {
  purl,          // canonical package URL, e.g. pkg:npm/lodash@4.17.21  (MERGE key)
  ecosystem,     // npm | PyPI | Go | Maven | crates.io | Packagist | RubyGems | NuGet
  name,
  version,       // nullable (L2 black-box may not know the version)
  source,        // sbom | lockfile | sourcemap | retirejs | import | wappalyzer | osv | finding
  source_path,   // manifest the package came from (L1 repo scans walk many lockfiles); coalesced, a later versionless sighting never erases it

// Verdict routing for a Package (the two are NOT interchangeable):
//   MALICIOUS  (OSV MAL-)        -> (:Package)-[:FLAGGED_AS]->(:MalPackageFinding {verdict:'malicious'})
//   SUSPICIOUS (GuardDog rule)   -> (:Package)-[:FLAGGED_AS]->(:MalPackageFinding {verdict:'suspicious'})
//   VULNERABLE (CVE-/GHSA-)      -> (:Package)-[:HAS_VULNERABILITY]->(:Vulnerability {source:'osv'})
// A package can carry both. Vulnerability reuses the nuclei/gvm node (no new
// label); severity comes from the OSV advisory band, defaulting to 'info' when
// the advisory is ungraded.
  user_id, project_id,
  first_seen, last_seen
})

Uniqueness: (purl, user_id, project_id) (tenant-scoped).

MalPackageFinding (a verdict about a package)

(:MalPackageFinding {
  finding_id,    // sha256(purl + ':' + advisory_or_rule)[:16]  (MERGE key)
  verdict,       // malicious (OSV MAL-) | suspicious (GuardDog)
  source_tool,   // osv | guarddog
  advisory_id,   // MAL-.../CVE-.../GHSA-... or a GuardDog rule name
  severity,      // high | medium | low | unknown
  confidence,    // malicious | suspicious
  title, detail,
  soft_error,    // true = the behavioural pass produced NO verdict (UNCHECKED, not clean)
  aliases,       // OSV alias ids for the advisory (a MAL- often also has a GHSA-)
  // --- incident context, from the public supplychainattack.org catalog -------
  // Attached AFTER the last artifact validation by
  // supply_chain_common.intel.enrich_findings, so these are deliberately NOT on
  // the DIRTY->CLEAN artifact allowlist: the analyzer may never supply them.
  // All NULL when the intel volume was never synced. NULL means "not in the
  // catalog OR never synced", never "this package is safe".
  incident_id,            // e.g. SCA-0001
  incident_url,           // link to the incident write-up
  incident_summary,       // free text, THIRD-PARTY and attacker-influenceable
  incident_blast_radius,  // e.g. "3,000 downloads"
  incident_remediation,   // list of steps, capped at 20
  incident_status,        // e.g. confirmed
  incident_feed_revised,  // feed revision that produced this enrichment
  user_id, project_id,
  first_seen, last_seen
})

Uniqueness: (finding_id, user_id, project_id) (tenant-scoped). The incident text never sets verdict and never sets title: the match is name-only (weaker evidence than an OSV verdict), and title is the graph viewer's node name, guarded at 120 chars.

A Scan Timeline snapshot serializes all node properties with no allowlist, so these ride along and restoring an old version restores the enrichment as it was at snapshot time. That is correct and intended: incident_feed_revised is what makes a restored snapshot interpretable. Only OSV MAL- ids produce verdict=malicious; CVE-/GHSA- are kept in raw JSON only, never written as malicious.

Relationships

  • (GithubRepository|BaseURL)-[:DEPENDS_ON]->(Package) (L1 anchors to the repo, L2 to the served BaseURL; an uploaded SBOM has no anchor and the Package floats).
  • (Package)-[:FLAGGED_AS]->(MalPackageFinding).

All writes MERGE (ON CREATE SET first_seen, unconditional SET last_seen). Constraints live in graph_db/schema.py (package_unique, malpackagefinding_unique); the writer is graph_db/mixins/supply_chain_mixin.py.


Origin-IP Discovery (CDN/WAF unmasking)

The origin_discovery module (recon GROUP 6 Phase A, gated by ORIGIN_DISCOVERY_ENABLED) unmasks the real origin server behind a CDN/WAF and records it by reusing the existing IP and Vulnerability labels โ€” no new node label โ€” plus one new relationship, HAS_ORIGIN. Writer: graph_db/mixins/osint_mixin.py (update_graph_from_origin_discovery).

IP node โ€” origin provenance properties

Set on the confirmed origin IP node (tenant-keyed {address, user_id, project_id}):

  • origin_confirmed (bool) โ€” the IP was confirmed as the origin by weighted-similarity validation.
  • is_origin_candidate (bool), origin_discovery_enriched (bool) โ€” provenance markers.
  • origin_discovery_method (string) โ€” subdomain | email_record | cert_san | favicon_hash | passive_dns.
  • origin_source (string) โ€” shodan | censys | fofa | zoomeye | otx | virustotal | securitytrails | viewdns | crtsh | dns.
  • origin_confidence (0-100), origin_for (the fronted host), cdn_fronting (the CDN name).
  • first_seen / last_seen / created_at (ISO ts) โ€” stamped like the sibling recon mixins so Recon Delta diffs by identity (address), not by a scan_id.

Vulnerability node

The exposure reuses the existing security-check finding shape: Vulnerability {type: 'waf_bypass', source: 'origin_discovery'} with matched_ip, hostname, url, matched_at, confidence_score, origin_discovery_method, origin_source, cdn_fronting, port, match_method, html_similarity, cert_match, header_match, status_code, and probe_url. Note url/matched_at are the canonical, port-less https://<ip> (IPv6 bracketed) โ€” this is what converges with the security-check producer โ€” while probe_url records the actual scheme://ip:port that was probed. Its id is the shared tenant-scoped stable_vuln_id(type, url, ip, user_id, project_id), so the same exposure emitted by both the security-check producer and origin_discovery within one tenant MERGEs to a single node. Cross-tenant collision is impossible in both halves: the id itself folds in the tenant, and vulnerability_tenant_unique is keyed on (id, user_id, project_id) rather than on id alone.

Relationships

  • (s:Subdomain)-[:HAS_ORIGIN {method, confidence, origin_source, discovered_at}]->(i:IP) โ€” the fronted Subdomain's real origin server.
  • (i:IP)-[:HAS_VULNERABILITY]->(v:Vulnerability) โ€” the origin-exposure finding.
  • (s:Subdomain)-[:WAF_BYPASS_VIA {evidence, discovered_at, source}]->(i:IP) โ€” reuses the existing WAF-bypass edge.

All writes MERGE on the tenant triple; a project wipe (clear_project_data) sweeps these nodes and the HAS_ORIGIN edge with no per-type code.