Skip to content

feat: expanded honeypot/decoy detection - #52

Open
bandrel wants to merge 22 commits into
nightlyfrom
feat/honeypot-detection
Open

feat: expanded honeypot/decoy detection#52
bandrel wants to merge 22 commits into
nightlyfrom
feat/honeypot-detection

Conversation

@bandrel

@bandrel bandrel commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds four new honeypot/decoy detection signals: TTL spread across a host's open ports, known port-profile matching (Thinkst Canary/Artillery, marked unverified), a source-verified Heralding VNC signature, and silent-open-ports counting (catches tarpits that hold a connection and send nothing back).
  • Replaces the existing flat-MEDIUM "Likely Honeypot / Decoy Host" finding with HIGH/MEDIUM/LOW severity tiering based on signal count and confidence.
  • Adds an optional, operator-gated active confirmation probe (raw TCP connect to unscanned high ports on a flagged host) — off by default via honeypot_active_confirm in config.json, scope-filtered so it cannot fire against out-of-scope or excluded hosts.

Notable decisions made during review

  • Two originally-planned signatures (Cowrie/Kippo, Dionaea) were dropped after verification against real nmap output showed they could never match as written, and a naive fix would have false-positived on any ordinary Debian host. Only the source-verified Heralding VNC signature ships in this release.
  • The active probe's design deviates from the original spec: instead of a live interactive per-host prompt, it's gated purely through config.json (mirroring the existing check_for_updates pattern), since this codebase never prompts mid-scan.

Test plan

  • uv run pytest tests/ — 1338 passed, 5 skipped (documented port-conflict/root-gated skips), 100% coverage
  • uv run ruff check spoonmap.py tests/ — clean
  • uv run bandit -c pyproject.toml -r spoonmap.py --baseline .bandit-baseline.json — clean, baseline updated and justified in commit history
  • Full task-by-task subagent review (spec compliance + code quality per task) plus a final whole-branch review, both with fix rounds applied and independently re-verified

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

bandrel and others added 22 commits August 28, 2026 10:25
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant