From f26cbc4c72df7835bfe4fd047f8406c0e7ad266a Mon Sep 17 00:00:00 2001 From: Alessio Date: Wed, 19 Aug 2026 17:46:34 +0100 Subject: [PATCH 1/2] fix(ui): make the pinned critical a block, and let a group name its attack type Three complaints from reviewing the shipped grouped alerts view. The pinned latest-critical row was distinguished only by a red left border, and by an accidental red BOTTOM border: `border-severity-critical-foreground/60` sets the colour on every edge, and TableRow already draws a bottom border, so the row picked up a red underline nobody asked for. The accent is now scoped to the left edge, the row carries a faint critical tint, and a section strip sits above it carrying the label. The label moved out of the transaction-hash cell for the reason it looked misplaced there: being pinned is a property of the row's PLACEMENT, and a badge beside the hash reads as an attribute of that transaction. A spacer row separates the block from the sorted list below. The group row's file icon was generic and said nothing, so it is gone. The contract address gains a copy button instead: the cell shows a registry label or a truncated address, so the full bech32 value could not be selected off the screen. It stops propagation, or copying would also expand the group. The Attack Type column held the alert count on a group row, promising a type and delivering a number. It now names the class of the group's WORST alert, which is the same alert the severity badge on that line describes, plus "+N more" when the group holds other kinds, tooltipped with their names. The count moves under the contract name, where it describes the group. Two aggregates back this: `argMax(max_class, max_score) AS worst_class` and `groupUniqArray(max_class)`, both aliased away from the source column name because on ClickHouse 26.x an aggregate aliased to a column a sibling aggregate reads returns Code 184. The contract_anomaly reconciliation keeps both in step: a verdict that takes over a group renames its attack type as well as its band, and its synthetic class is unioned into the class set that SQL cannot see. The union never subtracts, since proving no sibling row still carries the stored class would need per-class counts rather than a set; over-stating by one is the safe direction for a "+N other kinds" hint, and it is documented at the merge. Gates: 1283 backend (up 7), recall gate 554 unchanged, 136 frontend (up 5), ruff and mypy clean on both trees, eslint and build clean. The five new Tailwind utilities were checked against the built CSS, because a class Tailwind cannot generate fails silently rather than erroring. --- README.md | 2 +- backend/app/api/analysis.py | 20 ++- backend/app/api/contract_anomaly_read.py | 14 +- backend/app/db/clickhouse_scores.py | 16 ++- backend/tests/api/test_grouped_alerts.py | 117 ++++++++++++++- backend/tests/db/test_contract_grouping.py | 31 +++- frontend/src/components/alerts/alert-rows.tsx | 133 ++++++++++++++---- frontend/src/lib/api/analysis.ts | 14 ++ frontend/src/pages/AttacksPage.test.tsx | 88 +++++++++++- frontend/src/pages/AttacksPage.tsx | 18 +-- 10 files changed, 405 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 91b8795..e4ef60b 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Lifecycle statuses: `PENDING` (mempool), `CONFIRMED` (in block), `ROLLED_BACK` ( | Method | Path | Description | |---|---|---| | GET | `/api/v1/analysis/results` | Analysis results (params: `risk_band`, `min_score`, `min_corroboration`, `attack_class`, `contract`, `sort`, `analyzed_from`, `analyzed_to`, `limit`, `offset`). `contract` is tri-state: omitted means no filter, an address restricts to that contract, and the EMPTY string selects exactly the alerts that name no contract at all. Valid `attack_class` values are the nine stored classes (`token_dust`, `large_value`, `large_datum`, `multiple_sat`, `front_running`, `sandwich`, `circular`, `fake_token`, `phishing`) plus the synthetic `contract_anomaly` (resolved at read time; returns empty when the clustering profile is off). | -| GET | `/api/v1/analysis/results/grouped` | Alerts collapsed to one row per contract, for the grouped alerts table. Same filters as `/results`. Returns one ordered list mixing `kind="group"` rows with `kind="alert"` rows for alerts that name no contract, so both shapes interleave correctly across page boundaries. The envelope carries two totals: `total` counts ROWS (what the pager steps through) and `alert_total` counts ALERTS. | +| GET | `/api/v1/analysis/results/grouped` | Alerts collapsed to one row per contract, for the grouped alerts table. Same filters as `/results`. Returns one ordered list mixing `kind="group"` rows with `kind="alert"` rows for alerts that name no contract, so both shapes interleave correctly across page boundaries. The envelope carries two totals: `total` counts ROWS (what the pager steps through) and `alert_total` counts ALERTS. A group row names an attack type as well as counting: `attack_class` is the class of the alert that also gives the row its `worst_band` and `worst_score`, and `attack_classes` is every distinct class under the row, so a client can say how many other kinds the contract holds without expanding it. | | GET | `/api/v1/analysis/results/{tx_hash}` | Analysis result for a single transaction | | GET | `/api/v1/analysis/stats` | Risk-band distribution and per-class score stats. `avg_max_score` averages the FINDING population (`max_score >= 1`), matching the alert list beside it rather than every scored transaction; `finding_count` is that population's size | | GET | `/api/v1/analysis/stats/timeseries` | Daily High and Critical alert counts (params: `days`) | diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py index 900e598..a974f1f 100644 --- a/backend/app/api/analysis.py +++ b/backend/app/api/analysis.py @@ -89,7 +89,22 @@ class GroupedAlertRow(BaseModel): ) attack_class: str | None = Field( None, - description="Winning class, for kind='alert' only", + description=( + "The winning class of the alert this row stands for: the alert " + "itself for kind='alert', and the group's highest-scoring alert for " + "kind='group'. Same row worst_band and worst_score describe, so a " + "group names an attack type instead of only counting alerts." + ), + ) + attack_classes: list[str] = Field( + default_factory=list, + description=( + "Every distinct class under this row, so a client can say how many " + "OTHER kinds of alert a group holds without expanding it. One " + "element for kind='alert'. Bounded by the nine-class vocabulary. A " + "contract_anomaly verdict is unioned in, never subtracted: see " + "_augment_groups_with_contract_anomaly." + ), ) unclusterable_model: bool = Field( False, @@ -173,6 +188,7 @@ async def _unattributed_alert_rows( "latest_analyzed_at": r["analyzed_at"], "tx_hash": r["tx_hash"], "attack_class": r["max_class"], + "attack_classes": [r["max_class"]], "unclusterable_model": False, } for r in rows @@ -319,6 +335,8 @@ async def list_analysis_result_groups( "worst_score": float(g["worst_score"]), "worst_band": g["worst_band"], "latest_analyzed_at": g["latest_analyzed_at"], + "attack_class": g.get("worst_class"), + "attack_classes": list(g.get("classes") or []), "unclusterable_model": bool(g.get("unclusterable_model", False)), } for g in groups diff --git a/backend/app/api/contract_anomaly_read.py b/backend/app/api/contract_anomaly_read.py index e9e9837..b1cde68 100644 --- a/backend/app/api/contract_anomaly_read.py +++ b/backend/app/api/contract_anomaly_read.py @@ -626,7 +626,7 @@ async def _augment_groups_with_contract_anomaly( 1. it is added to its effective group, creating that group if SQL produced none for the target; - 2. that group's ``worst_score`` / ``worst_band`` / ``latest_analyzed_at`` + 2. that group's ``worst_score`` / ``worst_band`` / ``worst_class`` / ``latest_analyzed_at`` rise to the effective values. A transaction is never REMOVED from the group its stored contract names, @@ -705,6 +705,8 @@ def _passes(res: ClassScoreResult) -> bool: "alert_count": 0, "worst_score": 0.0, "worst_band": res.risk_band.value, + "worst_class": res.max_class, + "classes": [], "latest_analyzed_at": res.analyzed_at, "unclusterable_model": False, } @@ -713,9 +715,19 @@ def _passes(res: ClassScoreResult) -> bool: # Already counted by SQL only when it was counted under THIS contract. if not (stored_counted and stored_contract == effective_contract): group["alert_count"] = int(group["alert_count"]) + 1 + # The class set is UNION-only. Removing the stored class when a verdict + # rewrites a row would require knowing no sibling row still carries it, + # which a set-valued aggregate cannot answer; over-stating by one is the + # safe direction for a "+N other kinds" hint. + classes = group.setdefault("classes", []) + if res.max_class not in classes: + classes.append(res.max_class) if res.max_score > float(group["worst_score"]): group["worst_score"] = res.max_score group["worst_band"] = res.risk_band.value + # Kept in step with worst_band: both describe the group's top row, so + # a row that takes over the group renames its attack type too. + group["worst_class"] = res.max_class # Tracks the WORST row, so it flips back off when a stored-class # alert outranks the un-clusterable verdict. A group marked # un-clusterable on a row that no longer tops it would tell the diff --git a/backend/app/db/clickhouse_scores.py b/backend/app/db/clickhouse_scores.py index 16f335b..d6925f3 100644 --- a/backend/app/db/clickhouse_scores.py +++ b/backend/app/db/clickhouse_scores.py @@ -620,6 +620,16 @@ def group_class_scores_by_contract( (via argMax), not a band re-derived from ``worst_score``: a past recalibration can move the thresholds, and the badge should agree with the row the analyst sees when they expand the group. + + ``worst_class`` is that same highest-scoring alert's class, so the row can + name an attack type instead of only counting alerts, and it agrees with + ``worst_band`` by construction (both argMax the same ordering). ``classes`` + is every distinct class under the group, which lets the caller say how many + OTHER kinds of alert the group holds. It is aliased away from ``max_class`` + for the reason ``worst_score`` is aliased away from ``max_score``: on + ClickHouse 26.x, aliasing an aggregate to a source column name that a + sibling aggregate also reads returns Code 184. ``groupUniqArray`` is bounded + by the nine-class vocabulary, so the array is at most nine short strings. """ conditions, params = _score_filter_conditions( network, @@ -643,6 +653,8 @@ def group_class_scores_by_contract( count() AS alert_count, max(max_score) AS worst_score, argMax(risk_band, max_score) AS worst_band, + argMax(max_class, max_score) AS worst_class, + groupUniqArray(max_class) AS classes, max(analyzed_at) AS latest_analyzed_at FROM tx_class_scores FINAL WHERE {where} @@ -658,7 +670,9 @@ def group_class_scores_by_contract( "alert_count": int(r[1]), "worst_score": float(r[2]), "worst_band": r[3], - "latest_analyzed_at": r[4], + "worst_class": r[4], + "classes": list(r[5]), + "latest_analyzed_at": r[6], } for r in rows ] diff --git a/backend/tests/api/test_grouped_alerts.py b/backend/tests/api/test_grouped_alerts.py index d162165..5842545 100644 --- a/backend/tests/api/test_grouped_alerts.py +++ b/backend/tests/api/test_grouped_alerts.py @@ -43,7 +43,15 @@ def _clustering_off(monkeypatch): monkeypatch.setattr(settings, "CLUSTERING_ENABLED", False) -def _group(address, count=3, worst=72.0, band="High", at=None): +def _group( + address, + count=3, + worst=72.0, + band="High", + at=None, + worst_class="large_value", + classes=None, +): # clickhouse-driver hands back tz-NAIVE datetimes; the reconciliation has # to normalise before comparing against the tz-aware model value. at = at if at is not None else datetime(2026, 8, 1, 10, 0) @@ -52,6 +60,8 @@ def _group(address, count=3, worst=72.0, band="High", at=None): "alert_count": count, "worst_score": worst, "worst_band": band, + "worst_class": worst_class, + "classes": [worst_class] if classes is None else list(classes), "latest_analyzed_at": at, } @@ -344,6 +354,44 @@ def test_runs_when_the_filter_is_the_synthetic_class_itself(self, client, monkey assert calls[0]["anomaly_only"] is True +class TestGroupAttackType: + """A group row names an attack type, not only a count. + + The row's severity badge is its worst alert's stored band, so the type shown + beside it has to be that SAME alert's class or the two disagree on one line. + """ + + def test_a_group_names_the_class_of_its_worst_alert(self, client, monkeypatch): + _stub_groups( + monkeypatch, + [_group(DJED, worst_class="large_datum", classes=["large_datum", "token_dust"])], + unattributed=[], + ) + row = client.get(f"{GROUPED_URL}?network=preprod").json()["data"][0] + assert row["kind"] == "group" + assert row["attack_class"] == "large_datum" + + def test_a_group_lists_every_distinct_class_it_holds(self, client, monkeypatch): + # Feeds the "+N other kinds" hint, so the client never has to expand a + # group just to learn whether it is homogeneous. + _stub_groups( + monkeypatch, + [_group(DJED, worst_class="large_datum", classes=["large_datum", "token_dust"])], + unattributed=[], + ) + row = client.get(f"{GROUPED_URL}?network=preprod").json()["data"][0] + assert row["attack_classes"] == ["large_datum", "token_dust"] + + def test_an_unattributed_alert_is_its_own_single_class(self, client, monkeypatch): + # One alert cannot hold two classes, so the set is exactly its own and a + # client can render both row shapes through one code path. + _stub_groups(monkeypatch, [], unattributed=[_alert_row("tx9", cls="phishing")]) + row = client.get(f"{GROUPED_URL}?network=preprod").json()["data"][0] + assert row["kind"] == "alert" + assert row["attack_class"] == "phishing" + assert row["attack_classes"] == ["phishing"] + + class TestValidation: def test_unknown_attack_class_is_422(self, client, monkeypatch): _stub_groups(monkeypatch, []) @@ -421,6 +469,73 @@ async def test_verdict_moves_a_tx_to_the_watched_target(self, monkeypatch): assert by_address[STRIKE]["alert_count"] == 1 assert by_address[STRIKE]["worst_band"] == "Critical" assert by_address[DJED]["alert_count"] == 1 + # The group the verdict invented is named by the verdict's own class: + # there is no stored class under it to name instead. + assert by_address[STRIKE]["worst_class"] == "contract_anomaly" + + @pytest.mark.anyio + async def test_a_verdict_that_takes_over_renames_the_group_attack_type(self, monkeypatch): + # worst_class has to move with worst_band. A group badged Critical by the + # verdict while still naming the stored class would put two different + # alerts on one line. + from tests.analysis.test_contract_anomaly_projection import _full_score_row + + stored = _full_score_row("tx1", 20.0) + stored["contract_address"] = DJED + self._stored(monkeypatch, [stored], {"tx1": [self._flagged_row(DJED)]}) + + groups = [ + _group( + DJED, + count=1, + worst=20.0, + band="Informational", + worst_class="phishing", + classes=["phishing"], + ) + ] + await _augment_groups_with_contract_anomaly( + "preprod", + groups, + bands=None, + min_score=1.0, + analyzed_from=None, + analyzed_to=None, + min_corroboration=0, + ) + assert groups[0]["worst_band"] == "Critical" + assert groups[0]["worst_class"] == "contract_anomaly" + + @pytest.mark.anyio + async def test_the_verdict_class_is_added_to_the_group_class_set(self, monkeypatch): + # SQL cannot see the synthetic class, so without the union a mixed group + # would under-report how many kinds of alert it holds. + from tests.analysis.test_contract_anomaly_projection import _full_score_row + + stored = _full_score_row("tx1", 20.0) + stored["contract_address"] = DJED + self._stored(monkeypatch, [stored], {"tx1": [self._flagged_row(DJED)]}) + + groups = [ + _group( + DJED, + count=1, + worst=20.0, + band="Informational", + worst_class="phishing", + classes=["phishing"], + ) + ] + await _augment_groups_with_contract_anomaly( + "preprod", + groups, + bands=None, + min_score=1.0, + analyzed_from=None, + analyzed_to=None, + min_corroboration=0, + ) + assert sorted(groups[0]["classes"]) == ["contract_anomaly", "phishing"] @pytest.mark.anyio async def test_group_is_created_when_sql_produced_none(self, monkeypatch): diff --git a/backend/tests/db/test_contract_grouping.py b/backend/tests/db/test_contract_grouping.py index e26cb11..60cc382 100644 --- a/backend/tests/db/test_contract_grouping.py +++ b/backend/tests/db/test_contract_grouping.py @@ -105,10 +105,35 @@ def test_worst_band_is_the_stored_band_of_the_worst_row(self, monkeypatch): clickhouse_scores.group_class_scores_by_contract(network="preprod") assert "argMax(risk_band, max_score)" in fake.queries[0] + def test_worst_class_is_the_class_of_the_worst_row(self, monkeypatch): + # Same argMax ordering as worst_band, so the attack type a group row + # names and the severity badge beside it always describe one alert. + # Aliased to worst_class, NOT max_class: on 26.x an aggregate aliased to + # a source column a sibling aggregate reads returns Code 184. + fake = _patch_client(monkeypatch, []) + clickhouse_scores.group_class_scores_by_contract(network="preprod") + assert "argMax(max_class, max_score) AS worst_class" in fake.queries[0] + assert "AS max_class" not in fake.queries[0] + + def test_distinct_classes_are_collected_for_the_group(self, monkeypatch): + fake = _patch_client(monkeypatch, []) + clickhouse_scores.group_class_scores_by_contract(network="preprod") + assert "groupUniqArray(max_class) AS classes" in fake.queries[0] + def test_rows_are_mapped_by_name(self, monkeypatch): _patch_client( monkeypatch, - [("addr_test1wq9", 12, 92.5, "Critical", "2026-08-01 10:00:00")], + [ + ( + "addr_test1wq9", + 12, + 92.5, + "Critical", + "large_datum", + ("large_datum", "large_value"), + "2026-08-01 10:00:00", + ) + ], ) groups = clickhouse_scores.group_class_scores_by_contract(network="preprod") assert groups == [ @@ -117,6 +142,10 @@ def test_rows_are_mapped_by_name(self, monkeypatch): "alert_count": 12, "worst_score": 92.5, "worst_band": "Critical", + "worst_class": "large_datum", + # The driver hands back an array column as a tuple; the row map + # normalises it so callers can treat it as a plain list. + "classes": ["large_datum", "large_value"], "latest_analyzed_at": "2026-08-01 10:00:00", } ] diff --git a/frontend/src/components/alerts/alert-rows.tsx b/frontend/src/components/alerts/alert-rows.tsx index 0605362..655767d 100644 --- a/frontend/src/components/alerts/alert-rows.tsx +++ b/frontend/src/components/alerts/alert-rows.tsx @@ -28,7 +28,7 @@ import { ChevronDown, ChevronRight, Copy, - FileWarning, + Pin, } from "lucide-react"; /** @@ -42,19 +42,26 @@ export const ALERT_COLUMN_COUNT = 5; const CONTRACT_HEAD = 14; const CONTRACT_TAIL = 6; -function CopyHashButton({ hash }: { hash: string }) { +/** + * Copies an identifier the table can only show truncated. + * + * Serves a transaction hash and a group's contract address alike: both are shown + * shortened, and the address is often replaced outright by its registry label, + * so neither can be selected off the screen. + */ +function CopyButton({ value, label }: { value: string; label: string }) { return (