Files
rengine/shared/definitions/compare.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

339 lines
10 KiB
Python

"""The vocabulary of a scan-to-scan comparison: verbs, watched fields and named signals."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from shared.definitions.surface import SurfaceDimension
class ChangeVerb(StrEnum):
APPEARED = "appeared"
CHANGED = "changed"
DISAPPEARED = "disappeared"
UNCONFIRMED = "unconfirmed"
UNCHANGED = "unchanged"
VERB_ORDER: tuple[str, ...] = (
ChangeVerb.APPEARED.value,
ChangeVerb.CHANGED.value,
ChangeVerb.DISAPPEARED.value,
ChangeVerb.UNCONFIRMED.value,
ChangeVerb.UNCHANGED.value,
)
LISTED_VERBS: tuple[str, ...] = VERB_ORDER[:-1]
class Comparability(StrEnum):
LIKE_FOR_LIKE = "like_for_like"
SETTINGS_DIFFER = "settings_differ"
QUALITY_DIFFERS = "quality_differs"
NOT_COVERED = "not_covered"
class FieldKind(StrEnum):
SCALAR = "scalar"
LIST = "list"
BOOL = "bool"
DATE = "date"
OPAQUE = "opaque"
class Tone(StrEnum):
UP = "up"
DOWN = "down"
NEUTRAL = "neutral"
@dataclass(frozen=True)
class WatchedField:
name: str
label: str
kind: str = FieldKind.SCALAR.value
# ---------- watched fields ----------
_HOST_FIELDS: tuple[WatchedField, ...] = (
WatchedField("http_status", "Status"),
WatchedField("final_url", "Final URL"),
WatchedField("page_title", "Title"),
WatchedField("webserver", "Server"),
WatchedField("waf", "WAF"),
WatchedField("cdn_name", "CDN"),
WatchedField("cname", "CNAME"),
WatchedField("favicon_hash", "Favicon", FieldKind.OPAQUE.value),
WatchedField("is_active", "Responding", FieldKind.BOOL.value),
WatchedField("tls_expired", "Certificate expired", FieldKind.BOOL.value),
WatchedField("tls_self_signed", "Self-signed", FieldKind.BOOL.value),
WatchedField("tls_not_after", "Certificate expiry", FieldKind.DATE.value),
WatchedField("tech", "Technology", FieldKind.LIST.value),
WatchedField("hygiene_issues", "Hygiene", FieldKind.LIST.value),
WatchedField("posture_issues", "Domain posture", FieldKind.LIST.value),
WatchedField("ai_services", "AI service", FieldKind.LIST.value),
WatchedField("resolved_ips", "Addresses", FieldKind.LIST.value),
)
_ENDPOINT_FIELDS: tuple[WatchedField, ...] = (
WatchedField("status_code", "Status"),
WatchedField("content_hash", "Body", FieldKind.OPAQUE.value),
WatchedField("content_type", "Content type"),
WatchedField("redirect_location", "Redirects to"),
WatchedField("param_count", "Parameters"),
WatchedField("is_probed", "Probed", FieldKind.BOOL.value),
WatchedField("tech", "Technology", FieldKind.LIST.value),
WatchedField("interest", "Interest", FieldKind.LIST.value),
)
_SERVICE_FIELDS: tuple[WatchedField, ...] = (
WatchedField("state", "State"),
WatchedField("service_name", "Service"),
WatchedField("service_class", "Class"),
WatchedField("product", "Product"),
WatchedField("version", "Version"),
WatchedField("banner", "Banner", FieldKind.OPAQUE.value),
WatchedField("is_http", "HTTP", FieldKind.BOOL.value),
WatchedField("tls", "TLS", FieldKind.BOOL.value),
WatchedField("cpe", "CPE", FieldKind.LIST.value),
)
_ADDRESS_FIELDS: tuple[WatchedField, ...] = (
WatchedField("asn", "ASN"),
WatchedField("asn_org", "Network"),
WatchedField("prefix", "Prefix"),
WatchedField("country", "Country"),
WatchedField("cdn_name", "CDN"),
WatchedField("is_cdn", "Behind CDN", FieldKind.BOOL.value),
WatchedField("is_alive", "Responding", FieldKind.BOOL.value),
WatchedField("ptr_hostnames", "PTR", FieldKind.LIST.value),
)
_VULN_FIELDS: tuple[WatchedField, ...] = (
WatchedField("severity", "Severity"),
WatchedField("template_available", "Template available", FieldKind.BOOL.value),
WatchedField("extracted_results", "Extracted", FieldKind.LIST.value),
)
_SOFTWARE_FIELDS: tuple[WatchedField, ...] = (
WatchedField("severity", "Severity"),
WatchedField("confidence", "Confidence"),
WatchedField("is_kev", "Known exploited", FieldKind.BOOL.value),
WatchedField("caveats", "Caveats", FieldKind.LIST.value),
)
_SECRET_FIELDS: tuple[WatchedField, ...] = (
WatchedField("state", "State"),
WatchedField("hosts", "Web assets"),
)
WATCHED_FIELDS: dict[str, tuple[WatchedField, ...]] = {
SurfaceDimension.WEB_ASSETS.value: _HOST_FIELDS,
SurfaceDimension.ENDPOINTS.value: _ENDPOINT_FIELDS,
SurfaceDimension.SERVICES.value: _SERVICE_FIELDS,
SurfaceDimension.IPS.value: _ADDRESS_FIELDS,
SurfaceDimension.VULNERABILITIES.value: _VULN_FIELDS,
SurfaceDimension.SOFTWARE.value: _SOFTWARE_FIELDS,
SurfaceDimension.SECRETS.value: _SECRET_FIELDS,
}
# Class D: moves without a scan
INTEL_FIELDS: tuple[str, ...] = (
"is_kev",
"kev_ransomware",
"kev_due_date",
"epss_score",
"epss_percentile",
"exploit_score",
"intel_kinds",
"poc_count",
)
# ---------- identity ----------
COMPARE_KEYS: dict[str, tuple[str, ...]] = {
SurfaceDimension.WEB_ASSETS.value: ("name",),
SurfaceDimension.ENDPOINTS.value: ("signature",),
SurfaceDimension.SERVICES.value: ("ip", "number", "protocol"),
SurfaceDimension.IPS.value: ("ip",),
SurfaceDimension.VULNERABILITIES.value: ("fingerprint",),
SurfaceDimension.SOFTWARE.value: ("fingerprint",),
SurfaceDimension.SECRETS.value: ("fingerprint",),
}
# ---------- named signals ----------
class ChangeSignal(StrEnum):
KEV_APPEARED = "kev_appeared"
CRITICAL_APPEARED = "critical_appeared"
FINDING_APPEARED = "finding_appeared"
SEVERITY_RAISED = "severity_raised"
SENSITIVE_SERVICE_OPENED = "sensitive_service_opened"
AUTH_DROPPED = "auth_dropped"
SERVICE_OPENED = "service_opened"
CERT_EXPIRED = "cert_expired"
POSTURE_WEAKENED = "posture_weakened"
WAF_GONE = "waf_gone"
CDN_GONE = "cdn_gone"
HOSTING_MOVED = "hosting_moved"
HOST_WOKE = "host_woke"
BODY_CHANGED = "body_changed"
EXPOSED_HOST_APPEARED = "exposed_host_appeared"
HOST_APPEARED = "host_appeared"
ADDRESS_APPEARED = "address_appeared"
ENDPOINT_APPEARED = "endpoint_appeared"
ATTRIBUTES_CHANGED = "attributes_changed"
FINDING_GONE = "finding_gone"
SERVICE_CLOSED = "service_closed"
HOST_GONE = "host_gone"
ASSET_GONE = "asset_gone"
# lower ranks first
_SIGNAL_ORDER: tuple[str, ...] = (
ChangeSignal.KEV_APPEARED.value,
ChangeSignal.CRITICAL_APPEARED.value,
ChangeSignal.SENSITIVE_SERVICE_OPENED.value,
ChangeSignal.AUTH_DROPPED.value,
ChangeSignal.FINDING_APPEARED.value,
ChangeSignal.SEVERITY_RAISED.value,
ChangeSignal.CERT_EXPIRED.value,
ChangeSignal.POSTURE_WEAKENED.value,
ChangeSignal.WAF_GONE.value,
ChangeSignal.EXPOSED_HOST_APPEARED.value,
ChangeSignal.HOSTING_MOVED.value,
ChangeSignal.HOST_WOKE.value,
ChangeSignal.CDN_GONE.value,
ChangeSignal.SERVICE_OPENED.value,
ChangeSignal.BODY_CHANGED.value,
ChangeSignal.HOST_APPEARED.value,
ChangeSignal.ADDRESS_APPEARED.value,
ChangeSignal.ENDPOINT_APPEARED.value,
ChangeSignal.ATTRIBUTES_CHANGED.value,
ChangeSignal.FINDING_GONE.value,
ChangeSignal.SERVICE_CLOSED.value,
ChangeSignal.HOST_GONE.value,
ChangeSignal.ASSET_GONE.value,
)
SIGNAL_RANK: dict[str, int] = {name: i for i, name in enumerate(_SIGNAL_ORDER)}
# ---------- run-level facets ----------
class FacetKind(StrEnum):
SCALAR = "scalar"
LIST = "list"
BOOL = "bool"
COUNT = "count"
MASKED = "masked"
@dataclass(frozen=True)
class RunFacet:
key: str
label: str
path: str
kind: str = FacetKind.SCALAR.value
material: bool = True
RUN_FACETS: tuple[RunFacet, ...] = (
RunFacet("auth", "Authentication", "_auth.auth_type"),
RunFacet("headers", "Extra headers", "headers", FacetKind.COUNT.value),
RunFacet("intensity", "Intensity", "intensity"),
RunFacet("proxy", "Proxy", "proxy_url", FacetKind.MASKED.value),
RunFacet("http_protocol", "HTTP protocol", "http_protocol"),
RunFacet(
"excluded_subdomains",
"Excluded hosts",
"excluded_subdomains",
FacetKind.LIST.value,
),
RunFacet(
"excluded_paths", "Excluded paths", "excluded_paths", FacetKind.LIST.value
),
RunFacet(
"excluded_ips", "Excluded addresses", "excluded_ips", FacetKind.LIST.value
),
RunFacet(
"included_subdomains",
"Only these hosts",
"included_subdomains",
FacetKind.LIST.value,
),
RunFacet("seed_assets", "Seed assets", "seed_assets", FacetKind.COUNT.value),
RunFacet(
"rate_ceiling",
"Global rate limit",
"global_rate_limit_ceiling",
material=False,
),
RunFacet(
"thread_multiplier", "Thread multiplier", "thread_multiplier", material=False
),
RunFacet(
"timeout_multiplier", "Timeout multiplier", "timeout_multiplier", material=False
),
)
# ---------- why a pair cannot be compared ----------
class Refusal(StrEnum):
DIFFERENT_TARGET = "different_target"
UNFINISHED = "unfinished"
FOCUSED_AGAINST_FULL = "focused_against_full"
FULL_AGAINST_FOCUSED = "full_against_focused"
SAME_RUN = "same_run"
NO_EARLIER_RUN = "no_earlier_run"
NEED_ONE_MORE = "need_one_more"
TOO_MANY = "too_many"
REFUSAL_REASON: dict[str, str] = {
Refusal.DIFFERENT_TARGET.value: "Both runs must cover the same target.",
Refusal.UNFINISHED.value: "This run has not finished.",
Refusal.FOCUSED_AGAINST_FULL.value: (
"A focused run is compared with its parent run only. Open the focused run."
),
Refusal.FULL_AGAINST_FOCUSED.value: (
"A full run cannot be compared with a focused run. Select two full runs."
),
Refusal.SAME_RUN.value: "Pick two different runs.",
Refusal.NO_EARLIER_RUN.value: "No earlier run of this target to compare with.",
Refusal.NEED_ONE_MORE.value: "Select one more run of the same target.",
Refusal.TOO_MANY.value: "Select exactly two runs.",
}
MAX_RUN_DIFFERENCES = 12
# ---------- unified diff ----------
DIFF_MARK: dict[str, str] = {
ChangeVerb.APPEARED.value: "+",
ChangeVerb.CHANGED.value: "~",
ChangeVerb.DISAPPEARED.value: "-",
ChangeVerb.UNCONFIRMED.value: "?",
ChangeVerb.UNCHANGED.value: " ",
}
MAX_DIFF_LINES = 5000
DIFF_PAGE = 200
DIFF_FIELD_INDENT = " "
DEFAULT_RANK = 99
AUTH_STATUS: tuple[int, ...] = (401, 403)
OK_MIN = 200
OK_MAX = 400
MAX_ROWS_PER_PAGE = 100
DEFAULT_ROWS_PER_PAGE = 50
MAX_COMPARABLE_RUNS = 40