feat: expanded honeypot/decoy detection - #52
Open
bandrel wants to merge 22 commits into
Open
Conversation
Following this repo's precedent (9ea2bb3): spec/plan docs stay out of the public repo long-term and get removed before this lands on main, with durable reasoning folded into CLAUDE.md instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-detection design/plan Extends signal #2 (named-product signature match) with a real tell found by reading johnnykv/heralding's current source: its VNC capability hardcodes RFB protocol version 3.7 and drops the connection before ever sending a security-type list, unlike any real RFB server. vnc-info reporting protocol 3.7 with no security types is therefore a reliable Heralding indicator. vnclowpot was checked too and has no equivalent standalone signature, so it is documented as considered-and-cut rather than included. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add cases where exactly one of name/product/servicefp is present alone, so each clause of the not-name-and-not-product-and-not-servicefp check is independently load-bearing. Previously all three clauses could be deleted individually and TestCountSilentOpenPorts still passed 9/9. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n wiring tests Task 2 review fix-round: - Add three tests to TestTtlSpreadByHost covering the previously-unreached ports_elem/state_elem/ttl_text None guards. - _ttl_spread_by_host() now iterates every <port> child of a host's <ports> block instead of only the first, moving the UDP-protocol skip inside the loop. masscan's own XML gives one <host> per open port so the bug never showed there, but the nmap-style multi-port-per-host XML written to masscan_results/portDirect.xml previously dropped every TCP port's TTL when a UDP port happened to come first. - Add mass_scan()-level wiring tests (TestFullPortScan's two new tests plus TestMassScanHoneypotFlag) covering all three suspected_honeypots.txt call sites end to end, matching the existing tarpit-signal test pattern. - Soften _report_suspected_honeypots()'s port-profile wording from "known deployment profile" to "candidate port profile ... (unverified against current upstream defaults)", matching HONEYPOT_PORT_PROFILES' own documented caveat that the port lists are unverified placeholders. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both TestFullPortScan::test_full_scan_flags_suspected_honeypot_port_profile and TestMassScanHoneypotFlag::test_batch_scan_flags_suspected_honeypot_port_profile asserted only on the in-memory port_profile signal, which never touches disk, so neither caught a wrong masscan_dir argument at their call sites. Seed a two-TTL-value XML into discovery/masscan_results/ (mirroring the resume-path TTL-spread test) and assert the resulting ttl_spread line, so a typo'd masscan_dir path now fails both tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reviewer mutation-tested Task 3's tests and found all guards unverified: _vnc_heralding_match()'s UDP-file skip, malformed-XML guard, and IPv4 address guard, plus _named_honeypot_matches()'s extrainfo field, Dionaea signature, and first-match-wins early exit. Each mutation is now caught. Also closed a pre-existing coverage gap (empty-attribute service element) found while verifying 100% coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_select_confirm_probe_ports() compared candidate ephemeral ports against scanned_ports as literal strings, so range specs like '1-65535' (Full scan) or '49152-65535' were never expanded — a real host with a legitimately open ephemeral port could be probed and false-positive as a confirmed decoy. Add _expand_scanned_ports() to expand range specs into integers before the exclusion check. Add a connector=None parameter to _confirm_flagged_honeypots(), threaded to _maybe_confirm_honeypot(), giving the enabled=True orchestrator path a test seam instead of requiring a real socket connection. Add tests for: the range-exclusion fix (including the Full-scan case that now correctly finds zero probe candidates), all three mass_scan() call sites wiring honeypot_active_confirm through to _confirm_flagged_honeypots(), and the enabled=True path via an injected fake connector. Remove/replace three previously-tautological tests (empty probe_ports early-exit, which tested a guard that doesn't exist; the flagged-hosts early-return guard, left as documented-defensive since an empty dict's for-loop is a no-op with or without it) and correct the UDP-port-filter test's docstring to note it is now genuinely load-bearing after the range-expansion fix (an unfiltered 'U:' key now raises instead of merely miscounting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
random.Random(ip) in _select_confirm_probe_ports() trips bandit's B311 (pseudo-random generators flagged as unsuitable for security purposes). This use is legitimate: it seeds deterministic-but-decorrelated probe port selection from a target IP for run-to-run reproducibility, not a security or cryptographic use of randomness. Per this repo's convention, no inline # nosec is added; the baseline is regenerated instead via the command documented in ci.yml: uv run --frozen bandit -r spoonmap.py -c pyproject.toml -f json -o .bandit-baseline.json Confirmed this is the only new finding: bandit -b against the prior baseline reports exactly one new issue both before and after this branch's other changes in this fix round. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ashing
Round-2 re-review fix for Task 4: _expand_scanned_ports() called int() on
every non-U:-prefixed dest_ports token with no validation, so a garbage
token from an interactive Custom port list or a raw config.json value
('80-', 'http', '8O80', '80/tcp') raised ValueError. This ran ahead of
_maybe_confirm_honeypot()'s enabled check, so the crash fired even with
honeypot_active_confirm disabled (the default) and propagated out of
mass_scan()/main(), discarding a completed scan's aggregation.
Malformed tokens (individual or in a range) are now skipped rather than
raising, and a range span above the new _EXPAND_SCANNED_PORTS_MAX_SPAN
(100,000) is skipped instead of being materialized into a huge set, per
the reviewer's '1-4294967295' repro. Skipping only shrinks the "confirmed
scanned" set, which can only narrow the probe candidate pool, never
reintroduce the range-string false-positive fixed in round 1.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… drop Wrap _count_unmatched_service_ports(), _count_silent_open_ports(), _named_honeypot_matches(), and _vnc_heralding_match() in try/except OSError inside generate_findings() so a directory-read failure (e.g. nmap_results/ left root-owned by an earlier sudo run) degrades only that signal source instead of aborting the whole findings phase. Switch the named-match check from truthiness to membership (`ip in named_matches`) so a signature match that resolves to an empty/falsy product string still produces a finding instead of silently vanishing. Add a comprehensive combined-signal test exercising all four file-based and XML-based signal sources together on one host, plus a cross-host isolation test, closing review gaps from the honeypot detection task.
…g docs Following this repo's precedent (9ea2bb3): spec/plan docs stay out of the public repo long-term, with durable reasoning folded into CLAUDE.md instead.
Bandit baseline count was stale at 32; actual is 36 after the new B311 random.Random honeypot-probe finding. Also removed a now-false claim that the honeypot finding always emits at flat MEDIUM severity, which contradicted the severity-tiering description later in the same paragraph.
Five fixes from the whole-branch review, all in the honeypot path.
1. Drop both placeholder HONEYPOT_SIGNATURES entries; ship the table empty.
Both were verified broken as data, not merely untested. The Cowrie/Kippo
needle used a hyphen ('OpenSSH 6.0p1 Debian-4+deb7u2') but nmap's own
nmap-service-probes template renders that banner with a space, so it could
never match; and the superficial fix is strictly worse, because nmap applies
the same space-insertion to every genuine Debian OpenSSH host, which would
have reported HIGH-severity honeypot on one of the most common SSH banners
on the internet. The Dionaea needle was an unrecognised banner, which nmap
records in `servicefp` -- a field _named_honeypot_matches() never reads.
The matching machinery stays as an extensibility point for future,
verified signatures. The only named-product match this release ships is
the source-verified Heralding VNC tell, which uses a separate function.
2. Scope-filter honeypot_flags before the active confirmation probe, at all
three mass_scan() call sites, via the new _scope_filtered_flags(). The
probe is the one honeypot signal that SENDS PACKETS, and it ran on the raw
dict -- ahead of _report_out_of_scope_retained(), which runs immediately
after at each of the same three sites and had already established these IPs
can come from a prior, wider engagement (cached live_hosts/ files are never
pruned; _ttl_spread_by_host() walks an even wider stale set in
masscan_results/). Empty scope_ranges is permissive, matching
_report_out_of_scope_retained() exactly.
3. Guard _ttl_spread_by_host() against OSError inside _flag_honeypot_signals().
Same failure class already fixed in generate_findings(): a masscan_results/
left root-owned by a sudo run and read by a later non-root --resume. This
runs after every batch has completed, so an escaping OSError unwound
mass_scan() and main() and discarded a finished scan's whole aggregation
over an advisory heuristic. The port-profile half is unaffected.
4. Write suspected_honeypots.txt and confirmed_honeypots.txt unconditionally,
so an empty result truncates them. Both used to early-return, leaving a
broader previous run's file intact. That was cosmetic before severity
tiering; now generate_findings() COUNTS signals, so a stale confirmed line
pins a finding at HIGH forever and makes the deliverable assert "host
answered on a port never scanned open" for a probe that did not run.
_report_suspected_tarpits() deliberately keeps the old behaviour -- it does
not feed severity math.
5. Check `enabled` in _confirm_flagged_honeypots() before the per-host loop.
_select_confirm_probe_ports() builds and samples a ~16k-element candidate
set per host, and enabled=False is the default. The early-return path still
calls _report_confirmed_honeypots(set(), ...) so item 4's truncation is not
lost when the operator turns the probe back off -- which is the precise
scenario that truncation exists to prevent.
Also adds the "(unverified against current upstream defaults)" caveat to
generate_findings()'s port-profile reason text; HONEYPOT_PORT_PROFILES is
still a placeholder table and that string lands verbatim in a deliverable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- CLAUDE.md: HONEYPOT_SIGNATURES ships empty in this release; no active generic signatures, the Heralding VNC tell is the only named-product match the code can produce, and the generic mechanism is retained solely as an extensibility point for future entries verified against real -sV output. - CLAUDE.md + config.json.sample + the __honeypot_active_confirm_note__ constant in spoonmap.py: the active probe runs on hosts flagged by the TTL-spread or port-profile signals, not on "any flagged host" — a host flagged only by the older tarpit open-port-ratio heuristic feeds the finding but is never probed. - CLAUDE.md: document three limitations plainly, none of them fixed in code. (1) On a Full 1-65535 scan essentially every ephemeral port is already scanned, so _select_confirm_probe_ports() finds few or no candidates and the probe silently does nothing. (2) All honeypot detection lives in mass_scan(); the direct-nmap port-discovery path used below nmap_threshold performs none of it. (3) Every signal only becomes a FINDING when script_scan is true, since generate_findings() returns immediately without nse_results/ — the Stage 1 stdout warnings and discovery/*.txt files are written either way, because those come from mass_scan(). - CLAUDE.md: record the unconditional truncation of the two new discovery files, the pre-loop `enabled` check, and the new scope filter. - README.md: add the missing `honeypot_active_confirm` config-key row, and add "Likely Honeypot / Decoy Host" to the findings-severity catalog at all three tiers it can now emit, following the table's existing one-row-per- severity convention for multi-severity findings (cf. the SNMP and LLM-API entries). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
honeypot_active_confirminconfig.json, scope-filtered so it cannot fire against out-of-scope or excluded hosts.Notable decisions made during review
config.json(mirroring the existingcheck_for_updatespattern), since this codebase never prompts mid-scan.Test plan
uv run pytest tests/— 1338 passed, 5 skipped (documented port-conflict/root-gated skips), 100% coverageuv run ruff check spoonmap.py tests/— cleanuv run bandit -c pyproject.toml -r spoonmap.py --baseline .bandit-baseline.json— clean, baseline updated and justified in commit historyCo-Authored-By: Claude Sonnet 5 noreply@anthropic.com