Files
rengine/shared/services/asset_query/errors.py
T
Yogesh OjhaandClaude Opus 5 cb1412ce90 feat(interest): a scan can say which twenty of five thousand hosts to open first
reNgine 2 answered this with one global keyword list matched by `icontains`, and
rebuilt that query once per table row — `get_is_interesting` was a
SerializerMethodField, so a 5,000-host page ran the whole unindexable OR-chain
5,000 times. It was also dead until the user saved the settings form once, since
`get_interesting_subdomains` returns none() with no custom_type row.

The rule survives; everything around it is rebuilt. An interest rule IS a saved
HOST_QUERY query, so there is no second matcher to keep in sync, and the keyword
box compiles to `host:[…] or title:[…]` behind the same grammar. Signals persist
to interest_signals, roll up in SQL onto subdomains.interest_score/band/kinds so
sorting and `is:interesting` stay index-backed, and the scan carries a signature
of the rules it was labelled against — a stale read refreshes in the background,
so editing a keyword still re-labels every past scan without a read-time query.

interest/ is a top-level package mounted into api and worker like stages/ and
reports/, with providers auto-discovered from disk. Three ship:

  rules        the keyword lists and 19 saved queries
  correlation  what stands out from the estate, which no keyword can see
  ai           a judgement, labelled as one everywhere it appears

Correlation is the half a rule cannot express: an ASN carrying under 5% of the
surface, software or a favicon on three hosts or fewer, the one asset answering
directly while the rest sits behind a CDN. All gated on a minimum estate size,
because rarity means nothing without a norm. On gov.cy: 226 flagged of 1,379 in
0.4s, 29 of them critical.

AI sees hostname, status, page title and technology for hosts that answered, and
nothing else — never a response body — deduped to three samples per identical
response shape. It may answer only in a closed schema, a host it was not given is
dropped rather than stored, and its sentence must state what was observed, never
what is exploitable. Page titles are attacker-controlled, so they travel as JSON
data with strip_control and a cap, never interpolated into the instruction, and
nothing it returns triggers an action. Every AI signal stores its model and
prompt version, and the UI never lets it read as an observation.

Then the judgement graduates: suggest.py shows the model what it flagged and asks
for the query that would catch it again without a model. Each proposal is
compiled, counted against the scan and dropped if it errors, matches nothing or
already exists; nothing is stored until a person presses Add, after which it runs
on every keyless install with no cost.

Alerts are delta-only — hosts the target has never flagged before, and only those
in a high band or matched by a rule the user asked to be notified about.

Four bugs found on the way. `dr` matched drones.gov.cy, so shipped hostname rules
are anchored on label boundaries and false positives went from 6 hosts to 0.
predicates.interest_signal aliased InterestSignal while its condition used the
unaliased class, uncorrelating the EXISTS so every interest: field returned the
unfiltered total. The correlation aggregates needed explicit output aliases or a
bare `t` collided with the CTE and printed a UUID inside a sentence. And dismiss
removed the signals while leaving the denormalised rank, so the row stayed in the
list.

The asset query compiler moves to shared/ because the worker has no api/ and has
to compile a rule at finalize; api keeps a shim that aliases every submodule into
sys.modules, so no existing import changes.

The rule library lives at /interest, not in the Arsenal. The Arsenal is tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVh1CGtjDA33fupXjLGbk2
2026-09-06 19:30:04 +05:30

34 lines
1.1 KiB
Python

from __future__ import annotations
from sqlalchemy.exc import DBAPIError
from shared.models.asset_query import QueryError
STATEMENT_TIMEOUT = "SET LOCAL statement_timeout = '20s'"
# a set-returning expansion is estimated at 100 rows a row, and the bogus cost buys 270ms of JIT
NO_JIT = "SET LOCAL jit = off"
QUERY_SQLSTATES = {
"2201B": (
"That regular expression is not valid here.",
"PostgreSQL regular expressions differ slightly from PCRE.",
),
"2201G": ("That regular expression is not valid here.", None),
"57014": (
"That search took too long to run.",
"Add a field filter to narrow it down.",
),
"22003": ("A number in that query is out of range.", None),
"22P02": ("A value in that query has the wrong shape.", None),
"54000": ("That search term is too large to index.", None),
}
def query_error_for(exc: DBAPIError) -> QueryError | None:
sqlstate = getattr(exc.orig, "sqlstate", None) or getattr(exc.orig, "pgcode", None)
known = QUERY_SQLSTATES.get(str(sqlstate))
if known is None:
return None
message, hint = known
return QueryError(message=message, hint=hint)