fix(engines): IP enrichment was offered as a choice with no reasonable "no"

The stage sends nothing to the target, runs no tool, needs no key, and
since the lookup moved into the write it costs 0.1 s. What it produces —
ASN, network operator, country — is read by the IPs tab, the globe, the
hosting flow, netblock_sweep, the network_outlier interest signal and
every asn:/country:/org: query. Turning it off is a footgun with no
upside, and it was in the engine builder as a switch.

It is now catalog_hidden, joining asset_seed. Both still run in the graph
and still report in the activity timeline; they are simply not offered.

The test is objective, not a judgement about this one stage: no traffic,
no external call, nothing to configure beyond enabled, and every result
surface depends on the output. Exactly two of twenty stages meet it.
cdn_check and waf_detect also have no fields, but one runs a tool and the
other touches the target, so declining them is a real decision.

_validate_stages now drops a hidden stage's key instead of rejecting the
whole document. The editor materializes every stage, so every engine
built in the UI names ip_enrichment; rejecting would have failed their
next save with "Unknown stage" — the same way it broke the Full Sweep and
Passive Recon presets on asset_seed in 4600a4c8. Verified against the
stored engine that names it: catalog 19 -> 18 stages, re-save 200, and
the key normalizes away. A scan run after the change still shows
ip_enrichment success with {"ips": 5, "new": 1, "enriched": 5}.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165iTiibhYePy1EDixwC2Vs
This commit is contained in:
Yogesh Ojha
2026-09-08 07:53:21 +05:30
co-authored by Claude Opus 5
parent 3d310c173a
commit a5d835df3e
2 changed files with 7 additions and 1 deletions
+4 -1
View File
@@ -185,7 +185,10 @@ def _validate_stages(submitted: dict | None) -> dict[str, dict]:
clean: dict[str, dict] = {}
for name, raw in (submitted or {}).items():
spec = known.get(name)
if spec is None or spec.catalog_hidden:
if spec is not None and spec.catalog_hidden:
# the server decides this one; a document that still names it just loses the key
continue
if spec is None:
offered = sorted(n for n, s in known.items() if not s.catalog_hidden)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
+3
View File
@@ -69,6 +69,9 @@ class IpEnrichmentStage(Stage):
role = StageRole.SUPPORT.value
applies_to = ALL_TARGETS
touches_target = False
# not a choice: it sends nothing, calls nothing, costs a tenth of a second, and every
# address surface reads what it writes. Turning it off is a footgun with no upside.
catalog_hidden = True
config_model = IpEnrichmentConfig
def run(self) -> StageResult: