Files
rengine/toolbox/tools/ip_intel.py
T
Yogesh OjhaandClaude Opus 5 43c72be245 Revert(toolbox): the operator picks the tool again
Running every tool that matches an input does not scale. With six tools a
domain meant WHOIS and DNS; with the address- and domain-specific tools
still to come it means a shotgun, most of it unwanted and some of it
paid for in requests.

The rail and the per-tool form are back: pick a tool, fill its own fields,
run that one. `POST /toolbox/lookup`, `toolbox/routing.py`, `InputKind`,
`Tool.accepts` and `Tool.auto` are gone with it.

What the redesign got right stays. Results still lead with the hero,
still diff against the estate, still carry identity marks and a page
preview. Chaining stays too, but a `Lookup` now always names its tool
rather than being classified at click time: a nameserver opens DNS
records, an address opens the IP tool, an AS number opens WHOIS. The
catalog serves `value_field` so the chained value lands in the right
field, and `Tool.order` ranks the rail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtRcxFCPSYxW5sXam3SUWB
2026-09-09 13:35:09 +05:30

206 lines
6.2 KiB
Python

"""Address facts from the offline range tables, plus its recorded scan history."""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from pydantic import Field, field_validator
from shared.definitions.surface import SurfaceDimension
from shared.definitions.toolbox import (
MAX_INPUT_LENGTH,
Pivot,
Tone,
ToolExecution,
ToolGroup,
)
from shared.services.ip_asn import ADDRESS_LOOKUP_SQL
from toolbox import estate
from toolbox.base import (
Tool,
ToolContext,
ToolInput,
ToolOutcome,
fact,
facts,
flag,
glyph,
hero,
lookup,
mark,
metric,
note,
tag,
tags,
)
PTR_TIMEOUT = 4
class Input(ToolInput):
ip: str = Field(
...,
min_length=1,
max_length=MAX_INPUT_LENGTH,
title="IP address",
description="8.8.8.8 · 2606:4700::1111",
)
@field_validator("ip")
@classmethod
def _ip(cls, value: str) -> str:
try:
return str(ipaddress.ip_address(value.strip()))
except ValueError as exc:
msg = f"{value} is not an IP address."
raise ValueError(msg) from exc
class IpIntel(Tool):
name = "ip"
title = "IP address"
description = "Network, operator, country and reverse DNS for an address, with its scan history."
group = ToolGroup.LOOKUP.value
icon = "network"
execution = ToolExecution.INLINE.value
order = 10
value_field = "ip"
placeholder = "8.8.8.8"
examples = ("8.8.8.8", "1.1.1.1")
Input = Input
async def run(self, ctx: ToolContext, args: Input) -> ToolOutcome:
address = ipaddress.ip_address(args.ip)
row = (await ctx.session.execute(ADDRESS_LOOKUP_SQL, {"ip": args.ip})).first()
asn, as_name, country = row if row else (None, None, None)
ptr = await _reverse(args.ip)
seen = await estate.address(ctx.session, ctx.project_id, args.ip)
net = await estate.network(ctx.session, ctx.project_id, asn)
sub = ptr[0] if ptr else f"IPv{address.version} address"
blocks = [
hero(
args.ip,
sub=sub,
identity=flag(country) if country else glyph("network"),
metric=metric(seen.ports or "", "Open ports recorded")
or metric(
net.hosts or "", "Hosts on this network", tone=Tone.INFO.value
)
or metric(f"AS{asn}" if asn else "", "Network"),
marks=[
mark(
"Routing",
tone=Tone.NEUTRAL.value
if address.is_global
else Tone.WARNING.value,
note="public" if address.is_global else "not publicly routable",
),
mark(
"In this project",
tone=Tone.INFO.value if seen.rows else Tone.MUTED.value,
note=f"{seen.targets} target{'s' if seen.targets != 1 else ''}"
if seen.rows
else "not recorded",
),
],
),
facts(
fact(
"Network",
f"AS{asn}" if asn else "",
mono=True,
lookup=lookup(f"AS{asn}", "whois") if asn else None,
),
fact("Operator", as_name),
fact(
"Country",
country,
identity=flag(country) if country else None,
),
title="Network",
),
tags(
[
tag(name, identity=glyph("server"), lookup=lookup(name, "dns"))
for name in ptr
],
title="Reverse DNS",
empty="No PTR record",
),
facts(
fact("Targets", seen.targets or ""),
fact("Scans", seen.scans or ""),
fact("Open ports", seen.ports or ""),
fact("Last seen", (seen.last_seen or "")[:10]),
fact(
"On this network",
f"{net.hosts} host{'s' if net.hosts != 1 else ''} across "
f"{net.addresses} address{'es' if net.addresses != 1 else ''}"
if net.addresses
else "",
tone=Tone.INFO.value,
),
title="Scan history",
empty="Not recorded by any scan in this project",
),
]
if asn is None and country is None:
blocks.append(
note(
"IP range feeds have not been loaded on this instance.",
tone=Tone.WARNING.value,
)
)
summary = (
" · ".join(
p
for p in (
f"AS{asn} {as_name}" if asn else None,
country,
ptr[0] if ptr else None,
)
if p
)
or f"IPv{address.version} address"
)
return ToolOutcome(
summary=summary,
blocks=blocks,
pivot=Pivot(
label="Open in IP addresses",
dimension=SurfaceDimension.IPS.value,
query=f'ip:"{args.ip}"',
)
if seen.rows
else None,
raw={
"ip": args.ip,
"asn": asn,
"as_name": as_name,
"country": country,
"ptr": ptr,
"targets": seen.targets,
"scans": seen.scans,
"ports": seen.ports,
},
)
async def _reverse(ip: str) -> list[str]:
def lookup_ptr() -> list[str]:
try:
name, aliases, _ = socket.gethostbyaddr(ip)
except OSError:
return []
return [name, *aliases] if name else list(aliases)
try:
return await asyncio.wait_for(asyncio.to_thread(lookup_ptr), PTR_TIMEOUT)
except TimeoutError:
return []