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..a448cef 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,11 @@ 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"), + # SQL already sorts these, but the contract_anomaly merge APPENDS + # its synthetic class, so re-sort at the boundary or an augmented + # group reshuffles its list between two identical refreshes. + "attack_classes": sorted(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..cb6582b 100644 --- a/backend/app/db/clickhouse_scores.py +++ b/backend/app/db/clickhouse_scores.py @@ -620,6 +620,23 @@ 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. + + The band and the class come out of ONE ``argMax`` over the tuple + ``(risk_band, max_class)``, not two argMax calls sharing an ordering. Two + aggregates can break a tie on DIFFERENT rows (ClickHouse picks arbitrarily + among equal ``max_score`` values, the more so across merge threads), which + would put a severity badge and an attack type from two different alerts on + one line. One aggregate returns one row's pair, so the invariant holds by + construction rather than by luck. + + ``classes`` is every distinct class under the group, which lets the caller + say how many OTHER kinds of alert it holds. ``arraySort`` makes the order + stable, so an unchanged group does not reshuffle its tooltip between two + identical refreshes. Both are 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, @@ -642,7 +659,8 @@ def group_class_scores_by_contract( SELECT contract_address, count() AS alert_count, max(max_score) AS worst_score, - argMax(risk_band, max_score) AS worst_band, + argMax((risk_band, max_class), max_score) AS worst_pair, + arraySort(groupUniqArray(max_class)) AS classes, max(analyzed_at) AS latest_analyzed_at FROM tx_class_scores FINAL WHERE {where} @@ -657,8 +675,10 @@ def group_class_scores_by_contract( "contract_address": r[0], "alert_count": int(r[1]), "worst_score": float(r[2]), - "worst_band": r[3], - "latest_analyzed_at": r[4], + "worst_band": r[3][0], + "worst_class": r[3][1], + "classes": list(r[4]), + "latest_analyzed_at": r[5], } 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..fa009af 100644 --- a/backend/tests/db/test_contract_grouping.py +++ b/backend/tests/db/test_contract_grouping.py @@ -81,9 +81,10 @@ def test_aliases_do_not_shadow_source_columns(self, monkeypatch): clickhouse_scores.group_class_scores_by_contract(network="preprod") sql = fake.queries[0] assert "AS worst_score" in sql - assert "AS worst_band" in sql + assert "AS worst_pair" in sql + assert "AS classes" in sql assert "AS latest_analyzed_at" in sql - for shadowed in ("AS max_score", "AS risk_band", "AS analyzed_at"): + for shadowed in ("AS max_score", "AS risk_band", "AS analyzed_at", "AS max_class"): assert shadowed not in sql def test_excludes_rows_naming_no_contract(self, monkeypatch): @@ -97,18 +98,47 @@ def test_groups_by_contract(self, monkeypatch): clickhouse_scores.group_class_scores_by_contract(network="preprod") assert "GROUP BY contract_address" in fake.queries[0] - def test_worst_band_is_the_stored_band_of_the_worst_row(self, monkeypatch): - # argMax, not a band re-derived from worst_score: a past recalibration can - # move the thresholds and the badge must agree with the row the analyst - # sees on expanding the group. + def test_band_and_class_come_from_ONE_argmax(self, monkeypatch): + # The band is argMax, not re-derived from worst_score: a past + # recalibration can move the thresholds and the badge must agree with the + # row the analyst sees on expanding the group. + # + # And band and class come out of a single argMax over a TUPLE. Two + # separate argMax calls sharing an ordering can break a tie on different + # rows, which would put a severity badge and an attack type belonging to + # two different alerts on one line. fake = _patch_client(monkeypatch, []) clickhouse_scores.group_class_scores_by_contract(network="preprod") - assert "argMax(risk_band, max_score)" in fake.queries[0] + assert "argMax((risk_band, max_class), max_score) AS worst_pair" in fake.queries[0] + assert "argMax(risk_band, max_score)" not in fake.queries[0] + assert "argMax(max_class, max_score)" not in fake.queries[0] + # Aliased away from the source column names: on 26.x an aggregate aliased + # to a column a sibling aggregate reads returns Code 184. + assert "AS max_class" not in fake.queries[0] + assert "AS max_score" not in fake.queries[0] + + def test_distinct_classes_are_collected_and_sorted(self, monkeypatch): + # Sorted so an unchanged group does not reshuffle its "+N more" tooltip + # between two identical refreshes; groupUniqArray has no stable order. + fake = _patch_client(monkeypatch, []) + clickhouse_scores.group_class_scores_by_contract(network="preprod") + assert "arraySort(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, + # One argMax over a tuple, so the driver hands back the band + # and the class of the SAME row as one value. + ("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 +147,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/backend/tests/live_db/test_clickhouse_live.py b/backend/tests/live_db/test_clickhouse_live.py index a3eeb7d..6c63034 100644 --- a/backend/tests/live_db/test_clickhouse_live.py +++ b/backend/tests/live_db/test_clickhouse_live.py @@ -140,6 +140,76 @@ def test_write_read_list_count_stats(self, ch): assert isinstance(timeseries, list) +class TestContractGrouping: + """The grouped alerts aggregate, on a real server. + + Two reasons this cannot be left to the hermetic suite. The aggregate reads + ``max_class`` from two places at once (an argMax over a tuple and a + groupUniqArray), which is the shape that returns Code 184 on 26.x when an + alias collides with a source column, and a mocked client only ever proves + the query TEXT. And the band/class pairing is a tie-breaking property of + argMax that no string assertion can establish. + """ + + @staticmethod + def _row(contract: str, cls: str, score: float, band: str) -> dict: + row = _score_row(uuid.uuid4().hex * 2) + row["contract_address"] = contract + row["max_class"] = cls + row["max_score"] = score + row["risk_band"] = band + # Keep the per-class column in step with max_class, so the row is not + # self-contradictory to anything that reads the vector. + row.pop("token_dust", None) + row[cls] = score + return row + + def _group_for(self, contract: str) -> dict: + groups = scores.group_class_scores_by_contract( + network=LIVE_NETWORK, min_score=1.0, limit=100 + ) + found = [g for g in groups if g["contract_address"] == contract] + assert found, f"no group for {contract} in {len(groups)} groups" + return found[0] + + def test_group_counts_and_names_its_worst_alert(self, ch): + contract = f"addr_livedb_{uuid.uuid4().hex[:12]}" + scores.insert_class_scores( + [ + self._row(contract, "token_dust", 82.0, "Critical"), + self._row(contract, "large_value", 40.0, "Moderate"), + ] + ) + group = self._group_for(contract) + assert group["alert_count"] == 2 + assert group["worst_score"] == 82.0 + assert group["worst_band"] == "Critical" + assert group["worst_class"] == "token_dust" + # Sorted server-side, so the UI's "+N more" list cannot reshuffle between + # two identical refreshes. + assert group["classes"] == ["large_value", "token_dust"] + + def test_band_and_class_describe_the_SAME_alert_on_a_tie(self, ch): + # Equal max_score is where two independent argMax calls could each pick a + # different winner and hand the UI a severity from one alert with the + # attack type of another. One argMax over the tuple cannot: whichever row + # wins, the pair it returns is that row's own. + contract = f"addr_livedb_{uuid.uuid4().hex[:12]}" + tie = 90.0 + scores.insert_class_scores( + [ + self._row(contract, "phishing", tie, "High"), + self._row(contract, "circular", tie, "Critical"), + ] + ) + group = self._group_for(contract) + assert group["worst_score"] == tie + assert (group["worst_band"], group["worst_class"]) in { + ("High", "phishing"), + ("Critical", "circular"), + } + + class TestBaselines: def test_insert_then_get_roundtrip(self, ch): # Fresh scope_id per run so the read misses the in-process TTL diff --git a/frontend/src/components/alerts/alert-rows.tsx b/frontend/src/components/alerts/alert-rows.tsx index 0605362..f8f7647 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 (