Files
rengine/shared/models/port.py
T
Yogesh OjhaandClaude Opus 5 2375f1f1e4 feat(scan): service-level port pipeline, Services tab and Exposure panel
The unit of exposure is now a service — (ip, port) — not a bare open port.

Pipeline (EXPANSION): L0 produce hosts, L1 cdn_check + passive_ports,
L2 port_scan, L3 http_probe, L4 screenshot/waf_detect/service_fingerprint.

- cdn_check applies to every target type and records cdn_type, so a domain
  scan finally knows which addresses are CDN-fronted. cdn/waf is an edge you
  do not own (edge ports only); cloud is your own machine (scanned in full).
- port_scan decides and records a per-address scan_policy (full/web/skip)
  honouring the scan context's excluded_ips, and offers port profiles,
  exclusions, scan type, a tarpit threshold and an address budget.
- passive_ports reads Shodan internetdb through naabu -passive: keyless,
  touches_target=False, so a passive-intensity scan still gets exposure data.
- http_probe seeds its host x port matrix from open ports and folds every
  live response back onto the port (is_http, tls, product).
- service_fingerprint names the software behind non-web ports with a pure
  Python banner prober (tools/banner/), including a SOCKS5 CONNECT so it
  honours the context proxy. naabu -sV is unimplemented.
- shared/definitions/ports.py is the one vocabulary; ip_inventory and
  port_inventory are the only writers of ip_addresses/ports.
- open_ports_found is measured from the table (DERIVED_COUNTS): four stages
  write ports, so summing stage results double-counted.
- naabu failures no longer report zero open ports as success.

Surfaces: a Services tab (SERVICE_QUERY grammar, 16 fields, 18 counted
leads, 9 group dimensions, detail sheet) built on the shared results shell,
and an outlier-first Exposure panel on the Overview. Every count is a
promise — leads, groups and panel pivots were verified to equal the row
count they land on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnC2xMMzV6WQnpDp5zcXoU
2026-09-03 23:50:17 +05:30

72 lines
2.3 KiB
Python

import uuid
from datetime import datetime
from pydantic import BaseModel
from sqlalchemy import Column
from sqlalchemy.types import JSON
from sqlmodel import Field, SQLModel, UniqueConstraint
from shared.definitions.ports import PortSource, PortState, ServiceClass
from shared.utils.datetime import utc_now
class Port(SQLModel, table=True):
__tablename__ = "ports"
__table_args__ = (
UniqueConstraint(
"scan_id", "ip", "number", "protocol", name="uq_port_scan_ip_num_proto"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True)
scan_id: uuid.UUID = Field(foreign_key="scans.id", index=True, ondelete="CASCADE")
target_id: uuid.UUID = Field(
foreign_key="targets.id", index=True, ondelete="CASCADE"
)
project_id: uuid.UUID = Field(foreign_key="projects.id", index=True)
ip: str = Field(max_length=45, index=True)
number: int = Field(index=True)
protocol: str = Field(default="tcp", max_length=8)
state: str = Field(default=PortState.OPEN.value, max_length=16)
service_name: str | None = Field(default=None, max_length=64, index=True)
service_class: str = Field(
default=ServiceClass.OTHER.value, max_length=16, index=True
)
source: str = Field(max_length=30)
is_http: bool = Field(default=False, index=True)
tls: bool = Field(default=False)
product: str | None = Field(default=None, max_length=200)
version: str | None = Field(default=None, max_length=100)
banner: str | None = Field(default=None, max_length=1000)
cpe: list = Field(default_factory=list, sa_column=Column(JSON, nullable=False))
discovered_at: datetime = Field(default_factory=utc_now)
created_at: datetime = Field(default_factory=utc_now)
class PortRead(BaseModel):
id: uuid.UUID
scan_id: uuid.UUID
target_id: uuid.UUID
ip: str
number: int
protocol: str
state: str
service_name: str | None = None
service_class: str = ServiceClass.OTHER.value
source: str = PortSource.NAABU.value
is_http: bool = False
tls: bool = False
product: str | None = None
version: str | None = None
banner: str | None = None
cpe: list[str] = Field(default_factory=list)
discovered_at: datetime
class PortSummary(BaseModel):
total: int
by_service: dict[str, int] = Field(default_factory=dict)