Files
rengine/shared/services/asset_query/compiler.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

459 lines
16 KiB
Python

from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import (
and_,
cast,
exists,
false,
func,
literal,
or_,
select,
true,
union_all,
)
from sqlalchemy.dialects.postgresql import INET, JSONB
from sqlalchemy.dialects.postgresql import array as pg_array
from shared.definitions.ai_services import CATEGORY_LABELS as AI_CATEGORIES
from shared.definitions.asset_query import FLAGS, HOST_QUERY, FieldType, Op
from shared.definitions.domain_posture import QUERY_VALUES as POSTURE_VALUES
from shared.definitions.hygiene import QUERY_VALUES as HYGIENE_VALUES
from shared.models.endpoint import Endpoint
from shared.models.http_asset import HttpAsset
from shared.models.interest import InterestSignal
from shared.models.port import Port
from shared.models.subdomain import Subdomain
from shared.models.vulnerability import Vulnerability
from . import predicates as preds
from .ast import Compare, Or, QuerySyntaxError, Term
from .scope import QueryScope
from .terms import (
date_match,
folded_match,
int_coerce,
json_array_match,
negate,
number_match,
scaled_coerce,
string_match,
target_match,
tri_state,
)
from .values import asn_number, like, network, render_hash, status_range, tsquery
from .walk import as_compare, free_text_fields, walker
_IPV4_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
_HEADER_WEIGHT = "A"
_BODY_WEIGHT = "B"
@dataclass(frozen=True)
class QueryContext:
scope: QueryScope
now: datetime
@dataclass(frozen=True)
class Compiled:
where: object | None = None
asset: object | None = None
def flatten(self, ctx: QueryContext):
parts = [self.where] if self.where is not None else []
if self.asset is not None:
parts.append(preds.asset_match(ctx.scope, self.asset))
return and_(*parts) if parts else true()
def _ip(cmp: Compare):
branches = []
for raw in cmp.values:
cidr = network(raw)
if cidr is not None:
branches.append(_within(str(cidr)))
elif cmp.op is Op.EQ or _IPV4_RE.match(raw):
branches.append(func.jsonb_exists(cast(Subdomain.resolved_ips, JSONB), raw))
else:
branches.append(preds.ip_text().ilike(like(raw), escape="\\"))
matched = or_(*branches)
return negate(matched) if cmp.op is Op.NE else matched
def _within(cidr: str):
element = (
func.jsonb_array_elements_text(cast(Subdomain.resolved_ips, JSONB))
.table_valued("value")
.alias("resolved_ip")
)
return exists(
select(1)
.select_from(element)
.where(preds.inet_of(element.c.value).op("<<=")(cast(literal(cidr), INET)))
)
def _tsv(cmp: Compare, weight: str):
branches = []
for raw in cmp.values:
query = tsquery(raw, weight, prefix=not cmp.quoted)
if query:
branches.append(
HttpAsset.search_tsv.op("@@")(func.to_tsquery("simple", query))
)
if not branches:
return false()
matched = or_(*branches)
return negate(matched) if cmp.op is Op.NE else matched
def _header(cmp: Compare):
if cmp.sub:
name = cmp.sub.lower().replace("-", "_")
return string_match(
cast(HttpAsset.response_headers, JSONB).op("->>")(name), cmp
)
if cmp.quoted:
return string_match(HttpAsset.raw_response_header, cmp)
return _tsv(cmp, _HEADER_WEIGHT)
def _cert(cmp: Compare, ctx: QueryContext):
matched = or_(*[preds.cert_state(v.lower(), ctx.now) for v in cmp.values])
return negate(matched) if cmp.op is Op.NE else matched
def _status(cmp: Compare):
branches = []
numeric: list[str] = []
for raw in cmp.values:
bounds = status_range(raw)
if bounds is not None:
branches.append(
and_(
Subdomain.http_status >= bounds[0],
Subdomain.http_status <= bounds[1],
)
)
elif raw.lower() == "none":
branches.append(Subdomain.http_status.is_(None))
else:
numeric.append(raw)
if numeric:
positive = Compare(
name=cmp.name,
op=Op.MATCH if cmp.op is Op.NE else cmp.op,
values=tuple(numeric),
quoted=cmp.quoted,
sub=cmp.sub,
start=cmp.start,
end=cmp.end,
)
branches.append(number_match(Subdomain.http_status, positive, int_coerce(cmp)))
matched = or_(*branches)
if cmp.op is Op.NE:
return or_(Subdomain.http_status.is_(None), negate(matched))
return matched
def _flag(cmp: Compare, ctx: QueryContext):
branches = []
for raw in cmp.values:
name = raw.lower()
builder = _FLAG_BUILDERS.get(name)
if builder is None:
msg = f"Unknown flag {raw!r}."
hint = f"Try one of: {', '.join(FLAGS)}"
raise QuerySyntaxError(msg, cmp.start, cmp.end, hint)
branches.append(builder(ctx))
return or_(*branches)
_FLAG_BUILDERS = {
"live": lambda _ctx: preds.live(),
"web": lambda _ctx: preds.answered(),
"new": lambda ctx: preds.is_new(ctx.scope),
"resolved": lambda _ctx: preds.resolved(),
"auth": lambda _ctx: preds.auth(),
"cdn": lambda _ctx: Subdomain.is_cdn.is_(True),
"cloud": lambda ctx: preds.asset_match(ctx.scope, HttpAsset.cdn_type == "cloud"),
"waf": lambda _ctx: Subdomain.waf.isnot(None),
"screenshot": lambda _ctx: Subdomain.screenshot_path.isnot(None),
"important": lambda _ctx: Subdomain.is_important.is_(True),
"wildcard": lambda _ctx: Subdomain.is_wildcard.is_(True),
"issue": lambda ctx: preds.issues(ctx.now, ctx.scope),
"sensitive": lambda ctx: preds.sensitive(ctx.scope),
"http2": lambda ctx: preds.asset_match(
ctx.scope, HttpAsset.supports_http2.is_(True)
),
"redirect": lambda _ctx: and_(
Subdomain.final_url.isnot(None), Subdomain.final_url != Subdomain.http_url
),
"vulnerable": lambda ctx: preds.host_vuln(ctx.scope),
"exposed": lambda _ctx: preds.interesting(),
"interesting": lambda _ctx: preds.interesting(),
"kev": lambda ctx: preds.host_vuln(ctx.scope, Vulnerability.is_kev.is_(True)),
}
def _cdn(cmp: Compare):
state = tri_state(cmp)
if state is None:
return string_match(Subdomain.cdn_name, cmp)
return Subdomain.is_cdn.is_(state)
def _waf(cmp: Compare):
state = tri_state(cmp)
if state is None:
return string_match(Subdomain.waf, cmp)
return Subdomain.waf.isnot(None) if state else Subdomain.waf.is_(None)
def _issuer(cmp: Compare):
return or_(
string_match(HttpAsset.tls_issuer, cmp),
string_match(HttpAsset.tls_issuer_org, cmp),
string_match(HttpAsset.tls_issuer_cn, cmp),
)
def _url():
return func.coalesce(Subdomain.final_url, Subdomain.http_url)
def _endpoint_count(scope: QueryScope):
return (
select(func.count())
.select_from(Endpoint)
.where(scope.match(Endpoint.scan_id), Endpoint.host == Subdomain.name)
.correlate(Subdomain)
.scalar_subquery()
)
def _interest_kind(cmp: Compare):
matched = preds.interest_signal(
InterestSignal.kind.in_([v.lower() for v in cmp.values])
)
return negate(matched) if cmp.op is Op.NE else matched
def _interest_source(cmp: Compare):
matched = preds.interest_signal(
InterestSignal.source.in_([v.lower() for v in cmp.values])
)
return negate(matched) if cmp.op is Op.NE else matched
def _interest_band(cmp: Compare):
matched = Subdomain.interest_band.in_([v.lower() for v in cmp.values])
return negate(matched) if cmp.op is Op.NE else matched
def _hygiene(cmp: Compare):
for raw in cmp.values:
if raw.lower() not in HYGIENE_VALUES:
msg = f"Unknown hygiene check {raw!r}."
hint = f"Try one of: {', '.join(HYGIENE_VALUES)}"
raise QuerySyntaxError(msg, cmp.start, cmp.end, hint)
matched = preds.hygiene(list(cmp.values))
return negate(matched) if cmp.op is Op.NE else matched
def _posture(cmp: Compare):
for raw in cmp.values:
if raw.lower() not in POSTURE_VALUES:
msg = f"Unknown posture check {raw!r}."
hint = f"Try one of: {', '.join(POSTURE_VALUES)}"
raise QuerySyntaxError(msg, cmp.start, cmp.end, hint)
matched = preds.posture(list(cmp.values))
return negate(matched) if cmp.op is Op.NE else matched
def _ai(cmp: Compare, ctx: QueryContext):
services = cast(Subdomain.ai_services, JSONB)
state = tri_state(cmp)
if state is not None:
present = func.coalesce(func.jsonb_array_length(services), 0) > 0
matched = present if state else ~present
return negate(matched) if cmp.op is Op.NE else matched
if cmp.op in (Op.RE, Op.NRE):
return json_array_match(Subdomain.ai_services, cmp)
values = [v.lower() for v in cmp.values]
categories = [v for v in values if v in AI_CATEGORIES]
keys = [v for v in values if v not in AI_CATEGORIES]
parts = []
if keys:
parts.append(func.jsonb_exists_any(services, pg_array(keys)))
if categories:
parts.append(
preds.asset_match(ctx.scope, HttpAsset.ai_category.in_(categories))
)
matched = or_(*parts)
return negate(matched) if cmp.op is Op.NE else matched
def _ai_model(cmp: Compare):
state = tri_state(cmp)
if state is None:
return json_array_match(HttpAsset.ai_models, cmp)
present = func.coalesce(
func.jsonb_array_length(cast(HttpAsset.ai_models, JSONB)), 0
)
matched = present > 0 if state else present == 0
return negate(matched) if cmp.op is Op.NE else matched
_SUBDOMAIN_BUILDERS = {
"target": lambda c, _ctx: target_match(Subdomain.target_id, c),
"host": lambda c, _ctx: folded_match(Subdomain.name, c),
"url": lambda c, _ctx: string_match(_url(), c),
"cname": lambda c, _ctx: string_match(Subdomain.cname, c),
"source": lambda c, _ctx: json_array_match(Subdomain.sources, c),
"discovered": lambda c, ctx: date_match(
Subdomain.discovered_at, c, ctx.now, future=False
),
"status": lambda c, _ctx: _status(c),
"title": lambda c, _ctx: string_match(Subdomain.page_title, c),
"server": lambda c, _ctx: string_match(Subdomain.webserver, c),
"tech": lambda c, _ctx: json_array_match(Subdomain.tech, c),
"content_type": lambda c, _ctx: string_match(Subdomain.content_type, c),
"size": lambda c, _ctx: number_match(
Subdomain.content_length, c, scaled_coerce(c, FieldType.BYTES)
),
"time": lambda c, _ctx: number_match(
Subdomain.response_time, c, scaled_coerce(c, FieldType.DURATION)
),
"paths": lambda c, ctx: number_match(_endpoint_count(ctx.scope), c, int_coerce(c)),
"favicon": lambda c, _ctx: string_match(Subdomain.favicon_hash, c),
"screenshot": lambda c, _ctx: preds.renders_like(
Subdomain.screenshot_phash, render_hash(c.values[0], c.start, c.end)
),
"ip": lambda c, _ctx: _ip(c),
"asn": lambda c, _ctx: number_match(
Subdomain.asn, c, lambda raw: asn_number(raw, c.start, c.end)
),
"org": lambda c, _ctx: string_match(Subdomain.asn_org, c),
"cdn": lambda c, _ctx: _cdn(c),
"waf": lambda c, _ctx: _waf(c),
"port": lambda c, ctx: preds.port_match(
number_match(Port.number, c, int_coerce(c)), ctx.scope
),
"service": lambda c, ctx: preds.port_match(
string_match(Port.service_name, c), ctx.scope
),
"cert": _cert,
"cert.expires": lambda c, ctx: date_match(
Subdomain.tls_not_after, c, ctx.now, future=True
),
"vuln": lambda c, ctx: preds.host_vuln(
ctx.scope, string_match(Vulnerability.severity, c)
),
"exposure": lambda c, _ctx: _interest_kind(c),
"flagged": lambda c, _ctx: _interest_source(c),
"exposure_band": lambda c, _ctx: _interest_band(c),
"hygiene": lambda c, _ctx: _hygiene(c),
"posture": lambda c, _ctx: _posture(c),
"ai": _ai,
"cve": lambda c, ctx: preds.host_vuln(
ctx.scope, json_array_match(Vulnerability.cve_ids, c)
),
"is": _flag,
}
_ASSET_BUILDERS = {
"ai.model": lambda c, _ctx: _ai_model(c),
"path": lambda c, _ctx: string_match(HttpAsset.path, c),
"words": lambda c, _ctx: number_match(HttpAsset.words, c, int_coerce(c)),
"lines": lambda c, _ctx: number_match(HttpAsset.lines, c, int_coerce(c)),
"redirect": lambda c, _ctx: string_match(HttpAsset.location, c),
"content_hash": lambda c, _ctx: string_match(HttpAsset.content_hash, c),
"tracking": lambda c, _ctx: json_array_match(HttpAsset.tracking_ids, c),
"body": lambda c, _ctx: _tsv(c, _BODY_WEIGHT),
"header": lambda c, _ctx: _header(c),
"cert.cn": lambda c, _ctx: string_match(HttpAsset.tls_subject_cn, c),
"cert.san": lambda c, _ctx: json_array_match(HttpAsset.tls_sans, c),
"cert.issuer": lambda c, _ctx: _issuer(c),
"tls.version": lambda c, _ctx: string_match(HttpAsset.tls_version, c),
"jarm": lambda c, _ctx: string_match(HttpAsset.jarm, c),
"cert.fingerprint": lambda c, _ctx: string_match(HttpAsset.tls_fingerprint, c),
"header_hash": lambda c, _ctx: string_match(HttpAsset.header_hash, c),
}
_FREE_TEXT_BUILDERS = {
"cdn": lambda c, _ctx: string_match(Subdomain.cdn_name, c),
"waf": lambda c, _ctx: string_match(Subdomain.waf, c),
"url": lambda c, _ctx: string_match(Subdomain.final_url, c),
"favicon": lambda c, _ctx: Subdomain.favicon_hash == c.values[0],
}
def compile_compare(cmp: Compare, ctx: QueryContext) -> Compiled:
builder = _SUBDOMAIN_BUILDERS.get(cmp.name)
if builder is not None:
return Compiled(where=builder(cmp, ctx))
builder = _ASSET_BUILDERS.get(cmp.name)
if builder is not None:
return Compiled(asset=builder(cmp, ctx))
msg = f"Field {cmp.name!r} cannot be searched."
raise QuerySyntaxError(msg, cmp.start, cmp.end)
def compile_term(term: Term, ctx: QueryContext):
"""One id lookup per free-text field."""
branches = []
assets = []
for field in free_text_fields(HOST_QUERY):
cmp = as_compare(term, field)
override = _FREE_TEXT_BUILDERS.get(field)
if override is not None:
branches.append(override(cmp, ctx))
elif field in _ASSET_BUILDERS:
assets.append(_ASSET_BUILDERS[field](cmp, ctx))
else:
branches.append(_SUBDOMAIN_BUILDERS[field](cmp, ctx))
if assets:
branches.append(preds.asset_match(ctx.scope, or_(*assets)))
if not branches:
return false()
reach = ctx.scope.match(Subdomain.scan_id)
reachable = union_all(
*[
select(Subdomain.id).where(reach, branch).correlate(None)
for branch in branches
]
).subquery()
return Subdomain.id.in_(select(reachable.c.id))
def _compile_or(node: Or, ctx: QueryContext, walk):
"""Fold sibling asset predicates into one semijoin."""
parts, assets = [], []
for part in node.parts:
if isinstance(part, Compare):
compiled = compile_compare(part, ctx)
if compiled.where is None and compiled.asset is not None:
assets.append(compiled.asset)
continue
parts.append(compiled.flatten(ctx))
else:
parts.append(walk(part, ctx))
if assets:
parts.append(preds.asset_match(ctx.scope, or_(*assets)))
return or_(*parts) if parts else true()
compile_query = walker(
HOST_QUERY,
_SUBDOMAIN_BUILDERS,
term=compile_term,
compare=lambda cmp, ctx: compile_compare(cmp, ctx).flatten(ctx),
fold_or=_compile_or,
)