Files
rengine/shared/models/http_asset.py
T
Yogesh Ojha e035ca24d9 feat(ai-detection): identify AI services on web assets with julius
A new AI Detection stage in the Analysis group runs julius v1.4.19 over
every web asset root and names model servers, AI gateways, AI
applications, cloud AI endpoints and MCP servers, with the models they
list. julius's MCP probe runs alone against /mcp and /api/mcp. Batches
of eight roots are paced to the scan's rate, AI ports are probed first,
and a minute budget and an asset cap are reported when they cut.

Results live on http_assets.ai_* and fold onto hosts and onto services
per ip:port, so an IP target with no host rows still carries them. Web
asset and service rows show the service, the host and service grammars
take ai: and ai.model:, the dashboard and target page count services per
product through /ports/ai, two built-in exposure rules flag AI services
and unauthenticated model lists, and compare watches ai_services.

The AI default ports join WEB_PORTS. julius ignores -f without a
positional target, so targets go on stdin.
2026-09-25 08:02:29 +05:30

226 lines
8.5 KiB
Python

import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from sqlalchemy import BigInteger, Column, Computed, Text
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.types import JSON
from sqlmodel import Field, SQLModel, UniqueConstraint
from shared.definitions.ai_services import MAX_ENDPOINT_LENGTH, MAX_SERVICE_LENGTH
from shared.models.scan_surface import AssetSurface
from shared.utils.datetime import utc_now
HEADER_SEARCH_LIMIT = 20_000
BODY_SEARCH_LIMIT = 100_000
SEARCH_TSV_SQL = (
"setweight(to_tsvector('simple', "
f"left(coalesce(raw_response_header, ''), {HEADER_SEARCH_LIMIT})), 'A') || "
"setweight(to_tsvector('simple', "
f"left(coalesce(response_body, ''), {BODY_SEARCH_LIMIT})), 'B')"
)
def _json_list() -> Field:
return Field(default_factory=list, sa_column=Column(JSON, nullable=False))
def _text() -> Field:
return Field(default=None, sa_column=Column(Text, nullable=True))
def _json_optional_list() -> Field:
return Field(default=None, sa_column=Column(JSON(none_as_null=True), nullable=True))
class HttpAsset(SQLModel, table=True):
__tablename__ = "http_assets"
__table_args__ = (UniqueConstraint("scan_id", "url", name="uq_httpasset_scan_url"),)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=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)
# request
url: str = Field(max_length=2000)
final_url: str | None = Field(default=None, max_length=2000)
host: str = Field(max_length=500, index=True)
port: int = Field(default=0)
scheme: str = Field(default="https", max_length=8)
method: str | None = Field(default=None, max_length=10)
path: str | None = Field(default=None, max_length=2000)
# response
status_code: int | None = Field(default=None, index=True)
chain_status_codes: list = _json_list()
title: str | None = Field(default=None, max_length=1000)
webserver: str | None = Field(default=None, max_length=255)
content_type: str | None = Field(default=None, max_length=255)
content_length: int | None = Field(default=None)
location: str | None = Field(default=None, max_length=2000)
response_time: float | None = Field(default=None)
words: int | None = Field(default=None)
lines: int | None = Field(default=None)
# fingerprint
tech: list = _json_list()
cpe: list = _json_list()
software: list | None = _json_optional_list()
favicon_hash: str | None = Field(default=None, max_length=64)
favicon_path: str | None = Field(default=None, max_length=2000)
content_hash: str | None = Field(default=None, max_length=80)
header_hash: str | None = Field(default=None, max_length=80)
jarm: str | None = Field(default=None, max_length=64)
supports_http2: bool = Field(default=False)
supports_pipeline: bool = Field(default=False)
# network
ip: str | None = Field(default=None, max_length=45, index=True)
a_records: list = _json_list()
aaaa_records: list = _json_list()
cname: str | None = Field(default=None, max_length=500)
asn: int | None = Field(default=None, sa_type=BigInteger)
asn_org: str | None = Field(default=None, max_length=255)
is_cdn: bool = Field(default=False, index=True)
cdn_name: str | None = Field(default=None, max_length=100)
cdn_type: str | None = Field(default=None, max_length=20)
waf: str | None = Field(default=None, max_length=100)
# tls
tls_version: str | None = Field(default=None, max_length=20)
tls_cipher: str | None = Field(default=None, max_length=100)
tls_subject_cn: str | None = Field(default=None, max_length=500)
tls_subject_dn: str | None = Field(default=None, max_length=1000)
tls_sans: list = _json_list()
tls_issuer: str | None = Field(default=None, max_length=500)
tls_issuer_cn: str | None = Field(default=None, max_length=500)
tls_issuer_org: str | None = Field(default=None, max_length=500)
tls_serial: str | None = Field(default=None, max_length=200)
tls_fingerprint: str | None = Field(default=None, max_length=80)
tls_not_before: datetime | None = Field(default=None)
tls_not_after: datetime | None = Field(default=None)
tls_expired: bool | None = Field(default=None)
tls_self_signed: bool | None = Field(default=None)
screenshot_path: str | None = Field(default=None, max_length=500)
screenshot_phash: int | None = Field(
default=None, sa_column=Column(BigInteger, nullable=True)
)
# raw capture (httpx -irr)
raw_request: str | None = _text()
raw_response_header: str | None = _text()
response_body: str | None = _text()
response_headers: dict = Field(
default_factory=dict, sa_column=Column(JSON, nullable=False)
)
body_preview: str | None = Field(default=None, max_length=512)
hygiene_issues: list | None = _json_optional_list()
hygiene_checked: list | None = _json_optional_list()
tracking_ids: list | None = _json_optional_list()
# ai
ai_checked: bool = Field(default=False)
ai_service: str | None = Field(default=None, max_length=MAX_SERVICE_LENGTH)
ai_category: str | None = Field(default=None, max_length=32)
ai_endpoint: str | None = Field(default=None, max_length=MAX_ENDPOINT_LENGTH)
ai_models: list | None = _json_optional_list()
not_found: list | None = _json_optional_list()
search_tsv: Any | None = Field(
default=None,
sa_column=Column(TSVECTOR, Computed(SEARCH_TSV_SQL, persisted=True)),
)
discovered_at: datetime = Field(default_factory=utc_now)
created_at: datetime = Field(default_factory=utc_now)
class HttpAssetRead(BaseModel):
id: uuid.UUID
scan_id: uuid.UUID
target_id: uuid.UUID
url: str
final_url: str | None = None
host: str
port: int
scheme: str
method: str | None = None
path: str | None = None
status_code: int | None = None
chain_status_codes: list[int] = Field(default_factory=list)
title: str | None = None
webserver: str | None = None
content_type: str | None = None
content_length: int | None = None
location: str | None = None
response_time: float | None = None
words: int | None = None
lines: int | None = None
tech: list[str] = Field(default_factory=list)
cpe: list = Field(default_factory=list)
favicon_hash: str | None = None
content_hash: str | None = None
header_hash: str | None = None
jarm: str | None = None
supports_http2: bool = False
supports_pipeline: bool = False
ip: str | None = None
a_records: list[str] = Field(default_factory=list)
aaaa_records: list[str] = Field(default_factory=list)
cname: str | None = None
asn: int | None = None
asn_org: str | None = None
is_cdn: bool = False
cdn_name: str | None = None
cdn_type: str | None = None
waf: str | None = None
tls_version: str | None = None
tls_cipher: str | None = None
tls_subject_cn: str | None = None
tls_sans: list[str] = Field(default_factory=list)
tls_issuer: str | None = None
tls_issuer_org: str | None = None
tls_serial: str | None = None
tls_fingerprint: str | None = None
tls_not_before: datetime | None = None
tls_not_after: datetime | None = None
tls_expired: bool | None = None
tls_self_signed: bool | None = None
screenshot_path: str | None = None
body_preview: str | None = None
hygiene_issues: list[str] = Field(default_factory=list)
hygiene_checked: list[str] = Field(default_factory=list)
ai_checked: bool = False
ai_service: str | None = None
ai_category: str | None = None
ai_endpoint: str | None = None
ai_models: list[str] = Field(default_factory=list)
discovered_at: datetime
class HygieneVerdict(BaseModel):
key: str
failed: bool
evidence: str | None = None
class HttpAssetDetail(HttpAssetRead):
tls_subject_dn: str | None = None
tls_issuer_cn: str | None = None
raw_request: str | None = None
raw_response_header: str | None = None
response_body: str | None = None
response_headers: dict = Field(default_factory=dict)
hygiene: list[HygieneVerdict] = Field(default_factory=list)
surface: AssetSurface | None = None
class HttpAssetSummary(BaseModel):
total: int
by_status: dict[str, int] = Field(default_factory=dict)
by_tech: dict[str, int] = Field(default_factory=dict)
cdn: int = 0