Nmap Context Deep Dive
This page is the technical reference for the Nmap context adapter feature (NmapAdapterPass). It covers how the adapter works, how it is implemented, what it affects, its operational and security implications, and how to configure it.
Overview
VulnParse-Pin accepts Nmap XML scan output as optional supplementary context for vulnerability prioritization. Supplying a Nmap file does not alter the source vulnerability data — findings and assets derived from the primary scanner (Nessus, OpenVAS, Qualys, etc.) are never mutated. Instead, the adapter extracts confirmed open-port evidence and makes it available to downstream passes as a derived signal.
Two downstream effects are available:
- TopN ranking tiebreak — equal-score findings and assets that have a confirmed Nmap open port sort above those without one.
- Optional scoring bonus — a configurable raw-score addend applied when a finding's service port is confirmed open by Nmap (default
0.0, opt-in).
Both effects are independently controlled by policy knobs in config.yaml.
How it works
Pass lifecycle
NmapAdapterPass runs as the first derived pass in the pipeline, before Scoring@2.0, TopN@1.0, and Summary@1.0. It is a pure read-and-derive step:
[NmapAdapterPass] → [Scoring@2.0] → [TopN@1.0] → [Summary@1.0]
The pass writes its result into the DerivedContext registry under the key nmap_adapter@1.0. Downstream passes retrieve this by key — no direct coupling.
Asset identity join
The adapter maps Nmap hosts to scan assets using IP address and hostname tokens. It attempts:
- Exact IP match against
asset.ip_address - Hostname prefix/suffix matching against
asset.hostname
A "join" is recorded when a Nmap host maps to at least one scan asset. Unmatched Nmap hosts are tracked in unmatched_asset_ids in the output for observability.
Port extraction
For each matched host, the adapter collects all confirmed open TCP/UDP ports from the Nmap XML <port> elements. NSE script output is also scanned for CVE references when present.
The resulting per-asset port index is:
asset_open_ports: dict[str, tuple[int, ...]] # asset_id → (port, port, ...)
nse_cves_by_asset: dict[str, tuple[str, ...]] # asset_id → ("CVE-XXXX-XXXX", ...)
Downstream consumption
ScoringPass reads asset_open_ports via _get_nmap_open_ports_by_asset() on the DerivedContext. For each finding, it checks whether the finding's service port appears in the confirmed open ports for that asset. If a match is found, a "nmap_open_port": True signal is attached to the plugin attribute cache. If scoring_port_bonus > 0.0, this adds directly to raw_score before normalization.
TopNPass reads the same index. It injects an nmap_hit flag into all three sort key positions:
_rank_findings_for_asset:(-score, -nmap_hit, finding_id)_rank_assets:(-score, -crit_high, -crit_rank, -scorable_count, -nmap_confirmed, asset_id)_rank_global_findings:(-score, -nmap_hit, asset_id, finding_id)
This ensures deterministic and operationally meaningful ordering without numeric score inflation.
Status paths and ledger events
The pass always completes — it does not raise. Status is one of four values written to the DerivedPassResult:
| Status | Condition | Ledger reason code |
|---|---|---|
disabled |
--nmap-ctx was not supplied |
NMAP_CTX_DISABLED |
enabled |
XML parsed and joined successfully | NMAP_CTX_ENABLED |
error |
File unreadable or XML parse failure | NMAP_CTX_FAILED |
invalid_format |
Root XML tag is not nmaprun |
NMAP_CTX_INVALID_FORMAT |
All four paths emit a structured LedgerService decision event when a LedgerService is present in ctx.services. The NMAP_CTX_ENABLED event includes:
{
"status": "enabled",
"source_file": "/path/to/nmap.xml",
"host_count": 42,
"matched_asset_count": 39,
"unmatched_asset_count": 3,
"join_rate": 0.9286
}
join_rate is matched_asset_count / total_scan_assets, rounded to 4 decimal places. A low join rate may indicate IP/hostname mismatch between the Nmap scan scope and the vulnerability scanner scope.
Implementation files
| File | Role |
|---|---|
src/vulnparse_pin/core/passes/Nmap/nmap_adapter_pass.py |
Pass implementation |
src/vulnparse_pin/core/passes/Nmap/types.py |
NmapAdapterPassOutput dataclass |
src/vulnparse_pin/core/classes/decision_reasons.py |
NMAP_CTX_* reason codes |
src/vulnparse_pin/core/classes/scoring_pol.py |
ScoringPolicyV1.nmap_port_bonus |
src/vulnparse_pin/core/classes/dataclass.py |
Services.nmap_ctx_config |
src/vulnparse_pin/core/passes/Scoring/scoringPass.py |
nmap_open_port signal + bonus |
src/vulnparse_pin/core/passes/TopN/topn_pass.py |
Tiebreak wiring in all 3 sort paths |
src/vulnparse_pin/app/bootstrap.py |
CLI wiring and Services construction |
src/vulnparse_pin/app/runtime_helpers.py |
load_score_policy(nmap_port_bonus=...) |
src/vulnparse_pin/resources/config.yaml |
nmap_ctx config section defaults |
src/vulnparse_pin/core/schemas/config.schema.json |
Schema validation for nmap_ctx |
Configuration
Enabling Nmap context
Nmap context is opt-in. Pass the path to a Nmap XML file via --nmap-ctx:
vpp -f scan.nessus -o results.json --nmap-ctx nmap_results.xml
Short form:
vpp -f scan.nessus -o results.json -nmap nmap_results.xml
The argument requires a .xml extension and must be a readable file. If the file cannot be read or is not valid Nmap XML, the pass records the failure in the ledger and the pipeline continues normally — no findings are dropped.
config.yaml — nmap_ctx section
nmap_ctx:
port_tiebreak_enabled: true
scoring_port_bonus: 0.0
port_tiebreak_enabled
- Type: boolean
- Default:
true - Effect: when
true, TopN ranking uses confirmed open ports as a secondary sort key for equal-score findings and assets. Whenfalse, the sort key reverts to finding/asset ID only. - Does not affect numeric scores.
scoring_port_bonus
- Type: float, range
0.0–5.0 - Default:
0.0 - Effect: when
> 0, this value is added toraw_scorefor any finding whose service port is confirmed open by Nmap. The addend is applied before operational score normalization.
Score impact example with default ceilings (max_raw_risk: 15, max_op_risk: 10):
| Bonus | Raw score delta | Approx operational score delta |
|---|---|---|
0.0 |
+0.0 | +0.0 (tiebreak only) |
0.5 |
+0.5 | ~+0.33 |
1.5 |
+1.5 | ~+1.0 |
3.0 |
+3.0 | ~+2.0 |
5.0 (max) |
+5.0 | ~+3.33 |
The schema enforces a maximum of 5.0. This cap limits the influence of network context to a supplementary signal rather than a dominant factor.
Schema validation
config.schema.json validates both keys at startup:
"nmap_ctx": {
"type": "object",
"additionalProperties": false,
"properties": {
"port_tiebreak_enabled": { "type": "boolean" },
"scoring_port_bonus": { "type": "number", "minimum": 0.0, "maximum": 5.0 }
}
}
Unknown keys under nmap_ctx are rejected.
What it affects
| Area | Effect when disabled | Effect when enabled (defaults) | Effect with bonus > 0 |
|---|---|---|---|
| Findings/assets data | No change | No change | No change |
| Numeric scores | No change | No change | raw_score increases by bonus amount |
| TopN finding order | ID-sorted tiebreak | Nmap-confirmed ports sort first at equal score | Same as defaults |
| TopN asset order | ID-sorted tiebreak | Nmap-confirmed assets rank higher at equal score | Same as defaults |
| RunManifest ledger | No nmap events | NMAP_CTX_DISABLED event |
NMAP_CTX_ENABLED event with join stats |
derived.passes["nmap_adapter@1.0"] |
status: disabled |
status: enabled with port index |
status: enabled with port index |
Operational implications
When to use the tiebreak (default)
The tiebreak is the safest mode. It does not alter any numeric risk scores — it only promotes findings with confirmed open service ports within a group of otherwise equal-priority items. This is a signal of reachability: if two CVEs score identically but one's service port is confirmed reachable by Nmap, that finding gets surfaced first.
Use this when you want deterministic, operationally meaningful ordering at no risk of inflating scores.
When to use a scoring bonus
Set scoring_port_bonus > 0 when you want network reachability to explicitly lift findings above others on different assets. This is appropriate when:
- Confirmed open ports are a high-confidence indicator of exploitability in your environment.
- You are running both a vulnerability scanner and Nmap against the same scope with reliable overlap.
- You have validated the join rate is acceptable (e.g.,
≥ 0.80).
A small bonus (0.5–1.5) is generally sufficient to produce visible prioritization changes without drowning the CVSS/EPSS/KEV signal.
Join rate interpretation
| Join rate | Implication |
|---|---|
≥ 0.90 |
Strong overlap; Nmap context is highly reliable |
0.70–0.89 |
Acceptable; some scope mismatch |
0.50–0.69 |
Moderate mismatch; consider reviewing scan scopes |
< 0.50 |
Significant mismatch; Nmap context may not be representative |
Join rate is visible in the RunManifest ledger under the nmap_adapter component event.
Security considerations
- The Nmap XML file is read via
ctx.pfh.ensure_readable_file(...)— all standard PFH path policy constraints apply (symlink policy, root-read enforcement, etc.). - Parsing uses
defusedxmlto prevent XML external entity (XXE) and entity expansion attacks. nse_cves_by_assetCVE extraction applies the same_is_valid_cve_id()validator pattern used in GHSA to prevent malformed CVE strings from propagating into the output.- A maliciously crafted Nmap XML file cannot alter finding scores beyond the configured
scoring_port_bonusceiling (schema-enforced5.0). - The
NMAP_CTX_FAILEDandNMAP_CTX_INVALID_FORMATevents in the ledger provide a tamper-evidence trail if a supplied file is corrupt or unexpected.
Testing coverage
| Test file | What it covers |
|---|---|
tests/test_nmap_adapter_pass.py |
Disabled path, enabled path, error path, invalid format path, TopN tiebreak integration |
tests/test_parallel_scoring.py |
nmap_open_ports arg forwarding in plugin cache build |
tests/test_pass_contracts.py |
Pass contract and pipeline ordering |
tests/test_config_schema_validation.py |
nmap_ctx schema acceptance and unknown-key rejection |