From a25e948054827bb9bf1240af7abe26813d4219dc Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 28 Aug 2026 10:36:29 -0400 Subject: [PATCH 01/10] docs: design spec for SNI/TLS-cert hostname discovery --- ...026-08-28-sni-hostname-discovery-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md diff --git a/docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md b/docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md new file mode 100644 index 0000000..8be1ba8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md @@ -0,0 +1,110 @@ +# SNI/TLS-Certificate Hostname Discovery — Design + +## Problem + +SpooNMAP already resolves operator-supplied hostnames to IPs at the start of a +scan (`preprocess_targets()`) and threads that mapping through to nmap +invocations and the final report. It has no way to *discover* hostnames +during a scan itself. TLS services frequently reveal hostnames the operator +never typed in — via the certificate's `commonName` and +`subjectAltName` (SAN) entries — and those names are valuable both for the +report and for follow-up work (vhost enumeration, resuming with a wider +target list, etc.). + +## Source + +SpooNMAP already runs the `ssl-cert` NSE script on common TLS ports (443, +465, 636, 993, 995, 8443, 10443, ...; see `_SCRIPT_PORTS`-style table around +`spoonmap.py:2969`) for **External** scans only — `ssl-cert` is deliberately +excluded from the Internal script set (`spoonmap.py:3007`, +"not relevant for internal assessments"). This feature adds no new scanning; +it only parses `ssl-cert` output SpooNMAP already collects in +`nse_results/*.xml`. + +## Extraction + +New helper `_extract_ssl_cert_hostnames(ssl_cert_output)`: + +- Reads `commonName=` off the `Subject:` line. +- Reads each `DNS:` entry off the `Subject Alternative Name:` line. +- Returns a deduped, order-preserved list — CN first, then SANs in the order + nmap printed them. +- Names starting with `*.` (wildcards) are returned like any other name (the + finding should report them) but are tagged so callers can exclude them from + anything that feeds scanning — a wildcard is not a usable target. +- Missing/malformed input (no `Subject:` line, no matches) returns `[]` + rather than raising; this mirrors the file's existing per-element-defensive + XML/regex parsing convention (CLAUDE.md, "XML result parsing is per-element + defensive"). + +## Merge into the hostname map + +Right after `nmap_scan()` returns in `main()` (`spoonmap.py:6549`), and +*before* `_aggregate_result_dir()` is called (`spoonmap.py:6581`), a new step +`_merge_ssl_cert_hostnames(output_path, ip_to_hostname)`: + +1. Walks `nse_results/*.xml` (the files `generate_findings()` already walks), + extracts `ssl-cert` script output per host/port via the existing + `_parse_result_xml()` path. +2. For each host, calls `_extract_ssl_cert_hostnames()` and takes the first + non-wildcard name (CN preferred, else first non-wildcard SAN). +3. Merges that name into the in-memory `ip_to_hostname` dict **only for IPs + that don't already have an entry** — an operator-supplied hostname (from + the target file) is never overwritten by a cert-derived guess. +4. Writes the merged dict back to `discovery/ip_hostname_map.json` via + `_atomic_write()` (the same file `preprocess_targets()` writes), so a + later `--resume` sees the enriched map too. + +Placing this before `_aggregate_result_dir()` means the combined +`spoonmap_output.json`/`.xml` and the gnmap merge pick up the cert-derived +hostname. Placing it before `generate_findings()` (`spoonmap.py:6592`, which +re-reads `ip_hostname_map.json` fresh at `spoonmap.py:3578-3585`) means +findings display it too. This does **not** retroactively change how *this +run's* nmap invocations targeted the host — hostname-based targeting +(`create_hostname_target_file()`) already happened earlier in the same run, +using whatever `ip_to_hostname` looked like at that time. The benefit is to +this run's reporting/output and to any future resume. + +This step runs unconditionally after `nmap_scan()` (not gated on +`target_scan == 'External'` at the call site) — the External-only gate is +already enforced upstream by `ssl-cert` only ever being scheduled on External +scans, so an Internal scan's `nse_results/` simply has no `ssl-cert` entries +to find, and the walk is a no-op. + +## Findings + +New INFO-severity finding in `generate_findings()`, alongside the existing +expired-certificate check (same `'ssl-cert' in scripts and target_scan == +'External'` gate at `spoonmap.py:3845`): + +- **Title:** `TLS Certificate Hostname(s) Identified` +- **Body:** lists every name `_extract_ssl_cert_hostnames()` returned for + that host/port (including wildcards — they're informative even though + unused for targeting), e.g. `Certificate presents: example.corp, + www.example.corp, *.example.corp`. +- One finding per host/port that has a non-empty extraction result; a cert + with no parseable CN/SAN produces no finding (same "skip, don't fabricate" + posture as the rest of `generate_findings()`). + +## Testing + +- `_extract_ssl_cert_hostnames()`: CN only, CN+SAN, SAN only, wildcard-only, + duplicate names across CN/SAN, malformed/missing `Subject:` line, empty + string input. +- `_merge_ssl_cert_hostnames()`: fills a gap for an IP with no prior entry; + never overwrites an existing (operator-supplied) entry; no-op when + `nse_results/` has no `ssl-cert` output (Internal scans); writes via + `_atomic_write()` so a partial write can't corrupt `ip_hostname_map.json`. +- `generate_findings()`: new finding appears with the right hostname list on + an External scan; does not appear on an Internal scan; does not appear when + `ssl-cert` output has no parseable names. + +## Out of scope + +- No active hostname/vhost probing (e.g. supplying candidate SNI values and + diffing responses) — this is passive extraction from certs SpooNMAP + already retrieves. +- No change to how *this run's* nmap invocations target hosts — see "Merge + into the hostname map" above. +- No reverse-DNS/PTR-based hostname discovery — out of scope per the + clarifying question during brainstorming; SNI/cert-derived only. From e4146efa191b0c867f0c4b632a885c5e51fbfe92 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 28 Aug 2026 12:42:05 -0400 Subject: [PATCH 02/10] docs: implementation plan for SNI/TLS-cert hostname discovery --- .../2026-08-28-sni-hostname-discovery.md | 502 ++++++++++++++++++ ...026-08-28-sni-hostname-discovery-design.md | 2 +- 2 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-28-sni-hostname-discovery.md diff --git a/docs/superpowers/plans/2026-08-28-sni-hostname-discovery.md b/docs/superpowers/plans/2026-08-28-sni-hostname-discovery.md new file mode 100644 index 0000000..95947ca --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-sni-hostname-discovery.md @@ -0,0 +1,502 @@ +# SNI/TLS-Certificate Hostname Discovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract hostnames from `ssl-cert` NSE output (commonName/SAN) already collected on External scans, merge non-wildcard names into the operator hostname map, and surface all names as a new LOW-severity finding. + +**Architecture:** Two small pure/near-pure functions added to `spoonmap.py` — `_extract_ssl_cert_hostnames()` (regex extraction from one script's text) and `_merge_ssl_cert_hostnames()` (walks `nse_results/*.xml`, fills gaps in `ip_to_hostname`, persists to `ip_hostname_map.json`) — plus one new block in `generate_findings()`'s existing per-port loop and one new call site in `main()`. No new scanning, no new dependencies. + +**Tech Stack:** Python 3.8+ stdlib only (`re`, `xml.etree.ElementTree` as `etree`, `json`), pytest. + +## Global Constraints + +- Python 3.8+ compatible syntax throughout (repo's `requires-python` floor; no walrus-in-comprehension tricks or 3.10+-only syntax). +- No new third-party dependencies — `spoonmap.py` is stdlib-only by design (CLAUDE.md, Release Versioning section). +- Every `etree.parse()` site must guard the parse (`except Exception: continue`) and use `.attrib.get(...)` rather than bare subscripting, matching the file's existing per-element-defensive XML parsing convention (CLAUDE.md, "XML result parsing is per-element defensive"). +- Durable writes (`ip_hostname_map.json`) go through `_write_if_changed()` (which itself uses `_atomic_write()`), never a bare `open(...).write()`. +- 95% coverage floor is enforced by pytest's `addopts` — new code needs tests exercising it, not just the happy path. +- Target branch/PR base is `nightly`, not `main` (repo convention: nightly cuts release candidates; confirmed `origin/main` and `origin/nightly` are at the same commit as of this plan). + +--- + +### Task 1: `_extract_ssl_cert_hostnames()` helper + +**Files:** +- Modify: `spoonmap.py` — insert new function immediately after `resolve_hostname()` ends (currently `spoonmap.py:581`), before `_write_if_changed()` (currently `spoonmap.py:583`). +- Test: `tests/test_spoonmap.py` — new `TestExtractSslCertHostnames` class (place near other standalone-helper test classes, e.g. above `class TestGenerateFindings:` at `tests/test_spoonmap.py:1154`). + +**Interfaces:** +- Produces: `_extract_ssl_cert_hostnames(ssl_cert_output: str) -> list[str]` — deduped, order-preserved list of hostnames found in one `ssl-cert` script's output text (module-level function on `spoonmap`). CN first, then each `Subject Alternative Name` `DNS:` entry in printed order. Wildcard names (`*.example.com`) are included in the returned list — callers that need to exclude them do so themselves. Empty/malformed input returns `[]`. + +- [ ] **Step 1: Write the failing tests** + +Add near the top of `tests/test_spoonmap.py`, in whatever import block already imports other module-level helpers from `spoonmap` (e.g. alongside `resolve_hostname`, `is_hostname`), add `_extract_ssl_cert_hostnames` to the import list if the test file imports names individually; otherwise reference as `spoonmap._extract_ssl_cert_hostnames` matching the file's existing convention for private helpers (check how e.g. `_fname_port` or `_ip_sort_key` are referenced in existing tests and mirror that exactly). + +```python +class TestExtractSslCertHostnames: + def test_cn_only(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Issuer: commonName=Example CA\n' + 'Not valid before: 2021-01-01T00:00:00\n' + 'Not valid after: 2099-01-01T00:00:00\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp'] + + def test_cn_plus_san(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:www.example.corp\n' + 'Issuer: commonName=Example CA\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp', 'www.example.corp'] + + def test_san_only_no_cn(self): + out = 'Subject Alternative Name: DNS:api.example.corp, DNS:cdn.example.corp\n' + assert _extract_ssl_cert_hostnames(out) == ['api.example.corp', 'cdn.example.corp'] + + def test_wildcard_included_in_result(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:*.example.corp\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp', '*.example.corp'] + + def test_does_not_pick_up_issuer_common_name(self): + # Issuer's commonName must never be mistaken for the subject's hostname. + out = ( + 'Subject: commonName=example.corp\n' + 'Issuer: commonName=DigiCert TLS RSA SHA256 2020 CA1\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp'] + + def test_duplicate_name_in_cn_and_san_not_repeated(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:www.example.corp\n' + ) + result = _extract_ssl_cert_hostnames(out) + assert result == ['example.corp', 'www.example.corp'] + assert result.count('example.corp') == 1 + + def test_malformed_output_returns_empty_list(self): + assert _extract_ssl_cert_hostnames('garbage, no useful fields here') == [] + + def test_empty_string_returns_empty_list(self): + assert _extract_ssl_cert_hostnames('') == [] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_spoonmap.py::TestExtractSslCertHostnames -v` +Expected: FAIL — `AttributeError` / `NameError` (`_extract_ssl_cert_hostnames` does not exist yet). + +- [ ] **Step 3: Implement** + +Insert into `spoonmap.py` right after `resolve_hostname()` (after its closing `return None` at line 581, before the blank line and `def _write_if_changed` at line 583): + +```python +def _extract_ssl_cert_hostnames(ssl_cert_output): + """Extract hostnames from ssl-cert NSE script output (CN + SAN). + + Returns a deduped, order-preserved list: the certificate's commonName + first, then each Subject Alternative Name DNS: entry in the order nmap + printed them. Wildcard names (e.g. '*.example.com') are returned like + any other name -- callers that feed a scan target must filter those out + themselves. Anchored to the 'Subject:' line specifically (not + 'Issuer:'), since ssl-cert output carries a commonName for both and only + the subject's identifies the host being scanned. + """ + hostnames = [] + seen = set() + + cn_match = re.search(r'^Subject:.*?commonName=([^\s/,]+)', ssl_cert_output, re.MULTILINE) + if cn_match: + cn = cn_match.group(1).strip() + if cn and cn not in seen: + hostnames.append(cn) + seen.add(cn) + + san_match = re.search(r'^Subject Alternative Name:\s*(.+)$', ssl_cert_output, re.MULTILINE) + if san_match: + for entry in san_match.group(1).split(','): + entry = entry.strip() + if entry.startswith('DNS:'): + name = entry[len('DNS:'):].strip() + if name and name not in seen: + hostnames.append(name) + seen.add(name) + + return hostnames +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_spoonmap.py::TestExtractSslCertHostnames -v` +Expected: PASS (8 passed). + +- [ ] **Step 5: Commit** + +```bash +git add spoonmap.py tests/test_spoonmap.py +git commit -m "feat: add ssl-cert CN/SAN hostname extraction helper" +``` + +--- + +### Task 2: `_merge_ssl_cert_hostnames()` and wiring into `main()` + +**Files:** +- Modify: `spoonmap.py` — insert new function immediately after `preprocess_targets()` ends (currently `spoonmap.py:945`, `return masscan_file, ip_to_hostname`), before `_get_scripts_for_port()` (currently `spoonmap.py:947`). +- Modify: `spoonmap.py` — `main()`, inside the `if banner_scan or script_scan:` block, immediately after the `snmp_any_validated` assignment (currently `spoonmap.py:6566-6568`) and before the `# Combine all live hosts into one file` comment (currently `spoonmap.py:6570`). +- Test: `tests/test_spoonmap.py` — new `TestMergeSslCertHostnames` class (place near `TestGenerateFindings`, since both consume `nse_results/*.xml`). + +**Interfaces:** +- Consumes: `_extract_ssl_cert_hostnames(ssl_cert_output: str) -> list[str]` (Task 1). `_disc(output_path) -> str` (existing, `spoonmap.py:1518`). `_write_if_changed(path, content)` (existing, `spoonmap.py:583`). `etree` = `xml.etree.ElementTree` (already imported module-wide as `etree`). +- Produces: `_merge_ssl_cert_hostnames(output_path: str, ip_to_hostname: dict) -> dict` — returns a new dict equal to `ip_to_hostname` plus any IP that had no entry and does have a non-wildcard cert-derived name. Persists the merged dict to `/discovery/ip_hostname_map.json` via `_write_if_changed()`. Never mutates the `ip_to_hostname` argument in place (callers rebind: `ip_to_hostname = _merge_ssl_cert_hostnames(output_path, ip_to_hostname)`). + +- [ ] **Step 1: Write the failing tests** + +```python +class TestMergeSslCertHostnames: + def test_fills_gap_for_ip_with_no_prior_entry(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=example.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {'1.2.3.4': 'example.corp'} + + def test_never_overwrites_operator_supplied_entry(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=cert-name.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {'1.2.3.4': 'operator-name.corp'}) + + assert result == {'1.2.3.4': 'operator-name.corp'} + + def test_wildcard_only_cert_does_not_fill_gap(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject Alternative Name: DNS:*.example.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {} + + def test_cn_preferred_over_wildcard_san(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:*.example.corp, DNS:example.corp\n' + )}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {'1.2.3.4': 'example.corp'} + + def test_no_op_when_nse_results_missing(self, tmp_path): + result = _merge_ssl_cert_hostnames(str(tmp_path), {'9.9.9.9': 'kept.corp'}) + assert result == {'9.9.9.9': 'kept.corp'} + + def test_no_op_when_no_ssl_cert_script_present(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '445', + scripts={'smb2-security-mode': 'Message signing enabled but not required'}) + (tmp_path / 'nse_results' / 'port445.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {} + + def test_writes_merged_map_to_disk(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=example.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + _merge_ssl_cert_hostnames(str(tmp_path), {}) + + import json as _json + on_disk = _json.loads((tmp_path / 'discovery' / 'ip_hostname_map.json').read_text()) + assert on_disk == {'1.2.3.4': 'example.corp'} + + def test_ignores_unparseable_xml_file(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + (tmp_path / 'nse_results' / 'port443.xml').write_text('not valid xml <<<') + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_spoonmap.py::TestMergeSslCertHostnames -v` +Expected: FAIL — `_merge_ssl_cert_hostnames` does not exist yet. + +- [ ] **Step 3: Implement** + +Insert into `spoonmap.py` right after `preprocess_targets()`'s `return masscan_file, ip_to_hostname` (line 945), before `def _get_scripts_for_port(dest_port, target_scan):` (line 947): + +```python +def _merge_ssl_cert_hostnames(output_path, ip_to_hostname): + """Fill gaps in ip_to_hostname from ssl-cert CN/SAN data in nse_results/. + + Never overwrites an existing (operator-supplied) entry -- a name typed + into the target file always wins over a cert-derived guess. For an IP + with no prior entry, prefers the certificate's commonName; falls back to + the first non-wildcard Subject Alternative Name. A host whose only + names are wildcards gets no entry, since a wildcard is not a usable + scan target. Returns a new dict; persists it to + /discovery/ip_hostname_map.json via _write_if_changed(). + """ + nse_dir = f'{output_path}/nse_results' + merged = dict(ip_to_hostname) + + if os.path.isdir(nse_dir): + for fname in sorted(os.listdir(nse_dir)): + if not fname.endswith('.xml'): + continue + try: + root = etree.parse(f'{nse_dir}/{fname}') + except Exception: + continue + + for host in root.findall('host'): + addr_elem = host.find("address[@addrtype='ipv4']") + if addr_elem is None: + addr_elem = host.find('address') + ip = addr_elem.attrib.get('addr') if addr_elem is not None else None + if not ip or ip in merged: + continue + + for port_elem in host.iter('port'): + scripts = {s.attrib['id']: s.attrib.get('output', '') + for s in port_elem.findall('script') if s.attrib.get('id')} + ssl_out = scripts.get('ssl-cert') + if not ssl_out: + continue + for name in _extract_ssl_cert_hostnames(ssl_out): + if not name.startswith('*.'): + merged[ip] = name + break + if ip in merged: + break + + mapping_file = os.path.join(_disc(output_path), 'ip_hostname_map.json') + os.makedirs(_disc(output_path), exist_ok=True) + _write_if_changed(mapping_file, json.dumps(merged, indent=2)) + + return merged +``` + +Then wire it into `main()`. In the `if banner_scan or script_scan:` block, change: + +```python + snmp_any_validated = {} + if script_scan: + snmp_any_validated = _validate_snmp_any_community(output_path, target_scan) +``` + +to: + +```python + snmp_any_validated = {} + if script_scan: + snmp_any_validated = _validate_snmp_any_community(output_path, target_scan) + ip_to_hostname = _merge_ssl_cert_hostnames(output_path, ip_to_hostname) +``` + +This runs before `_aggregate_result_dir(result_dir, ip_to_hostname)` (line 6581) and before `generate_findings(...)` (line 6592), so both pick up the merged map — `_aggregate_result_dir` via the rebound local, `generate_findings` via its own fresh read of `ip_hostname_map.json` off disk (`spoonmap.py:3578-3585`). Gated on `script_scan` because `nse_results/` (and therefore any `ssl-cert` output) only exists when `script_scan` is True — see `spoonmap.py:2661` (`if script_scan and not interrupt_event.is_set():`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_spoonmap.py::TestMergeSslCertHostnames -v` +Expected: PASS (8 passed). + +- [ ] **Step 5: Run the full suite to confirm no regressions in `main()`** + +Run: `uv run pytest tests/ -x -q` +Expected: all existing tests still pass (the `main()` change only adds a call inside an existing `if script_scan:` branch; no signature changes). + +- [ ] **Step 6: Commit** + +```bash +git add spoonmap.py tests/test_spoonmap.py +git commit -m "feat: merge ssl-cert-derived hostnames into the operator hostname map" +``` + +--- + +### Task 3: `TLS Certificate Hostname(s) Identified` finding + +**Files:** +- Modify: `spoonmap.py` — `generate_findings()`, insert new block immediately after the existing `# ── ssl-cert — expired (External only) ───` block (currently `spoonmap.py:3844-3852`), inside the same `for port_elem in host.iter('port'):` loop. +- Modify: `spoonmap.py` — `_FINDING_REPRO` dict (currently ends `spoonmap.py:4766`), add a new entry for the new title, modeled on the existing `'Expired TLS Certificate'` entry (`spoonmap.py:4473-4480`). +- Test: `tests/test_spoonmap.py` — add methods to the existing `# ── TLS certificate expiry ──` section of `TestGenerateFindings` (near `test_expired_cert_flagged` / `test_valid_cert_not_flagged`, `tests/test_spoonmap.py:1422-1436`). + +**Interfaces:** +- Consumes: `_extract_ssl_cert_hostnames(ssl_cert_output: str) -> list[str]` (Task 1). Existing `add(sev, host, port, title, detail='')` closure inside `generate_findings()` (`spoonmap.py:3529`). +- Produces: nothing consumed by later tasks — this is the terminal, user-visible deliverable. + +- [ ] **Step 1: Write the failing tests** + +```python + def test_ssl_cert_hostnames_flagged_on_external(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:www.example.corp\n' + )}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + txt = (nmap_dir / 'findings.txt').read_text() + assert 'TLS Certificate Hostname(s) Identified' in txt + assert 'example.corp' in txt + assert 'www.example.corp' in txt + + def test_ssl_cert_hostnames_includes_wildcard_in_detail(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:*.example.corp\n' + )}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + txt = (nmap_dir / 'findings.txt').read_text() + assert '*.example.corp' in txt + + def test_ssl_cert_hostnames_not_flagged_on_internal(self, nmap_dir): + xml = _nmap_xml('10.0.0.2', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=internal.corp\n'}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'Internal') + assert 'TLS Certificate Hostname(s) Identified' not in (nmap_dir / 'findings.txt').read_text() + + def test_ssl_cert_no_parseable_names_no_finding(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Not valid after: 2099-01-01T00:00:00\n'}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + assert 'TLS Certificate Hostname(s) Identified' not in (nmap_dir / 'findings.txt').read_text() + + def test_ssl_cert_hostname_finding_is_low_severity(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=example.corp\n'}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + import json as _json + records = _json.loads((nmap_dir / 'findings.json').read_text()) + matches = [r for r in records if r['title'] == 'TLS Certificate Hostname(s) Identified'] + assert len(matches) == 1 + assert matches[0]['severity'] == 'LOW' +``` + +Check `findings.json`'s exact record key names before assuming `'severity'`/`'title'` — open `tests/test_spoonmap.py` around `test_anonymous_ftp_detected_rated_low_with_review_note` (`tests/test_spoonmap.py:1157-1168`) and match whatever keys that existing test reads. `SEVERITY_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']` (`spoonmap.py:3336`) has no `INFO` tier — `findings.sort()` calls `SEVERITY_ORDER.index(f[0])`, which raises `ValueError` on an unlisted severity and would take down the whole findings phase. Use `'LOW'`, matching the precedent of other informational findings like "SQL Server Instance Discovered" (`spoonmap.py:3564`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_spoonmap.py -k ssl_cert_hostname -v` +Expected: FAIL — no matching finding produced yet. + +- [ ] **Step 3: Implement** + +In `generate_findings()`, immediately after the existing block: + +```python + # ── ssl-cert — expired (External only) ─────────────────── + if 'ssl-cert' in scripts and target_scan == 'External': + out = scripts['ssl-cert'] + m = re.search(r'Not valid after:\s+(\d{4}-\d{2}-\d{2})', out) + if m: + expiry = datetime.date.fromisoformat(m.group(1)) + if expiry < datetime.date.today(): + add('MEDIUM', ip, port_str, 'Expired TLS Certificate', + f'Certificate expired on {expiry}.') +``` + +add: + +```python + # ── ssl-cert — hostnames from CN/SAN (External only) ───── + if 'ssl-cert' in scripts and target_scan == 'External': + cert_hostnames = _extract_ssl_cert_hostnames(scripts['ssl-cert']) + if cert_hostnames: + add('LOW', ip, port_str, 'TLS Certificate Hostname(s) Identified', + f'Certificate presents: {", ".join(cert_hostnames)}.') +``` + +In `_FINDING_REPRO`, immediately after the existing `'Expired TLS Certificate'` entry (`spoonmap.py:4473-4480`): + +```python + 'TLS Certificate Hostname(s) Identified': { + 'flags': '--script ssl-cert', + 'sample': ( + 'PORT STATE SERVICE\n' + '443/tcp open https\n' + '| ssl-cert: Subject: commonName=example.corp\n' + '|_Subject Alternative Name: DNS:example.corp, DNS:www.example.corp' + ), + }, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_spoonmap.py -k ssl_cert_hostname -v` +Expected: PASS (5 passed). + +- [ ] **Step 5: Run the full suite** + +Run: `uv run pytest tests/ -x -q` +Expected: all tests pass, coverage still at/above the 95% floor. + +- [ ] **Step 6: Commit** + +```bash +git add spoonmap.py tests/test_spoonmap.py +git commit -m "feat: report TLS certificate CN/SAN hostnames as a LOW-severity finding" +``` + +--- + +### Task 4: Documentation + +**Files:** +- Modify: `CLAUDE.md` — add a short subsection describing the feature, matching the file's existing documentation depth for related subsystems (e.g. the "Hostname support" bullet under **Key Implementation Details**, and the "Honeypot/tarpit detection" bullet immediately above it, as the closest precedent for a self-contained detection feature documented in one bullet). + +**Interfaces:** +- Consumes: nothing — pure documentation, written after Tasks 1-3 land so line/behavior references are accurate. +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Add a bullet to `CLAUDE.md`'s "Key Implementation Details" section** + +Insert a new bullet immediately after the existing **Hostname support** bullet (`CLAUDE.md`, search for `**Hostname support**:`): + +```markdown +- **TLS certificate hostname discovery**: on External scans, `_extract_ssl_cert_hostnames()` parses the `ssl-cert` NSE output SpooNMAP already collects (commonName off the `Subject:` line, each `DNS:` entry off `Subject Alternative Name:`) and reports every name found as a LOW-severity `TLS Certificate Hostname(s) Identified` finding, wildcards included. Separately, `_merge_ssl_cert_hostnames()` runs right after the NSE script pass (gated on `script_scan`, since that's the only time `nse_results/` exists) and fills gaps in `ip_to_hostname` with the first non-wildcard name per host — never overwriting an operator-supplied hostname from the target file — then rewrites `discovery/ip_hostname_map.json`. It runs before `_aggregate_result_dir()` and `generate_findings()` (which re-reads that file fresh) so `spoonmap_output.*` and the findings report both reflect the merged map for the current run. It does not retroactively change what *this* run's nmap invocations targeted — hostname-based targeting via `create_hostname_target_file()` already happened earlier in the same run using whatever `ip_to_hostname` looked like at that point; the benefit is to this run's reporting and to a future `--resume`. +``` + +- [ ] **Step 2: Verify the doc references are accurate** + +Run: `grep -n "_extract_ssl_cert_hostnames\|_merge_ssl_cert_hostnames" spoonmap.py` and confirm both function names match exactly what Tasks 1-2 implemented (no drift from renames during review). + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: document SNI/TLS-certificate hostname discovery" +``` + +--- + +## Post-plan: PR target + +Per this plan's Global Constraints, open the PR against `nightly`, not `main` — confirm `origin/nightly`'s tip hasn't diverged from `origin/main` since this plan was written (`git rev-parse origin/main origin/nightly`); if it has, rebase onto `origin/nightly` before opening the PR. diff --git a/docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md b/docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md index 8be1ba8..0eb47d2 100644 --- a/docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md +++ b/docs/superpowers/specs/2026-08-28-sni-hostname-discovery-design.md @@ -73,7 +73,7 @@ to find, and the walk is a no-op. ## Findings -New INFO-severity finding in `generate_findings()`, alongside the existing +New LOW-severity finding in `generate_findings()`, alongside the existing expired-certificate check (same `'ssl-cert' in scripts and target_scan == 'External'` gate at `spoonmap.py:3845`): From dcde420f24e8e4868d4f6534a8239d7e83c20131 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 28 Aug 2026 13:34:43 -0400 Subject: [PATCH 03/10] feat: add ssl-cert CN/SAN hostname extraction helper --- spoonmap.py | 33 ++++++++++++++++++++++++++ tests/test_spoonmap.py | 54 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/spoonmap.py b/spoonmap.py index 091d622..e53fb28 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -583,6 +583,39 @@ def resolve_hostname(hostname): print(_COLOR_ERROR + f'Warning: Could not resolve hostname {hostname}: {e}' + _COLOR_RESET) return None +def _extract_ssl_cert_hostnames(ssl_cert_output): + """Extract hostnames from ssl-cert NSE script output (CN + SAN). + + Returns a deduped, order-preserved list: the certificate's commonName + first, then each Subject Alternative Name DNS: entry in the order nmap + printed them. Wildcard names (e.g. '*.example.com') are returned like + any other name -- callers that feed a scan target must filter those out + themselves. Anchored to the 'Subject:' line specifically (not + 'Issuer:'), since ssl-cert output carries a commonName for both and only + the subject's identifies the host being scanned. + """ + hostnames = [] + seen = set() + + cn_match = re.search(r'^Subject:.*?commonName=([^\s/,]+)', ssl_cert_output, re.MULTILINE) + if cn_match: + cn = cn_match.group(1).strip() + if cn and cn not in seen: + hostnames.append(cn) + seen.add(cn) + + san_match = re.search(r'^Subject Alternative Name:\s*(.+)$', ssl_cert_output, re.MULTILINE) + if san_match: + for entry in san_match.group(1).split(','): + entry = entry.strip() + if entry.startswith('DNS:'): + name = entry[len('DNS:'):].strip() + if name and name not in seen: + hostnames.append(name) + seen.add(name) + + return hostnames + def _write_if_changed(path, content): """Write *content* to *path* only if it differs from the current contents. diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 70b5452..0e45b62 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -42,6 +42,7 @@ _count_hosts_in_file, _count_unmatched_service_ports, _external_exposure_scripts, + _extract_ssl_cert_hostnames, _format_eta, _raise_fd_limit, _sql_version_year, @@ -354,6 +355,59 @@ def test_failed_resolution_returns_none_and_warns(self, capsys): assert 'Could not resolve hostname' in capsys.readouterr().out +class TestExtractSslCertHostnames: + def test_cn_only(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Issuer: commonName=Example CA\n' + 'Not valid before: 2021-01-01T00:00:00\n' + 'Not valid after: 2099-01-01T00:00:00\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp'] + + def test_cn_plus_san(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:www.example.corp\n' + 'Issuer: commonName=Example CA\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp', 'www.example.corp'] + + def test_san_only_no_cn(self): + out = 'Subject Alternative Name: DNS:api.example.corp, DNS:cdn.example.corp\n' + assert _extract_ssl_cert_hostnames(out) == ['api.example.corp', 'cdn.example.corp'] + + def test_wildcard_included_in_result(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:*.example.corp\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp', '*.example.corp'] + + def test_does_not_pick_up_issuer_common_name(self): + # Issuer's commonName must never be mistaken for the subject's hostname. + out = ( + 'Subject: commonName=example.corp\n' + 'Issuer: commonName=DigiCert TLS RSA SHA256 2020 CA1\n' + ) + assert _extract_ssl_cert_hostnames(out) == ['example.corp'] + + def test_duplicate_name_in_cn_and_san_not_repeated(self): + out = ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:www.example.corp\n' + ) + result = _extract_ssl_cert_hostnames(out) + assert result == ['example.corp', 'www.example.corp'] + assert result.count('example.corp') == 1 + + def test_malformed_output_returns_empty_list(self): + assert _extract_ssl_cert_hostnames('garbage, no useful fields here') == [] + + def test_empty_string_returns_empty_list(self): + assert _extract_ssl_cert_hostnames('') == [] + + class TestCountHostsInFile: def test_counts_bare_ips_and_cidrs(self, tmp_path): f = tmp_path / 'targets.txt' From 5a53b7304cd4891ff4b66310656ca24f3ab40d16 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 28 Aug 2026 13:39:22 -0400 Subject: [PATCH 04/10] feat: merge ssl-cert-derived hostnames into the operator hostname map --- spoonmap.py | 52 +++++++++++++++++++++++++++ tests/test_spoonmap.py | 80 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/spoonmap.py b/spoonmap.py index e53fb28..3f012e7 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -980,6 +980,57 @@ def preprocess_targets(target_file, output_path): return masscan_file, ip_to_hostname + +def _merge_ssl_cert_hostnames(output_path, ip_to_hostname): + """Fill gaps in ip_to_hostname from ssl-cert CN/SAN data in nse_results/. + + Never overwrites an existing (operator-supplied) entry -- a name typed + into the target file always wins over a cert-derived guess. For an IP + with no prior entry, prefers the certificate's commonName; falls back to + the first non-wildcard Subject Alternative Name. A host whose only + names are wildcards gets no entry, since a wildcard is not a usable + scan target. Returns a new dict; persists it to + /discovery/ip_hostname_map.json via _write_if_changed(). + """ + nse_dir = f'{output_path}/nse_results' + merged = dict(ip_to_hostname) + + if os.path.isdir(nse_dir): + for fname in sorted(os.listdir(nse_dir)): + if not fname.endswith('.xml'): + continue + try: + root = etree.parse(f'{nse_dir}/{fname}') + except Exception: + continue + + for host in root.findall('host'): + addr_elem = host.find("address[@addrtype='ipv4']") + if addr_elem is None: + addr_elem = host.find('address') + ip = addr_elem.attrib.get('addr') if addr_elem is not None else None + if not ip or ip in merged: + continue + + for port_elem in host.iter('port'): + scripts = {s.attrib['id']: s.attrib.get('output', '') + for s in port_elem.findall('script') if s.attrib.get('id')} + ssl_out = scripts.get('ssl-cert') + if not ssl_out: + continue + for name in _extract_ssl_cert_hostnames(ssl_out): + if not name.startswith('*.'): + merged[ip] = name + break + if ip in merged: + break + + mapping_file = os.path.join(_disc(output_path), 'ip_hostname_map.json') + os.makedirs(_disc(output_path), exist_ok=True) + _write_if_changed(mapping_file, json.dumps(merged, indent=2)) + + return merged + def _get_scripts_for_port(dest_port, target_scan): """Return comma-separated NSE script list for dest_port, or None. @@ -7398,6 +7449,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates if script_scan: snmp_any_validated = _validate_snmp_any_community( output_path, target_scan, extra_script_args) + ip_to_hostname = _merge_ssl_cert_hostnames(output_path, ip_to_hostname) # Combine all live hosts into one file disc = _disc(output_path) diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 0e45b62..86b47a2 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -43,6 +43,7 @@ _count_unmatched_service_ports, _external_exposure_scripts, _extract_ssl_cert_hostnames, + _merge_ssl_cert_hostnames, _format_eta, _raise_fd_limit, _sql_version_year, @@ -1229,6 +1230,85 @@ def nmap_dir(tmp_path): return tmp_path # callers write files under tmp_path/nse_results/ +class TestMergeSslCertHostnames: + def test_fills_gap_for_ip_with_no_prior_entry(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=example.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {'1.2.3.4': 'example.corp'} + + def test_never_overwrites_operator_supplied_entry(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=cert-name.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {'1.2.3.4': 'operator-name.corp'}) + + assert result == {'1.2.3.4': 'operator-name.corp'} + + def test_wildcard_only_cert_does_not_fill_gap(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject Alternative Name: DNS:*.example.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {} + + def test_cn_preferred_over_wildcard_san(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:*.example.corp, DNS:example.corp\n' + )}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {'1.2.3.4': 'example.corp'} + + def test_no_op_when_nse_results_missing(self, tmp_path): + result = _merge_ssl_cert_hostnames(str(tmp_path), {'9.9.9.9': 'kept.corp'}) + assert result == {'9.9.9.9': 'kept.corp'} + + def test_no_op_when_no_ssl_cert_script_present(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '445', + scripts={'smb2-security-mode': 'Message signing enabled but not required'}) + (tmp_path / 'nse_results' / 'port445.xml').write_text(xml) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {} + + def test_writes_merged_map_to_disk(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=example.corp\n'}) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml) + + _merge_ssl_cert_hostnames(str(tmp_path), {}) + + import json as _json + on_disk = _json.loads((tmp_path / 'discovery' / 'ip_hostname_map.json').read_text()) + assert on_disk == {'1.2.3.4': 'example.corp'} + + def test_ignores_unparseable_xml_file(self, tmp_path): + (tmp_path / 'nse_results').mkdir() + (tmp_path / 'nse_results' / 'port443.xml').write_text('not valid xml <<<') + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {} + + class TestGenerateFindings: # ── anonymous FTP ──────────────────────────────────────────────────────── From ae2266323da21923f8d74934fcea7fb0d211c926 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 28 Aug 2026 13:43:12 -0400 Subject: [PATCH 05/10] fix: use .get() for all .attrib accesses and add test for multi-port certificate scanning --- spoonmap.py | 3 ++- tests/test_spoonmap.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/spoonmap.py b/spoonmap.py index 3f012e7..295b7b7 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -1013,7 +1013,7 @@ def _merge_ssl_cert_hostnames(output_path, ip_to_hostname): continue for port_elem in host.iter('port'): - scripts = {s.attrib['id']: s.attrib.get('output', '') + scripts = {s.attrib.get('id'): s.attrib.get('output', '') for s in port_elem.findall('script') if s.attrib.get('id')} ssl_out = scripts.get('ssl-cert') if not ssl_out: @@ -1031,6 +1031,7 @@ def _merge_ssl_cert_hostnames(output_path, ip_to_hostname): return merged + def _get_scripts_for_port(dest_port, target_scan): """Return comma-separated NSE script list for dest_port, or None. diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 86b47a2..d20a3b3 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -1308,6 +1308,22 @@ def test_ignores_unparseable_xml_file(self, tmp_path): assert result == {} + def test_finds_cn_on_second_port_when_first_is_wildcard_only(self, tmp_path): + """Exercise the port-scanning loop: wildcard on first port, CN on second.""" + (tmp_path / 'nse_results').mkdir() + # First port (8443) has wildcard-only cert + xml1 = _nmap_xml('1.2.3.4', 'tcp', '8443', + scripts={'ssl-cert': 'Subject Alternative Name: DNS:*.example.corp\n'}) + # Second port (443) has usable CN + xml2 = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=api.example.corp\n'}) + (tmp_path / 'nse_results' / 'port8443.xml').write_text(xml1) + (tmp_path / 'nse_results' / 'port443.xml').write_text(xml2) + + result = _merge_ssl_cert_hostnames(str(tmp_path), {}) + + assert result == {'1.2.3.4': 'api.example.corp'} + class TestGenerateFindings: # ── anonymous FTP ──────────────────────────────────────────────────────── From 2434635c877d0dfe28bbfa0906d6d8d0e6383816 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 28 Aug 2026 13:47:40 -0400 Subject: [PATCH 06/10] feat: report TLS certificate CN/SAN hostnames as a LOW-severity finding --- spoonmap.py | 17 +++++++++++++++ tests/test_spoonmap.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/spoonmap.py b/spoonmap.py index 295b7b7..85327ed 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -3992,6 +3992,13 @@ def port_str_from_fname(fname): add('MEDIUM', ip, port_str, 'Expired TLS Certificate', f'Certificate expired on {expiry}.') + # ── ssl-cert — hostnames from CN/SAN (External only) ───── + if 'ssl-cert' in scripts and target_scan == 'External': + cert_hostnames = _extract_ssl_cert_hostnames(scripts['ssl-cert']) + if cert_hostnames: + add('LOW', ip, port_str, 'TLS Certificate Hostname(s) Identified', + f'Certificate presents: {", ".join(cert_hostnames)}.') + # ── ldap-signing-check (ports 389 / 3268) ──────────────────── if 'ldap-signing-check' in scripts and target_scan == 'Internal': if 'NOT REQUIRED' in scripts['ldap-signing-check'].upper(): @@ -4621,6 +4628,15 @@ def _signing_not_req(key): '|_Not valid after: 2022-01-01T00:00:00' ), }, + 'TLS Certificate Hostname(s) Identified': { + 'flags': '--script ssl-cert', + 'sample': ( + 'PORT STATE SERVICE\n' + '443/tcp open https\n' + '| ssl-cert: Subject: commonName=example.corp\n' + '|_Subject Alternative Name: DNS:example.corp, DNS:www.example.corp' + ), + }, 'SQL Server Instance Discovered': { 'flags': '--script ms-sql-info', 'sample': ( @@ -4940,6 +4956,7 @@ def _write_artifact(path, content): _PER_HOST_DETAIL_TITLES = frozenset({ 'Service Exposed Externally', 'VNC Desktop Name Disclosed', + 'TLS Certificate Hostname(s) Identified', }) diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index d20a3b3..c3e385a 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -1612,6 +1612,55 @@ def test_valid_cert_not_flagged(self, nmap_dir): generate_findings(str(nmap_dir), 'External') assert 'Expired TLS Certificate' not in (nmap_dir / 'findings.txt').read_text() + def test_ssl_cert_hostnames_flagged_on_external(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:www.example.corp\n' + )}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + txt = (nmap_dir / 'findings.txt').read_text() + assert 'TLS Certificate Hostname(s) Identified' in txt + assert 'example.corp' in txt + assert 'www.example.corp' in txt + + def test_ssl_cert_hostnames_includes_wildcard_in_detail(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': ( + 'Subject: commonName=example.corp\n' + 'Subject Alternative Name: DNS:example.corp, DNS:*.example.corp\n' + )}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + txt = (nmap_dir / 'findings.txt').read_text() + assert '*.example.corp' in txt + + def test_ssl_cert_hostnames_not_flagged_on_internal(self, nmap_dir): + xml = _nmap_xml('10.0.0.2', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=internal.corp\n'}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'Internal') + assert 'TLS Certificate Hostname(s) Identified' not in (nmap_dir / 'findings.txt').read_text() + + def test_ssl_cert_no_parseable_names_no_finding(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Not valid after: 2099-01-01T00:00:00\n'}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + assert 'TLS Certificate Hostname(s) Identified' not in (nmap_dir / 'findings.txt').read_text() + + def test_ssl_cert_hostname_finding_is_low_severity(self, nmap_dir): + xml = _nmap_xml('1.2.3.4', 'tcp', '443', + scripts={'ssl-cert': 'Subject: commonName=example.corp\n'}) + (nmap_dir / 'nse_results' / 'port443.xml').write_text(xml) + generate_findings(str(nmap_dir), 'External') + import json as _json + records = _json.loads((nmap_dir / 'findings.json').read_text()) + matches = [r for r in records if r['title'] == 'TLS Certificate Hostname(s) Identified'] + assert len(matches) == 1 + assert matches[0]['severity'] == 'LOW' + # ── known-bad service detection ─────────────────────────────────────────── def test_dameware_detected(self, nmap_dir): From b33fb9825f373206716e6a7bd133f1a21e96b13a Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 28 Aug 2026 13:49:31 -0400 Subject: [PATCH 07/10] docs: document SNI/TLS-certificate hostname discovery --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 33e2b85..b685af1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -436,6 +436,7 @@ Internal discovery runs a single masscan sweep (no source-port override) followe - **When the record is written and dropped**: on **success paths only**, exactly as `_EMPTY_RESULT_XML` is — a record on a killed scan would assert coverage that never happened. A `KeyboardInterrupt`, a missing binary and a non-zero exit all leave none; `_nmap_udp_discovery()` needs an explicit `proc.returncode == 0` guard for the last of those, because unlike `_nmap_port_discovery()` it does not treat a non-zero exit as fatal and a failing nmap can still leave parseable partial XML. Each phase also calls `_discard_coverage_record()` **before it scans**, not only on failure: a run killed outright never reaches the stamp, and the previous run's record would otherwise sit beside output that run had already replaced. The hazard is specifically a record that covered *more* than the output now on disk, since a subset test accepts it — a narrower leftover can only cause a redundant re-scan. Both halves live in one file written by a single `_atomic_write` for the same reason: as two sidecars written in sequence, a `KeyboardInterrupt` between them (a `BaseException`, so `except Exception` missed it) left a fresh target list beside a stale exclusion list and the gate accepted the pair as an exclusion-free scan. Neither an unreadable input nor a failed write raises on its own account — the scan already succeeded, so unwinding would discard real results — but every failure path, including an interrupt, deletes the record first. A cache whose record is absent or malformed is rejected, so output from before this change re-scans once on the first resume after upgrading; that is the deliberate direction, since a redundant re-scan is visible and an under-scan is not. The `.coverage` suffix is invisible to every result consumer: `masscan_results/` is aggregated by listing the directory, and `_parse_result_xml()` drops anything not ending in `.xml`, the same guard that hides `portN.xml.failed`. `_delete_previous_results()` removes it with the directory, so `--cleanup` and `[d]elete` need no special handling. - **config.json validation**: `_load_config()` refuses to start a scan it cannot run correctly. Missing required keys are reported all at once and exit. `target_scan` goes through `_config_target_scan()`, which accepts any case/whitespace spelling of `Internal`/`External` (normalising to the exact literal the ~25 comparison sites use) and exits on anything else — an unvalidated `"internal"` matched neither literal, so the scan ran and looked completely normal while every `target_scan == 'Internal'` gated check was silently skipped. Every numeric goes through `_config_int(key, value, default, minimum=1)`, mirroring `_prompt_int`'s floor: a non-numeric or null value warns and takes the default, and a value below `minimum` is clamped with a warning. `max_rate` is included (defaulting to the interactive prompt's 20000 external / 2000 internal) and only then re-`str()`-ed for Popen. - **Hostname support**: hostnames in the target file are resolved once at startup; nmap receives the original hostname (for SNI/vhost), masscan receives the resolved IP +- **TLS certificate hostname discovery**: on External scans, `_extract_ssl_cert_hostnames()` parses the `ssl-cert` NSE output SpooNMAP already collects (commonName off the `Subject:` line, each `DNS:` entry off `Subject Alternative Name:`) and reports every name found as a LOW-severity `TLS Certificate Hostname(s) Identified` finding, wildcards included. Separately, `_merge_ssl_cert_hostnames()` runs right after the NSE script pass (gated on `script_scan`, since that's the only time `nse_results/` exists) and fills gaps in `ip_to_hostname` with the first non-wildcard name per host — never overwriting an operator-supplied hostname from the target file — then rewrites `discovery/ip_hostname_map.json`. It runs before `_aggregate_result_dir()` and `generate_findings()` (which re-reads that file fresh) so `spoonmap_output.*` and the findings report both reflect the merged map for the current run. It does not retroactively change what *this* run's nmap invocations targeted — hostname-based targeting via `create_hostname_target_file()` already happened earlier in the same run using whatever `ip_to_hostname` looked like at that point; the benefit is to this run's reporting and to a future `--resume`. - **IPv4-only, enforced at the edges**: the tool scans IPv4 exclusively (masscan/nmap invocations, target expansion, and address sorting all assume it). IPv6 is rejected rather than half-supported, in two places. (1) `_build_discovery_target_file()`'s `_parse_ranges()` skips any entry `ipaddress.ip_network()` resolves to a non-v4 network and prints the offending file, line number, and content — previously the v6 bounds were stored silently and only surfaced hundreds of lines later as `AddressValueError: ... (>= 2**32)` from `summarize_address_range()`, and only when an exclusions file happened to be configured. (2) The masscan/discovery XML parsers (`_parse_masscan_ping_xml()`, `_parse_nmap_sn_xml()`, `_run_masscan_batch()`) select `address[@addrtype='ipv4']` instead of the first `
` child, matching what the nmap-side parsers already did, so a dual-stacked host's IPv6 or MAC string can't enter `live_ips`/`port_ips` and become a masscan `-iL` target. Address sorting goes through `_ip_sort_key()`, which orders valid IPv4 numerically and sorts anything unparseable last instead of raising — the three former inline `tuple(int(o) for o in x.split('.'))` keys ran *after* a completed sweep, so one odd entry discarded the whole thing. - **XML result parsing is per-element defensive**: every `etree.parse()` site guards the *walk* as well as the parse. Attributes are read with `.attrib.get(...)` and the element is skipped when the identifier is missing — never a bare `attrib['addr']` or `findall('address')[0]`, both of which raise `KeyError`/`IndexError` that `except etree.ParseError` does not catch. Those exceptions escaped the guard and discarded the results for *every other host* in the file (or, in `_host_elem_to_dict()`, lost `spoonmap_output.xml`/`.json` for the whole run) over one truncated element. `