fix(scans): http asset correlation — probed host + resolved IP

HttpAsset.ip was storing httpx's request host (the hostname), not the
resolved IP, which broke the http-asset <-> IpAddress/Port value-join. Now:

- host = the probed host (from httpx `input`) -> exact match to Subdomain.name
- ip   = the resolved IP (host_ip / A record) -> exact match to Port / IpAddress
- final_url = where a followed redirect landed, kept separate from the probed
  url so the correlation key never drifts

Adds http_assets.final_url.
This commit is contained in:
Yogesh Ojha
2026-06-26 16:17:52 +05:30
parent 81fa9e899b
commit f49ff0ff82
4 changed files with 50 additions and 7 deletions
@@ -0,0 +1,36 @@
"""add http_assets.final_url (redirect destination)
Revision ID: f1a3c5e7d9b2
Revises: e7c9a1b3d5f8
Create Date: 2026-06-25 16:00:00.000000+00:00
Keeps `host`/`url` as the probed key (correlation) and records where a
followed redirect actually landed.
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
revision: str = "f1a3c5e7d9b2"
down_revision: str | None = "e7c9a1b3d5f8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"http_assets",
sa.Column(
"final_url",
sqlmodel.sql.sqltypes.AutoString(length=2000),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("http_assets", "final_url")
+1
View File
@@ -143,6 +143,7 @@ class HttpProbeEngine(Engine):
content_length=fields["content_length"],
content_type=fields["content_type"],
location=fields["location"],
final_url=fields["final_url"],
tech=fields["tech"],
ip=fields["ip"],
cname=fields["cname"],
+2
View File
@@ -31,6 +31,7 @@ class HttpAsset(SQLModel, table=True):
content_length: int | None = Field(default=None)
content_type: str | None = Field(default=None, max_length=255)
location: str | None = Field(default=None, max_length=2000)
final_url: str | None = Field(default=None, max_length=2000)
tech: list = Field(default_factory=list, sa_column=Column(JSON, nullable=False))
ip: str | None = Field(default=None, max_length=45)
@@ -72,6 +73,7 @@ class HttpAssetRead(BaseModel):
content_length: int | None = None
content_type: str | None = None
location: str | None = None
final_url: str | None = None
tech: list[str] = Field(default_factory=list)
ip: str | None = None
cname: str | None = None
+11 -7
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from urllib.parse import urlsplit
def _int(value) -> int | None:
@@ -37,12 +36,16 @@ def _parse_dt(value) -> datetime | None:
return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt
def _first(value) -> str | None:
return value[0] if isinstance(value, list) and value else None
def _host_of(record: dict) -> str:
url = record.get("url") or ""
host = urlsplit(url).hostname if url else None
if host:
return host
return (record.get("input") or "").split(":")[0]
"""The host we probed (the correlation key) — derived from httpx `input`."""
raw = (record.get("input") or record.get("url") or "").strip()
if "://" in raw:
raw = raw.split("://", 1)[1]
return raw.split("/", 1)[0].rsplit(":", 1)[0].strip().lower()
def parse_httpx_record(record: dict) -> dict:
@@ -59,8 +62,9 @@ def parse_httpx_record(record: dict) -> dict:
return {
"url": record.get("url") or record.get("input") or "",
"final_url": _trunc(record.get("final_url"), 2000),
"host": _host_of(record),
"ip": _trunc(record.get("host"), 45),
"ip": _trunc(record.get("host_ip") or _first(record.get("a")), 45),
"port": _int(record.get("port")) or 0,
"scheme": (record.get("scheme") or "https")[:8],
"status_code": _int(record.get("status_code")),