From 023c789a3190a92e49c30b3385ade0e983008f41 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:46:12 +0100 Subject: [PATCH 1/7] wip: delphi trace transform rules --- Cargo.lock | 91 ++++- Cargo.toml | 2 + apps/frontend/src/pages/moderation.vue | 25 +- .../moderation/technical-review/index.vue | 9 + .../moderation/technical-review/rules.vue | 288 +++++++++++++ ...1078cb48ccd645f059e257cd204de024955b8.json | 14 + ...05f834dbb9b88b834186b1353f116a07c3421.json | 60 +++ ...a9ff55fc6dda4b154dc3136478d3772e06020.json | 61 +++ ...6f7de5ddc18e2014470d2b88626a9b37d30fe.json | 42 ++ ...70a71b32d7fe35bf5f11654e1653c63245078.json | 80 ++++ ...37aa1023b436d06b4412a559553dd5452029e.json | 22 + apps/labrinth/Cargo.toml | 1 + .../20260715120000_delphi_rules.sql | 50 +++ apps/labrinth/src/routes/internal/mod.rs | 4 + .../routes/internal/moderation/tech_review.rs | 2 + .../internal/moderation/tech_review/rules.rs | 379 ++++++++++++++++++ .../modules/labrinth/tech-review/internal.ts | 48 +++ .../api-client/src/modules/labrinth/types.ts | 20 + packages/delphi-cel-experiment/Cargo.toml | 10 + packages/delphi-cel-experiment/src/main.rs | 173 ++++++++ 20 files changed, 1376 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/pages/moderation/technical-review/rules.vue create mode 100644 apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json create mode 100644 apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json create mode 100644 apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json create mode 100644 apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json create mode 100644 apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json create mode 100644 apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json create mode 100644 apps/labrinth/migrations/20260715120000_delphi_rules.sql create mode 100644 apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs create mode 100644 packages/delphi-cel-experiment/Cargo.toml create mode 100644 packages/delphi-cel-experiment/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1c3493e8e0..c7b1fff036 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,6 +437,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set 0.8.0", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid 1.23.3", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -1415,10 +1432,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" dependencies = [ "base64 0.21.7", - "pastey", + "pastey 0.1.1", "serde", ] +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "bindgen" version = "0.72.1" @@ -1445,7 +1468,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -1454,6 +1486,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit_field" version = "0.10.3" @@ -1883,6 +1921,22 @@ dependencies = [ "shlex", ] +[[package]] +name = "cel" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed39583e427bf41d93c28c7f27c943a93bcb8697220ff3575fd53a9e13f3814" +dependencies = [ + "antlr4rust", + "base64 0.22.1", + "lazy_static", + "nom 7.1.3", + "pastey 0.2.3", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "censor" version = "0.3.0" @@ -2843,6 +2897,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" +[[package]] +name = "delphi-cel-experiment" +version = "0.1.0" +dependencies = [ + "cel", + "serde", + "serde_json", +] + [[package]] name = "der" version = "0.7.10" @@ -3508,7 +3571,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex-automata", "regex-syntax", ] @@ -5433,6 +5496,7 @@ dependencies = [ "base64 0.22.1", "bitflags 2.9.4", "bytes", + "cel", "censor", "chrono", "clap 4.5.48", @@ -6170,6 +6234,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb585ade2549a017db2e35978b77c319214fa4b37cede841e27954dd6e8f3ca8" +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + [[package]] name = "mutually_exclusive_features" version = "0.1.0" @@ -7178,6 +7251,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path-util" version = "0.0.0" @@ -11671,6 +11750,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typed-path" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index a5aed6cf76..5d1fa375b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "packages/app-lib", "packages/ariadne", "packages/daedalus", + "packages/delphi-cel-experiment", "packages/labrinth-derive", "packages/modrinth-content-management", "packages/modrinth-log", @@ -55,6 +56,7 @@ bon = "3.9.3" bytemuck = "1.24.0" bytes = "1.10.1" censor = "0.3.0" +cel = { version = "0.14.0", default-features = false, features = ["json"] } chardetng = "0.1.17" chrono = "0.4.42" cidre = { version = "0.15.0", default-features = false, features = [ diff --git a/apps/frontend/src/pages/moderation.vue b/apps/frontend/src/pages/moderation.vue index 9352acabba..dc4a051390 100644 --- a/apps/frontend/src/pages/moderation.vue +++ b/apps/frontend/src/pages/moderation.vue @@ -15,7 +15,14 @@ diff --git a/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json b/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json new file mode 100644 index 0000000000..5c2a54af63 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tUPDATE delphi_rule_revisions\n\t\tSET active = FALSE\n\t\tWHERE rule_id = $1 AND active\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8" +} diff --git a/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json b/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json new file mode 100644 index 0000000000..e22ef8fa7c --- /dev/null +++ b/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tINSERT INTO delphi_rules (\n\t\t\tname,\n\t\t\tpriority,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t)\n\t\tVALUES ($1, $2, $3, $3)\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421" +} diff --git a/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json b/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json new file mode 100644 index 0000000000..0caf56d981 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tUPDATE delphi_rules\n\t\tSET\n\t\t\tname = $2,\n\t\t\tpriority = $3,\n\t\t\tupdated = CURRENT_TIMESTAMP,\n\t\t\tupdated_by = $4\n\t\tWHERE id = $1\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020" +} diff --git a/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json b/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json new file mode 100644 index 0000000000..de87121fdd --- /dev/null +++ b/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tINSERT INTO delphi_rule_revisions (rule_id, expression, created_by)\n\t\tVALUES ($1, $2, $3)\n\t\tRETURNING id, expression, created, created_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "expression", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe" +} diff --git a/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json b/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json new file mode 100644 index 0000000000..80d5ade693 --- /dev/null +++ b/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT\n\t\t\tr.id,\n\t\t\tr.name,\n\t\t\tr.priority,\n\t\t\tr.created,\n\t\t\tr.updated,\n\t\t\tr.created_by,\n\t\t\tr.updated_by,\n\t\t\trr.id AS revision_id,\n\t\t\trr.expression,\n\t\t\trr.created AS revision_created,\n\t\t\trr.created_by AS revision_created_by\n\t\tFROM delphi_rules r\n\t\tINNER JOIN delphi_rule_revisions rr\n\t\t\tON rr.rule_id = r.id\n\t\t\tAND rr.active\n\t\tORDER BY r.priority DESC, r.id\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "updated_by", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "revision_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "expression", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "revision_created", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revision_created_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078" +} diff --git a/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json b/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json new file mode 100644 index 0000000000..97b8370fb4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM delphi_rules WHERE id = $1 RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e" +} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 3da97e6fa8..50ac869f4a 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -34,6 +34,7 @@ base64 = { workspace = true } bitflags = { workspace = true } bytes = { workspace = true } censor = { workspace = true } +cel = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } clickhouse = { workspace = true, features = ["time", "uuid"] } diff --git a/apps/labrinth/migrations/20260715120000_delphi_rules.sql b/apps/labrinth/migrations/20260715120000_delphi_rules.sql new file mode 100644 index 0000000000..527528dcaa --- /dev/null +++ b/apps/labrinth/migrations/20260715120000_delphi_rules.sql @@ -0,0 +1,50 @@ +CREATE TABLE delphi_rules ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL CHECK (BTRIM(name) <> ''), + priority INTEGER NOT NULL DEFAULT 0, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT REFERENCES users (id) + ON DELETE SET NULL + ON UPDATE CASCADE, + updated_by BIGINT REFERENCES users (id) + ON DELETE SET NULL + ON UPDATE CASCADE +); + +CREATE TABLE delphi_rule_revisions ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + rule_id BIGINT NOT NULL REFERENCES delphi_rules (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + expression TEXT NOT NULL CHECK (BTRIM(expression) <> ''), + active BOOLEAN NOT NULL DEFAULT TRUE, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT REFERENCES users (id) + ON DELETE SET NULL + ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX delphi_rule_revisions_active + ON delphi_rule_revisions (rule_id) + WHERE active; + +CREATE INDEX delphi_rule_revisions_rule_id + ON delphi_rule_revisions (rule_id, created DESC); + +CREATE TABLE delphi_rule_effects ( + rule_revision_id BIGINT NOT NULL REFERENCES delphi_rule_revisions (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + issue_detail_id BIGINT NOT NULL REFERENCES delphi_report_issue_details (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + severity delphi_severity, + hidden BOOLEAN NOT NULL DEFAULT FALSE, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rule_revision_id, issue_detail_id), + CHECK (severity IS NOT NULL OR hidden) +); + +CREATE INDEX delphi_rule_effects_issue_detail_id + ON delphi_rule_effects (issue_detail_id); diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 8ab03e1d12..acb31a4f36 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -115,6 +115,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { moderation::tech_review::search_projects, moderation::tech_review::global::search_global_issue_details, moderation::tech_review::global::get_global_issue_detail, + moderation::tech_review::rules::get_rules, + moderation::tech_review::rules::create_rule, + moderation::tech_review::rules::update_rule, + moderation::tech_review::rules::delete_rule, moderation::tech_review::get_project_report, moderation::tech_review::submit_report, moderation::tech_review::update_issue_details, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 2bd5097c58..fedf2288f2 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -44,10 +44,12 @@ use crate::{ use eyre::eyre; pub mod global; +pub mod rules; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(search_projects) .configure(global::config) + .configure(rules::config) .service(get_project_report) .service(get_report) .service(get_issue) diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs new file mode 100644 index 0000000000..f0946c3de6 --- /dev/null +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -0,0 +1,379 @@ +use actix_web::{HttpRequest, delete, get, post, put, web}; +use chrono::{DateTime, Utc}; +use eyre::eyre; +use serde::{Deserialize, Serialize}; + +use crate::{ + auth::check_is_moderator_from_headers, + database::{PgPool, ReadOnlyPgPool, redis::RedisPool}, + models::pats::Scopes, + queue::session::AuthQueue, + routes::ApiError, + util::error::Context, +}; + +const MAX_RULE_NAME_LENGTH: usize = 128; +const MAX_RULE_EXPRESSION_LENGTH: usize = 65_536; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_rules) + .service(create_rule) + .service(update_rule) + .service(delete_rule); +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DelphiRule { + pub id: i64, + pub name: String, + pub priority: i32, + pub expression: String, + pub revision_id: i64, + pub created: DateTime, + pub updated: DateTime, + pub revision_created: DateTime, + pub created_by: Option, + pub updated_by: Option, + pub revision_created_by: Option, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct WriteDelphiRule { + pub name: String, + pub priority: i32, + pub expression: String, +} + +struct ValidatedRule { + name: String, + priority: i32, + expression: String, +} + +impl WriteDelphiRule { + fn validate(self) -> Result { + let name = self.name.trim().to_string(); + if name.is_empty() { + return Err(ApiError::Request(eyre!("rule name cannot be empty"))); + } + if name.chars().count() > MAX_RULE_NAME_LENGTH { + return Err(ApiError::Request(eyre!( + "rule name cannot exceed {MAX_RULE_NAME_LENGTH} characters" + ))); + } + + let expression = self.expression.trim().to_string(); + if expression.is_empty() { + return Err(ApiError::Request(eyre!( + "rule expression cannot be empty" + ))); + } + if expression.len() > MAX_RULE_EXPRESSION_LENGTH { + return Err(ApiError::Request(eyre!( + "rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes" + ))); + } + + cel::Program::compile(&expression).map_err(|error| { + ApiError::Request(eyre!("invalid cel expression: {error}")) + })?; + + Ok(ValidatedRule { + name, + priority: self.priority, + expression, + }) + } +} + +/// List the current revision of every Delphi rule. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = Vec)) +)] +#[get("/rules")] +pub async fn get_rules( + req: HttpRequest, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result>, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let rows = sqlx::query!( + r#" + SELECT + r.id, + r.name, + r.priority, + r.created, + r.updated, + r.created_by, + r.updated_by, + rr.id AS revision_id, + rr.expression, + rr.created AS revision_created, + rr.created_by AS revision_created_by + FROM delphi_rules r + INNER JOIN delphi_rule_revisions rr + ON rr.rule_id = r.id + AND rr.active + ORDER BY r.priority DESC, r.id + "#, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch delphi rules")?; + + Ok(web::Json( + rows.into_iter() + .map(|row| DelphiRule { + id: row.id, + name: row.name, + priority: row.priority, + expression: row.expression, + revision_id: row.revision_id, + created: row.created, + updated: row.updated, + revision_created: row.revision_created, + created_by: row.created_by, + updated_by: row.updated_by, + revision_created_by: row.revision_created_by, + }) + .collect(), + )) +} + +/// Create a Delphi rule and its first revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + request_body = WriteDelphiRule, + responses((status = OK, body = DelphiRule)) +)] +#[post("/rules")] +pub async fn create_rule( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + let rule = body.into_inner().validate()?; + let user_id = user.id.0 as i64; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("failed to begin delphi rule transaction")?; + + let row = sqlx::query!( + r#" + INSERT INTO delphi_rules ( + name, + priority, + created_by, + updated_by + ) + VALUES ($1, $2, $3, $3) + RETURNING id, name, priority, created, updated, created_by, updated_by + "#, + rule.name, + rule.priority, + user_id, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("failed to create delphi rule")?; + + let revision = sqlx::query!( + r#" + INSERT INTO delphi_rule_revisions (rule_id, expression, created_by) + VALUES ($1, $2, $3) + RETURNING id, expression, created, created_by + "#, + row.id, + rule.expression, + user_id, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("failed to create delphi rule revision")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit delphi rule transaction")?; + + Ok(web::Json(DelphiRule { + id: row.id, + name: row.name, + priority: row.priority, + expression: revision.expression, + revision_id: revision.id, + created: row.created, + updated: row.updated, + revision_created: revision.created, + created_by: row.created_by, + updated_by: row.updated_by, + revision_created_by: revision.created_by, + })) +} + +/// Replace a Delphi rule and create a new current revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + request_body = WriteDelphiRule, + responses((status = OK, body = DelphiRule), (status = NOT_FOUND)) +)] +#[put("/rules/{id}")] +pub async fn update_rule( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(i64,)>, + body: web::Json, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + let (id,) = path.into_inner(); + let rule = body.into_inner().validate()?; + let user_id = user.id.0 as i64; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("failed to begin delphi rule transaction")?; + + let row = sqlx::query!( + r#" + UPDATE delphi_rules + SET + name = $2, + priority = $3, + updated = CURRENT_TIMESTAMP, + updated_by = $4 + WHERE id = $1 + RETURNING id, name, priority, created, updated, created_by, updated_by + "#, + id, + rule.name, + rule.priority, + user_id, + ) + .fetch_optional(&mut transaction) + .await + .wrap_internal_err("failed to update delphi rule")? + .ok_or(ApiError::NotFound)?; + + sqlx::query!( + r#" + UPDATE delphi_rule_revisions + SET active = FALSE + WHERE rule_id = $1 AND active + "#, + id, + ) + .execute(&mut transaction) + .await + .wrap_internal_err("failed to deactivate delphi rule revision")?; + + let revision = sqlx::query!( + r#" + INSERT INTO delphi_rule_revisions (rule_id, expression, created_by) + VALUES ($1, $2, $3) + RETURNING id, expression, created, created_by + "#, + id, + rule.expression, + user_id, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("failed to create delphi rule revision")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit delphi rule transaction")?; + + Ok(web::Json(DelphiRule { + id: row.id, + name: row.name, + priority: row.priority, + expression: revision.expression, + revision_id: revision.id, + created: row.created, + updated: row.updated, + revision_created: revision.created, + created_by: row.created_by, + updated_by: row.updated_by, + revision_created_by: revision.created_by, + })) +} + +/// Delete a Delphi rule and all its revisions and materialized effects. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK), (status = NOT_FOUND)) +)] +#[delete("/rules/{id}")] +pub async fn delete_rule( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(i64,)>, +) -> Result<(), ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + let (id,) = path.into_inner(); + + let deleted = sqlx::query!( + "DELETE FROM delphi_rules WHERE id = $1 RETURNING id", + id, + ) + .fetch_optional(&**pool) + .await + .wrap_internal_err("failed to delete delphi rule")?; + + if deleted.is_none() { + return Err(ApiError::NotFound); + } + + Ok(()) +} diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts index 0012474334..ef2af98c29 100644 --- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts +++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts @@ -6,6 +6,54 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { return 'labrinth_tech_review_internal' } + public async getRules(): Promise { + return this.client.request( + '/moderation/tech-review/rules', + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async createRule( + rule: Labrinth.TechReview.Internal.WriteDelphiRule, + ): Promise { + return this.client.request( + '/moderation/tech-review/rules', + { + api: 'labrinth', + version: 'internal', + method: 'POST', + body: rule, + }, + ) + } + + public async updateRule( + id: number, + rule: Labrinth.TechReview.Internal.WriteDelphiRule, + ): Promise { + return this.client.request( + `/moderation/tech-review/rules/${id}`, + { + api: 'labrinth', + version: 'internal', + method: 'PUT', + body: rule, + }, + ) + } + + public async deleteRule(id: number): Promise { + return this.client.request(`/moderation/tech-review/rules/${id}`, { + api: 'labrinth', + version: 'internal', + method: 'DELETE', + }) + } + /** * Search for projects awaiting technical review. * diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index cbe00e4f90..4c586cb44f 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2226,6 +2226,26 @@ export namespace Labrinth { export namespace TechReview { export namespace Internal { + export type DelphiRule = { + id: number + name: string + priority: number + expression: string + revision_id: number + created: string + updated: string + revision_created: string + created_by: number | null + updated_by: number | null + revision_created_by: number | null + } + + export type WriteDelphiRule = { + name: string + priority: number + expression: string + } + export type SearchProjectsRequest = { limit?: number page?: number diff --git a/packages/delphi-cel-experiment/Cargo.toml b/packages/delphi-cel-experiment/Cargo.toml new file mode 100644 index 0000000000..34ea4460b0 --- /dev/null +++ b/packages/delphi-cel-experiment/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "delphi-cel-experiment" +version = "0.1.0" +edition.workspace = true +publish = false + +[dependencies] +cel = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/packages/delphi-cel-experiment/src/main.rs b/packages/delphi-cel-experiment/src/main.rs new file mode 100644 index 0000000000..92fe06bd33 --- /dev/null +++ b/packages/delphi-cel-experiment/src/main.rs @@ -0,0 +1,173 @@ +use std::{collections::BTreeMap, error::Error, io}; + +use cel::{Context, Program}; +use serde::{Deserialize, Serialize}; + +const EFFECT_RULE: &str = r#" + input.trace.issue_type == "OBFUSCATED_NAMES" + && input.trace.severity == "high" + && "confidence" in input.trace.data + && input.trace.data.confidence >= 0.9 + ? { + "severity": "low", + "hidden": false + } + : null +"#; + +#[derive(Debug, Serialize)] +struct RuleInput { + schema_version: u32, + trace: TraceInput, + scan: ScanInput, + artifact: ArtifactInput, + scope: ScopeInput, +} + +#[derive(Debug, Serialize)] +struct TraceInput { + key: String, + issue_type: String, + severity: DelphiSeverity, + jar: Option, + file_path: String, + data: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct ScanInput { + delphi_version: i32, +} + +#[derive(Debug, Serialize)] +struct ArtifactInput { + size: u32, + hashes: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct ScopeInput { + project_id: String, + version_id: String, + file_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum DelphiSeverity { + Low, + Medium, + High, + Severe, +} + +#[derive(Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleEffect { + #[serde(default)] + severity: Option, + #[serde(default)] + hidden: bool, +} + +fn main() -> Result<(), Box> { + let input = example_input(); + println!( + "CEL input context:\n{}", + serde_json::to_string_pretty(&input)? + ); + + let effect = evaluate_rule(EFFECT_RULE, &input)?; + println!("\nCEL map decoded as RuleEffect:\n{effect:#?}"); + + Ok(()) +} + +fn example_input() -> RuleInput { + RuleInput { + schema_version: 1, + trace: TraceInput { + key: "known-safe:obfuscated-bootstrap".to_string(), + issue_type: "OBFUSCATED_NAMES".to_string(), + severity: DelphiSeverity::High, + jar: Some("META-INF/jars/embedded.jar".to_string()), + file_path: "com/example/Bootstrap.class".to_string(), + data: BTreeMap::from([ + ("confidence".to_string(), serde_json::json!(0.97)), + ("symbol_count".to_string(), serde_json::json!(42)), + ]), + }, + scan: ScanInput { delphi_version: 17 }, + artifact: ArtifactInput { + size: 412_892, + hashes: BTreeMap::from([ + ("sha1".to_string(), "0123456789abcdef".to_string()), + ("sha512".to_string(), "fedcba9876543210".to_string()), + ]), + }, + scope: ScopeInput { + project_id: "AANobbMI".to_string(), + version_id: "IIJJKKLL".to_string(), + file_id: "XXYYZZ00".to_string(), + }, + } +} + +fn evaluate_rule( + expression: &str, + input: &RuleInput, +) -> Result, Box> { + let mut context = Context::default(); + context.add_variable("input", input)?; + + let value = Program::compile(expression)?.execute(&context)?; + let json = value + .json() + .map_err(|error| invalid_data(error.to_string()))?; + match json { + serde_json::Value::Null => Ok(None), + value => Ok(Some(serde_json::from_value(value)?)), + } +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_a_map_as_an_effect() { + let effect = evaluate_rule(EFFECT_RULE, &example_input()) + .expect("rule should evaluate"); + + assert_eq!( + effect, + Some(RuleEffect { + severity: Some(DelphiSeverity::Low), + hidden: false, + }) + ); + } + + #[test] + fn returns_none_when_a_rule_does_not_match() { + let mut input = example_input(); + input.trace.issue_type = "NETWORK_ACCESS".to_string(); + + let effect = + evaluate_rule(EFFECT_RULE, &input).expect("rule should evaluate"); + + assert_eq!(effect, None); + } + + #[test] + fn rejects_unknown_map_fields() { + let error = evaluate_rule(r#"{"unknown": true}"#, &example_input()) + .expect_err("unknown fields should be rejected"); + + assert!(error.to_string().contains("unknown field")); + } +} From b6b19e543c41c47f7411d7557b587330d05c759e Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:03 +0100 Subject: [PATCH 2/7] add demo delphi rules ui --- .../moderation/technical-review/rules.vue | 256 +++++++++-- ...36eb1dd626e77331d8278c406df952691be4c.json | 22 - ...67a2296efc16763cdbd5d8c295a1a7ca08021.json | 12 - ...f4eeff66ab4165a9f4980032e114db4dc1286.json | 26 -- ...63ce02ca16781e32232ae0fa7a0af1973d3a4.json | 20 - ...1078cb48ccd645f059e257cd204de024955b8.json | 14 - ...b8c14fe329deac983bc2f43ba74e066f9cf8d.json | 14 - ...8518f1272690ce7766dbdc41ec1e98f7f1fec.json | 12 - ...78df08d25844f1019d539712b0cb686e4ae81.json | 16 - ...2dea4a6c830ec1b74ea1e2b71d5f18751ac6c.json | 62 +++ ...66908ea41a06ba858dc5b523aef6aae27b850.json | 15 - ...19dc0cdef502935dd5939a2f3ab89b0766ad2.json | 12 - ...58541fc78e910111c09ba3d50a12d9ca4a9f8.json | 16 - ...71aa078119ed2c60828683f872321fb61bb79.json | 23 + ...6f7de5ddc18e2014470d2b88626a9b37d30fe.json | 42 -- ...1a31da1885cc22bb61be6853950ba52be9aa7.json | 15 - ...70a71b32d7fe35bf5f11654e1653c63245078.json | 80 ---- ...37aa1023b436d06b4412a559553dd5452029e.json | 22 - ...d2402f52fea71e27b08e7926fcc2a9e62c0f3.json | 20 - ...2c36b9db3770508be4acbb6b441ed8006d9c7.json | 12 - ...afedb074492b4ec7f2457c14113f5fd13aa02.json | 18 - ...e5c93783c7641b019fdb698a1ec0be1393606.json | 17 - ...52f7ef678bd0d6b9cee0be7df36bdc92b80a.json} | 28 +- ...6540f5b6d265c87af267be0a5c0044d470a9.json} | 28 +- ...0d934b9afc6dccc5fc77454fbe45365bdf429.json | 20 - ...86e8917cfab9679d5064074681663ba467e41.json | 22 - .../20260715120000_delphi_rules.sql | 50 --- .../20260720105358_delphi_trace_rules.sql | 34 ++ apps/labrinth/src/routes/internal/mod.rs | 1 + .../internal/moderation/tech_review/rules.rs | 413 +++++++++++------- .../modules/labrinth/tech-review/internal.ts | 14 + .../api-client/src/modules/labrinth/types.ts | 37 +- 32 files changed, 685 insertions(+), 708 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json delete mode 100644 apps/labrinth/.sqlx/query-0ffebf149d50149d0c128478e6267a2296efc16763cdbd5d8c295a1a7ca08021.json delete mode 100644 apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json delete mode 100644 apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json delete mode 100644 apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json delete mode 100644 apps/labrinth/.sqlx/query-329c009635ad2a4c214da2f4651b8c14fe329deac983bc2f43ba74e066f9cf8d.json delete mode 100644 apps/labrinth/.sqlx/query-38ace0abad724d1111b79a72c7c8518f1272690ce7766dbdc41ec1e98f7f1fec.json delete mode 100644 apps/labrinth/.sqlx/query-5cb65218a2a4a7343130d838f8a78df08d25844f1019d539712b0cb686e4ae81.json create mode 100644 apps/labrinth/.sqlx/query-64d02e0fa69fe1cad91d851e4d32dea4a6c830ec1b74ea1e2b71d5f18751ac6c.json delete mode 100644 apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json delete mode 100644 apps/labrinth/.sqlx/query-66868d0b35229abffa3f668eaef19dc0cdef502935dd5939a2f3ab89b0766ad2.json delete mode 100644 apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json create mode 100644 apps/labrinth/.sqlx/query-7b4ee9c2fb7ef8da9bce4d86d4271aa078119ed2c60828683f872321fb61bb79.json delete mode 100644 apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json delete mode 100644 apps/labrinth/.sqlx/query-8ca7f46618bd72cfd5fe1f246d41a31da1885cc22bb61be6853950ba52be9aa7.json delete mode 100644 apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json delete mode 100644 apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json delete mode 100644 apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json delete mode 100644 apps/labrinth/.sqlx/query-c65295f61b18f7be4fdf30b3e2c2c36b9db3770508be4acbb6b441ed8006d9c7.json delete mode 100644 apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json delete mode 100644 apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json rename apps/labrinth/.sqlx/{query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json => query-e522b3da5f03cafd265929a8489252f7ef678bd0d6b9cee0be7df36bdc92b80a.json} (58%) rename apps/labrinth/.sqlx/{query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json => query-f5b43baf6838a16554aab7282a396540f5b6d265c87af267be0a5c0044d470a9.json} (57%) delete mode 100644 apps/labrinth/.sqlx/query-fabba52b34e14196b6305671c060d934b9afc6dccc5fc77454fbe45365bdf429.json delete mode 100644 apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json delete mode 100644 apps/labrinth/migrations/20260715120000_delphi_rules.sql create mode 100644 apps/labrinth/migrations/20260720105358_delphi_trace_rules.sql diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 1a9ac93c98..3668b93201 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -1,34 +1,106 @@