From ea04a3ad40f920a35c20ba45cfa32886c4478c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Tue, 19 Aug 2025 01:04:42 +0200 Subject: [PATCH 1/6] chore: fix typo in status message --- apps/frontend/nuxt.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/nuxt.config.ts b/apps/frontend/nuxt.config.ts index ab532a2057..1c1d4b02cc 100644 --- a/apps/frontend/nuxt.config.ts +++ b/apps/frontend/nuxt.config.ts @@ -149,7 +149,7 @@ export default defineNuxtConfig({ (state.errors ?? []).length === 0 ) { console.log( - 'Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.', + 'Tags already recently generated. Delete apps/src/frontend/generated/state.json to force regeneration.', ) return } From da4d3f9ab1e97567daca1881a675e838101667fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Wed, 20 Aug 2025 02:07:41 +0200 Subject: [PATCH 2/6] chore(env): use proper, but still invalid Delphi URL for sample envs This provides better error messages. --- apps/labrinth/.env.docker-compose | 2 +- apps/labrinth/.env.local | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 9644111a97..84a927ade6 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -123,7 +123,7 @@ PYRO_API_KEY=none BREX_API_URL=https://platform.brexapis.com/v2/ BREX_API_KEY=none -DELPHI_URL=none +DELPHI_URL=http://labrinth-delphi:8001 DELPHI_SLACK_WEBHOOK=none ARCHON_URL=none diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index aa07fa2069..54eedbfd31 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -123,7 +123,7 @@ PYRO_API_KEY=none BREX_API_URL=https://platform.brexapis.com/v2/ BREX_API_KEY=none -DELPHI_URL=none +DELPHI_URL=http://delphi:8001 DELPHI_SLACK_WEBHOOK=none ARCHON_URL=none From bf72dd47674a755f813c0ab8b6dcaae762bc14dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Mon, 18 Aug 2025 23:25:24 +0200 Subject: [PATCH 3/6] feat(labrinth): overhaul malware scanner report storage and routes --- ...bc457a08e70dcde320c6852074819e41f8ad9.json | 24 ++ ...f9530c311eef084abb6fce35de5f37d79bcea.json | 34 ++ ...724e9a4d5b9765d52305f99f859f939c2e854.json | 63 ++++ ...3153f5e9796b55ae753ab57b14f37708b400d.json | 24 ++ ...d0a1658c6ddf7a486082cdb847fab06150328.json | 164 +++++++++ ...5d818fde0499d8e5a08e9e22bee42014877f3.json | 20 ++ .../20250810155316_delphi-reports.sql | 64 ++++ .../src/database/models/delphi_report_item.rs | 334 ++++++++++++++++++ apps/labrinth/src/database/models/ids.rs | 31 +- apps/labrinth/src/database/models/mod.rs | 1 + .../src/database/models/version_item.rs | 10 + apps/labrinth/src/routes/internal/admin.rs | 103 +----- apps/labrinth/src/routes/internal/delphi.rs | 265 ++++++++++++++ apps/labrinth/src/routes/internal/mod.rs | 4 +- apps/labrinth/src/routes/mod.rs | 4 + .../src/routes/v3/project_creation.rs | 4 - .../src/routes/v3/version_creation.rs | 44 +-- 17 files changed, 1032 insertions(+), 161 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-0080a101c9ae040adbaadf9e46fbc457a08e70dcde320c6852074819e41f8ad9.json create mode 100644 apps/labrinth/.sqlx/query-0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea.json create mode 100644 apps/labrinth/.sqlx/query-10a332091be118f580d50ceb7a8724e9a4d5b9765d52305f99f859f939c2e854.json create mode 100644 apps/labrinth/.sqlx/query-8f1f75d9c52a5a340aae2b3fd863153f5e9796b55ae753ab57b14f37708b400d.json create mode 100644 apps/labrinth/.sqlx/query-c1cd83ddcd112e46477a195e8bed0a1658c6ddf7a486082cdb847fab06150328.json create mode 100644 apps/labrinth/.sqlx/query-fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3.json create mode 100644 apps/labrinth/migrations/20250810155316_delphi-reports.sql create mode 100644 apps/labrinth/src/database/models/delphi_report_item.rs create mode 100644 apps/labrinth/src/routes/internal/delphi.rs diff --git a/apps/labrinth/.sqlx/query-0080a101c9ae040adbaadf9e46fbc457a08e70dcde320c6852074819e41f8ad9.json b/apps/labrinth/.sqlx/query-0080a101c9ae040adbaadf9e46fbc457a08e70dcde320c6852074819e41f8ad9.json new file mode 100644 index 0000000000..37dcad2943 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0080a101c9ae040adbaadf9e46fbc457a08e70dcde320c6852074819e41f8ad9.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO delphi_report_issue_java_classes (issue_id, internal_class_name, decompiled_source)\n VALUES ($1, $2, $3)\n ON CONFLICT (issue_id, internal_class_name) DO UPDATE SET decompiled_source = $3\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0080a101c9ae040adbaadf9e46fbc457a08e70dcde320c6852074819e41f8ad9" +} diff --git a/apps/labrinth/.sqlx/query-0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea.json b/apps/labrinth/.sqlx/query-0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea.json new file mode 100644 index 0000000000..6f7b991949 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n version_id AS \"version_id: crate::database::models::DBVersionId\",\n versions.mod_id AS \"project_id: crate::database::models::DBProjectId\",\n files.url AS \"url\"\n FROM files INNER JOIN versions ON files.version_id = versions.id\n WHERE files.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id: crate::database::models::DBVersionId", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "project_id: crate::database::models::DBProjectId", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "url", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea" +} diff --git a/apps/labrinth/.sqlx/query-10a332091be118f580d50ceb7a8724e9a4d5b9765d52305f99f859f939c2e854.json b/apps/labrinth/.sqlx/query-10a332091be118f580d50ceb7a8724e9a4d5b9765d52305f99f859f939c2e854.json new file mode 100644 index 0000000000..963ea430b4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-10a332091be118f580d50ceb7a8724e9a4d5b9765d52305f99f859f939c2e854.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO delphi_report_issues (report_id, issue_type, status)\n VALUES ($1, $2, $3)\n ON CONFLICT (report_id, issue_type) DO UPDATE SET status = $3\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + { + "Custom": { + "name": "delphi_report_issue_type", + "kind": { + "Enum": [ + "reflection_indirection", + "xor_obfuscation", + "included_libraries", + "suspicious_binaries", + "corrupt_classes", + "suspicious_classes", + "url_usage", + "classloader_usage", + "processbuilder_usage", + "runtime_exec_usage", + "jni_usage", + "main_method", + "native_loading", + "malformed_jar", + "nested_jar_too_deep", + "failed_decompilation", + "analysis_failure", + "malware_easyforme", + "malware_simplyloader", + "unknown" + ] + } + } + }, + { + "Custom": { + "name": "delphi_report_issue_status", + "kind": { + "Enum": [ + "pending", + "approved", + "rejected" + ] + } + } + } + ] + }, + "nullable": [ + false + ] + }, + "hash": "10a332091be118f580d50ceb7a8724e9a4d5b9765d52305f99f859f939c2e854" +} diff --git a/apps/labrinth/.sqlx/query-8f1f75d9c52a5a340aae2b3fd863153f5e9796b55ae753ab57b14f37708b400d.json b/apps/labrinth/.sqlx/query-8f1f75d9c52a5a340aae2b3fd863153f5e9796b55ae753ab57b14f37708b400d.json new file mode 100644 index 0000000000..de31a078f0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8f1f75d9c52a5a340aae2b3fd863153f5e9796b55ae753ab57b14f37708b400d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO delphi_reports (file_id, delphi_version, artifact_url)\n VALUES ($1, $2, $3)\n ON CONFLICT (file_id, delphi_version) DO UPDATE SET\n delphi_version = $2, artifact_url = $3, created = CURRENT_TIMESTAMP\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int4", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8f1f75d9c52a5a340aae2b3fd863153f5e9796b55ae753ab57b14f37708b400d" +} diff --git a/apps/labrinth/.sqlx/query-c1cd83ddcd112e46477a195e8bed0a1658c6ddf7a486082cdb847fab06150328.json b/apps/labrinth/.sqlx/query-c1cd83ddcd112e46477a195e8bed0a1658c6ddf7a486082cdb847fab06150328.json new file mode 100644 index 0000000000..54969cea41 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c1cd83ddcd112e46477a195e8bed0a1658c6ddf7a486082cdb847fab06150328.json @@ -0,0 +1,164 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n delphi_report_issues.id AS \"id\", report_id,\n issue_type AS \"issue_type: DelphiReportIssueType\",\n delphi_report_issues.status as \"status: DelphiReportIssueStatus\",\n\n file_id, delphi_version, artifact_url, created,\n json_array(SELECT to_jsonb(delphi_report_issue_java_classes)\n FROM delphi_report_issue_java_classes\n WHERE issue_id = delphi_report_issues.id\n ) AS \"classes: sqlx::types::Json>\",\n versions.mod_id AS \"project_id?\", mods.published AS \"project_published?\"\n FROM delphi_report_issues\n INNER JOIN delphi_reports ON delphi_reports.id = report_id\n LEFT OUTER JOIN files ON files.id = file_id\n LEFT OUTER JOIN versions ON versions.id = files.version_id\n LEFT OUTER JOIN mods ON mods.id = versions.mod_id\n WHERE\n (issue_type = $1 OR $1 IS NULL)\n AND (delphi_report_issues.status = $2 OR $2 IS NULL)\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'pending_status_first' THEN delphi_report_issues.status ELSE 'pending'::delphi_report_issue_status END ASC\n OFFSET $5\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "report_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "issue_type: DelphiReportIssueType", + "type_info": { + "Custom": { + "name": "delphi_report_issue_type", + "kind": { + "Enum": [ + "reflection_indirection", + "xor_obfuscation", + "included_libraries", + "suspicious_binaries", + "corrupt_classes", + "suspicious_classes", + "url_usage", + "classloader_usage", + "processbuilder_usage", + "runtime_exec_usage", + "jni_usage", + "main_method", + "native_loading", + "malformed_jar", + "nested_jar_too_deep", + "failed_decompilation", + "analysis_failure", + "malware_easyforme", + "malware_simplyloader", + "unknown" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "status: DelphiReportIssueStatus", + "type_info": { + "Custom": { + "name": "delphi_report_issue_status", + "kind": { + "Enum": [ + "pending", + "approved", + "rejected" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "file_id", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "delphi_version", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "artifact_url", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "classes: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "project_id?", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "project_published?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "delphi_report_issue_type", + "kind": { + "Enum": [ + "reflection_indirection", + "xor_obfuscation", + "included_libraries", + "suspicious_binaries", + "corrupt_classes", + "suspicious_classes", + "url_usage", + "classloader_usage", + "processbuilder_usage", + "runtime_exec_usage", + "jni_usage", + "main_method", + "native_loading", + "malformed_jar", + "nested_jar_too_deep", + "failed_decompilation", + "analysis_failure", + "malware_easyforme", + "malware_simplyloader", + "unknown" + ] + } + } + }, + { + "Custom": { + "name": "delphi_report_issue_status", + "kind": { + "Enum": [ + "pending", + "approved", + "rejected" + ] + } + } + }, + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + null, + true, + true + ] + }, + "hash": "c1cd83ddcd112e46477a195e8bed0a1658c6ddf7a486082cdb847fab06150328" +} diff --git a/apps/labrinth/.sqlx/query-fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3.json b/apps/labrinth/.sqlx/query-fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3.json new file mode 100644 index 0000000000..38db606828 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT MAX(delphi_version) FROM delphi_reports", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "max", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3" +} diff --git a/apps/labrinth/migrations/20250810155316_delphi-reports.sql b/apps/labrinth/migrations/20250810155316_delphi-reports.sql new file mode 100644 index 0000000000..4bc15e705b --- /dev/null +++ b/apps/labrinth/migrations/20250810155316_delphi-reports.sql @@ -0,0 +1,64 @@ +CREATE TYPE delphi_report_issue_status AS ENUM ('pending', 'approved', 'rejected'); + +CREATE TYPE delphi_report_issue_type AS ENUM ( + 'reflection_indirection', + 'xor_obfuscation', + 'included_libraries', + 'suspicious_binaries', + 'corrupt_classes', + 'suspicious_classes', + 'url_usage', + 'classloader_usage', + 'processbuilder_usage', + 'runtime_exec_usage', + 'jni_usage', + 'main_method', + 'native_loading', + 'malformed_jar', + 'nested_jar_too_deep', + 'failed_decompilation', + 'analysis_failure', + 'malware_easyforme', + 'malware_simplyloader', + 'unknown' +); + +-- A Delphi analysis report for a project version +CREATE TABLE delphi_reports ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + file_id BIGINT REFERENCES files (id) + ON DELETE SET NULL + ON UPDATE CASCADE, + delphi_version INTEGER NOT NULL, + artifact_url VARCHAR(2048) NOT NULL, + created TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + UNIQUE (file_id, delphi_version) +); +CREATE INDEX delphi_version ON delphi_reports (delphi_version); + +-- An issue found in a Delphi report. Every issue belongs to a report, +-- and a report can have zero, one, or more issues attached to it +CREATE TABLE delphi_report_issues ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + report_id BIGINT NOT NULL REFERENCES delphi_reports (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + issue_type DELPHI_REPORT_ISSUE_TYPE NOT NULL, + status DELPHI_REPORT_ISSUE_STATUS NOT NULL, + UNIQUE (report_id, issue_type) +); +CREATE INDEX delphi_report_issue_by_status_and_type ON delphi_report_issues (status, issue_type); + +-- A Java class affected by a Delphi report issue. Every affected +-- Java class belongs to a specific issue, and an issue can have zero, +-- one, or more affected classes. (Some issues may be artifact-wide, +-- or otherwise not really specific to any particular class.) +CREATE TABLE delphi_report_issue_java_classes ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + issue_id BIGINT NOT NULL REFERENCES delphi_report_issues (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + internal_class_name TEXT NOT NULL, + decompiled_source TEXT NOT NULL, + UNIQUE (issue_id, internal_class_name) +); diff --git a/apps/labrinth/src/database/models/delphi_report_item.rs b/apps/labrinth/src/database/models/delphi_report_item.rs new file mode 100644 index 0000000000..8d83bc7dd2 --- /dev/null +++ b/apps/labrinth/src/database/models/delphi_report_item.rs @@ -0,0 +1,334 @@ +use std::{ + fmt::{self, Display, Formatter}, + ops::Deref, +}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::database::models::{ + DBFileId, DBProjectId, DatabaseError, DelphiReportId, DelphiReportIssueId, + DelphiReportIssueJavaClassId, +}; + +/// A Delphi malware analysis report for a project version file. +/// +/// Malware analysis reports usually belong to a specific project file, +/// but they can get orphaned if the versions they belong to are deleted. +/// Thus, deleting versions does not delete these reports. +#[derive(Serialize)] +pub struct DBDelphiReport { + pub id: DelphiReportId, + pub file_id: Option, + /// A sequential, monotonically increasing version number for the + /// Delphi version that generated this report. + pub delphi_version: i32, + pub artifact_url: String, + pub created: DateTime, +} + +impl DBDelphiReport { + pub async fn upsert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + Ok(DelphiReportId(sqlx::query_scalar!( + " + INSERT INTO delphi_reports (file_id, delphi_version, artifact_url) + VALUES ($1, $2, $3) + ON CONFLICT (file_id, delphi_version) DO UPDATE SET + delphi_version = $2, artifact_url = $3, created = CURRENT_TIMESTAMP + RETURNING id + ", + self.file_id as Option, + self.delphi_version, + self.artifact_url, + ) + .fetch_one(&mut **transaction) + .await?)) + } +} + +/// An issue found in a Delphi report. Every issue belongs to a report, +/// and a report can have zero, one, or more issues attached to it. +#[derive(Deserialize, Serialize)] +pub struct DBDelphiReportIssue { + pub id: DelphiReportIssueId, + pub report_id: DelphiReportId, + pub issue_type: DelphiReportIssueType, + pub status: DelphiReportIssueStatus, +} + +/// An status a Delphi report issue can have. +#[derive( + Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, +)] +#[serde(rename_all = "snake_case")] +#[sqlx(type_name = "delphi_report_issue_status")] +#[sqlx(rename_all = "snake_case")] +pub enum DelphiReportIssueStatus { + /// The issue is pending review by the moderation team. + Pending, + /// The issue has been approved (i.e., reviewed as a valid, true positive). + /// The affected artifact has thus been verified to be potentially malicious. + Approved, + /// The issue has been rejected (i.e., reviewed as a false positive). + /// The affected artifact has thus been verified to be clean, other issues + /// with it notwithstanding. + Rejected, +} + +impl Display for DelphiReportIssueStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.serialize(f) + } +} + +/// An order in which Delphi report issues can be sorted during queries. +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum DelphiReportListOrder { + CreatedAsc, + CreatedDesc, + PendingStatusFirst, +} + +impl Display for DelphiReportListOrder { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.serialize(f) + } +} + +/// A result returned from a Delphi report issue query, slightly +/// denormalized with related entity information for ease of +/// consumption by clients. +#[derive(Serialize)] +pub struct DelphiReportIssueResult { + pub issue: DBDelphiReportIssue, + pub report: DBDelphiReport, + pub java_classes: Vec, + pub project_id: Option, + pub project_published: Option>, +} + +impl DBDelphiReportIssue { + pub async fn upsert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + Ok(DelphiReportIssueId( + sqlx::query_scalar!( + " + INSERT INTO delphi_report_issues (report_id, issue_type, status) + VALUES ($1, $2, $3) + ON CONFLICT (report_id, issue_type) DO UPDATE SET status = $3 + RETURNING id + ", + self.report_id as DelphiReportId, + self.issue_type as DelphiReportIssueType, + self.status as DelphiReportIssueStatus, + ) + .fetch_one(&mut **transaction) + .await?, + )) + } + + pub async fn find_all_by( + ty: Option, + status: Option, + order_by: Option, + count: Option, + offset: Option, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + Ok(sqlx::query!( + r#" + SELECT + delphi_report_issues.id AS "id", report_id, + issue_type AS "issue_type: DelphiReportIssueType", + delphi_report_issues.status as "status: DelphiReportIssueStatus", + + file_id, delphi_version, artifact_url, created, + json_array(SELECT to_jsonb(delphi_report_issue_java_classes) + FROM delphi_report_issue_java_classes + WHERE issue_id = delphi_report_issues.id + ) AS "classes: sqlx::types::Json>", + versions.mod_id AS "project_id?", mods.published AS "project_published?" + FROM delphi_report_issues + INNER JOIN delphi_reports ON delphi_reports.id = report_id + LEFT OUTER JOIN files ON files.id = file_id + LEFT OUTER JOIN versions ON versions.id = files.version_id + LEFT OUTER JOIN mods ON mods.id = versions.mod_id + WHERE + (issue_type = $1 OR $1 IS NULL) + AND (delphi_report_issues.status = $2 OR $2 IS NULL) + ORDER BY + CASE WHEN $3 = 'created_asc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END ASC, + CASE WHEN $3 = 'created_desc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END DESC, + CASE WHEN $3 = 'pending_status_first' THEN delphi_report_issues.status ELSE 'pending'::delphi_report_issue_status END ASC + OFFSET $5 + LIMIT $4 + "#, + ty as Option, + status as Option, + order_by.map(|order_by| order_by.to_string()), + count.map(|count| count as i64), + offset, + ) + .map(|row| DelphiReportIssueResult { + issue: DBDelphiReportIssue { + id: DelphiReportIssueId(row.id), + report_id: DelphiReportId(row.report_id), + issue_type: row.issue_type, + status: row.status, + }, + report: DBDelphiReport { + id: DelphiReportId(row.report_id), + file_id: row.file_id.map(DBFileId), + delphi_version: row.delphi_version, + artifact_url: row.artifact_url, + created: row.created, + }, + java_classes: row + .classes + .into_iter() + .flat_map(|class_list| class_list.0) + .collect(), + project_id: row.project_id.map(DBProjectId), + project_published: row.project_published, + }) + .fetch_all(exec) + .await?) + } +} + +/// A type of issue found by Delphi for an artifact. +#[derive( + Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, +)] +#[serde(rename_all = "snake_case")] +#[sqlx(type_name = "delphi_report_issue_type")] +#[sqlx(rename_all = "snake_case")] +pub enum DelphiReportIssueType { + ReflectionIndirection, + XorObfuscation, + IncludedLibraries, + SuspiciousBinaries, + CorruptClasses, + SuspiciousClasses, + + UrlUsage, + ClassloaderUsage, + ProcessbuilderUsage, + RuntimeExecUsage, + #[serde(rename = "jni_usage")] + #[sqlx(rename = "jni_usage")] + JNIUsage, + + MainMethod, + NativeLoading, + + MalformedJar, + NestedJarTooDeep, + FailedDecompilation, + #[serde(alias = "ANALYSIS FAILURE!")] + AnalysisFailure, + + MalwareEasyforme, + MalwareSimplyloader, + + /// An issue reported by Delphi but not known by labrinth yet. + #[serde(other)] + Unknown, +} + +impl Display for DelphiReportIssueType { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.serialize(f) + } +} + +/// A Java class affected by a Delphi report issue. Every affected +/// Java class belongs to a specific issue, and an issue can have zero, +/// one, or more affected classes. (Some issues may be artifact-wide, +/// or otherwise not really specific to any particular class.) +#[derive(Debug, Deserialize, Serialize)] +pub struct DBDelphiReportIssueJavaClass { + pub id: DelphiReportIssueJavaClassId, + pub issue_id: DelphiReportIssueId, + pub internal_class_name: InternalJavaClassName, + pub decompiled_source: DecompiledJavaClassSource, +} + +impl DBDelphiReportIssueJavaClass { + pub async fn upsert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + Ok(DelphiReportIssueJavaClassId(sqlx::query_scalar!( + " + INSERT INTO delphi_report_issue_java_classes (issue_id, internal_class_name, decompiled_source) + VALUES ($1, $2, $3) + ON CONFLICT (issue_id, internal_class_name) DO UPDATE SET decompiled_source = $3 + RETURNING id + ", + self.issue_id as DelphiReportIssueId, + self.internal_class_name.0, + self.decompiled_source.0, + ) + .fetch_one(&mut **transaction) + .await?)) + } +} + +/// A [Java class name] with dots replaced by forward slashes (/). +/// +/// Because class names are usually the [binary names] passed to a classloader, top level interfaces and classes +/// have a binary name that matches its canonical, fully qualified name, such canonical names are prefixed by the +/// package path the class is in, and packages usually match the directory structure within a JAR for typical +/// classloaders, this usually (but not necessarily) corresponds to the path to the class file within its JAR. +/// +/// [Java class name]: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Class.html#getName() +/// [binary names]: https://docs.oracle.com/javase/specs/jls/se21/html/jls-13.html#jls-13.1 +#[derive( + Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash, sqlx::Type, +)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct InternalJavaClassName(String); + +impl Deref for InternalJavaClassName { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for InternalJavaClassName { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The decompiled source code of a Java class. +#[derive( + Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash, sqlx::Type, +)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct DecompiledJavaClassSource(String); + +impl Deref for DecompiledJavaClassSource { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for DecompiledJavaClassSource { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 795862cef2..668dfbc461 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -137,8 +137,8 @@ macro_rules! db_id_interface { }; } -macro_rules! short_id_type { - ($name:ident) => { +macro_rules! id_type { + ($name:ident as $type:ty) => { #[derive( Copy, Clone, @@ -151,7 +151,7 @@ macro_rules! short_id_type { Hash, )] #[sqlx(transparent)] - pub struct $name(pub i32); + pub struct $name(pub $type); }; } @@ -261,14 +261,17 @@ db_id_interface!( generator: generate_version_id @ "versions", ); -short_id_type!(CategoryId); -short_id_type!(GameId); -short_id_type!(LinkPlatformId); -short_id_type!(LoaderFieldEnumId); -short_id_type!(LoaderFieldEnumValueId); -short_id_type!(LoaderFieldId); -short_id_type!(LoaderId); -short_id_type!(NotificationActionId); -short_id_type!(ProjectTypeId); -short_id_type!(ReportTypeId); -short_id_type!(StatusId); +id_type!(CategoryId as i32); +id_type!(GameId as i32); +id_type!(LinkPlatformId as i32); +id_type!(LoaderFieldEnumId as i32); +id_type!(LoaderFieldEnumValueId as i32); +id_type!(LoaderFieldId as i32); +id_type!(LoaderId as i32); +id_type!(NotificationActionId as i32); +id_type!(ProjectTypeId as i32); +id_type!(ReportTypeId as i32); +id_type!(StatusId as i32); +id_type!(DelphiReportId as i64); +id_type!(DelphiReportIssueId as i64); +id_type!(DelphiReportIssueJavaClassId as i64); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 4ef40cf1c3..5d9956668a 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -3,6 +3,7 @@ use thiserror::Error; pub mod categories; pub mod charge_item; pub mod collection_item; +pub mod delphi_report_item; pub mod flow_item; pub mod friend_item; pub mod ids; diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index 0aae95b29f..46c34390fc 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{ }; use crate::database::redis::RedisPool; use crate::models::projects::{FileType, VersionStatus}; +use crate::routes::internal::delphi::DelphiRunParameters; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; use futures::TryStreamExt; @@ -164,6 +165,15 @@ impl VersionFileBuilder { .await?; } + if let Err(err) = crate::routes::internal::delphi::run( + &mut **transaction, + DelphiRunParameters { file_id }, + ) + .await + { + tracing::error!("Error submitting new file to Delphi: {err}"); + } + Ok(file_id) } } diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs index 3be7e013f3..945737f90c 100644 --- a/apps/labrinth/src/routes/internal/admin.rs +++ b/apps/labrinth/src/routes/internal/admin.rs @@ -1,13 +1,10 @@ use crate::auth::validate::get_user_record_from_bearer_token; -use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; use crate::models::analytics::Download; use crate::models::ids::ProjectId; use crate::models::pats::Scopes; -use crate::models::threads::MessageBody; use crate::queue::analytics::AnalyticsQueue; use crate::queue::maxmind::MaxMindIndexer; -use crate::queue::moderation::AUTOMOD_ID; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::SearchConfig; @@ -17,17 +14,14 @@ use actix_web::{HttpRequest, HttpResponse, patch, post, web}; use serde::Deserialize; use sqlx::PgPool; use std::collections::HashMap; -use std::fmt::Write; use std::net::Ipv4Addr; use std::sync::Arc; -use tracing::info; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("admin") .service(count_download) - .service(force_reindex) - .service(delphi_result_ingest), + .service(force_reindex), ); } @@ -163,98 +157,3 @@ pub async fn force_reindex( index_projects(pool.as_ref().clone(), redis.clone(), &config).await?; Ok(HttpResponse::NoContent().finish()) } - -#[derive(Deserialize)] -pub struct DelphiIngest { - pub url: String, - pub project_id: crate::models::ids::ProjectId, - pub version_id: crate::models::ids::VersionId, - pub issues: HashMap>, -} - -#[post("/_delphi", guard = "admin_key_guard")] -pub async fn delphi_result_ingest( - pool: web::Data, - redis: web::Data, - body: web::Json, -) -> Result { - if body.issues.is_empty() { - info!("No issues found for file {}", body.url); - return Ok(HttpResponse::NoContent().finish()); - } - - let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?; - - let project = crate::database::models::DBProject::get_id( - body.project_id.into(), - &**pool, - &redis, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Project {} does not exist", - body.project_id - )) - })?; - - let mut header = format!("Suspicious traces found at {}", body.url); - - for (issue, trace) in &body.issues { - for (path, code) in trace { - write!( - &mut header, - "\n issue {issue} found at file {path}: \n ```\n{code}\n```" - ) - .unwrap(); - } - } - - crate::util::webhook::send_slack_webhook( - body.project_id, - &pool, - &redis, - webhook_url, - Some(header), - ) - .await - .ok(); - - let mut thread_header = format!( - "Suspicious traces found at [version {}](https://modrinth.com/project/{}/version/{})", - body.version_id, body.project_id, body.version_id - ); - - for (issue, trace) in &body.issues { - for path in trace.keys() { - write!( - &mut thread_header, - "\n\n- issue {issue} found at file {path}" - ) - .unwrap(); - } - - if trace.is_empty() { - write!(&mut thread_header, "\n\n- issue {issue} found").unwrap(); - } - } - - let mut transaction = pool.begin().await?; - ThreadMessageBuilder { - author_id: Some(crate::database::models::DBUserId(AUTOMOD_ID)), - body: MessageBody::Text { - body: thread_header, - private: true, - replying_to: None, - associated_images: vec![], - }, - thread_id: project.thread_id, - hide_identity: false, - } - .insert(&mut transaction) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().finish()) -} diff --git a/apps/labrinth/src/routes/internal/delphi.rs b/apps/labrinth/src/routes/internal/delphi.rs new file mode 100644 index 0000000000..b111881824 --- /dev/null +++ b/apps/labrinth/src/routes/internal/delphi.rs @@ -0,0 +1,265 @@ +use std::{collections::HashMap, fmt::Write, io, sync::LazyLock}; + +use actix_web::{HttpResponse, get, post, put, web}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use sqlx::PgPool; +use tracing::info; + +use crate::{ + database::{ + models::{ + DBFileId, DelphiReportId, DelphiReportIssueId, + DelphiReportIssueJavaClassId, + delphi_report_item::{ + DBDelphiReport, DBDelphiReportIssue, + DBDelphiReportIssueJavaClass, DecompiledJavaClassSource, + DelphiReportIssueStatus, DelphiReportIssueType, + DelphiReportListOrder, InternalJavaClassName, + }, + }, + redis::RedisPool, + }, + routes::ApiError, + util::guards::admin_key_guard, +}; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("delphi") + .service(ingest_report) + .service(_run) + .service(version) + .service(issues) + .service(update_issue), + ); +} + +#[derive(Deserialize)] +struct DelphiReport { + pub url: String, + pub project_id: crate::models::ids::ProjectId, + #[serde(rename = "version_id")] + pub _version_id: crate::models::ids::VersionId, + pub file_id: crate::models::ids::FileId, + /// A sequential, monotonically increasing version number for the + /// Delphi version that generated this report. + pub delphi_version: i32, + pub issues: HashMap< + DelphiReportIssueType, + HashMap, + >, +} + +impl DelphiReport { + async fn send_to_slack( + &self, + pool: &PgPool, + redis: &RedisPool, + ) -> Result<(), ApiError> { + let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?; + + let mut message_header = + format!("⚠️ Suspicious traces found at {}", self.url); + + for (issue, trace) in &self.issues { + for (path, code) in trace { + write!( + &mut message_header, + "\n issue {issue} found at file {path}:\n```\n{code}\n```" + ) + .ok(); + } + } + + crate::util::webhook::send_slack_webhook( + self.project_id, + pool, + redis, + webhook_url, + Some(message_header), + ) + .await + } +} + +#[derive(Deserialize)] +pub struct DelphiRunParameters { + pub file_id: crate::database::models::ids::DBFileId, +} + +#[post("ingest", guard = "admin_key_guard")] +async fn ingest_report( + pool: web::Data, + redis: web::Data, + web::Json(report): web::Json, +) -> Result { + if report.issues.is_empty() { + info!("No issues found for file {}", report.url); + return Ok(HttpResponse::NoContent().finish()); + } + + report.send_to_slack(&pool, &redis).await.ok(); + + let mut transaction = pool.begin().await?; + + let report_id = DBDelphiReport { + id: DelphiReportId(0), // This will be set by the database + file_id: Some(DBFileId(report.file_id.0 as i64)), + delphi_version: report.delphi_version, + artifact_url: report.url.clone(), + created: DateTime::::MIN_UTC, // This will be set by the database + } + .upsert(&mut transaction) + .await?; + + for (issue_type, issue_java_classes) in report.issues { + let issue_id = DBDelphiReportIssue { + id: DelphiReportIssueId(0), // This will be set by the database + report_id, + issue_type, + status: DelphiReportIssueStatus::Pending, + } + .upsert(&mut transaction) + .await?; + + for (internal_class_name, decompiled_source) in issue_java_classes { + DBDelphiReportIssueJavaClass { + id: DelphiReportIssueJavaClassId(0), // This will be set by the database + issue_id, + internal_class_name, + decompiled_source, + } + .upsert(&mut transaction) + .await?; + } + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} + +pub async fn run( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + run_parameters: DelphiRunParameters, +) -> Result { + let file_data = sqlx::query!( + r#" + SELECT + version_id AS "version_id: crate::database::models::DBVersionId", + versions.mod_id AS "project_id: crate::database::models::DBProjectId", + files.url AS "url" + FROM files INNER JOIN versions ON files.version_id = versions.id + WHERE files.id = $1 + "#, + run_parameters.file_id.0 + ) + .fetch_one(exec) + .await?; + + static DELPHI_CLIENT: LazyLock = + LazyLock::new(reqwest::Client::new); + + tracing::debug!( + "Running Delphi for project {}, version {}, file {}", + file_data.project_id.0, + file_data.version_id.0, + run_parameters.file_id.0 + ); + + DELPHI_CLIENT + .post(dotenvy::var("DELPHI_URL")?) + .json(&serde_json::json!({ + "url": file_data.url, + "project_id": file_data.project_id, + "version_id": file_data.version_id, + "file_id": run_parameters.file_id, + })) + .send() + .await + .and_then(|res| res.error_for_status()) + .map_err(ApiError::Delphi)?; + + Ok(HttpResponse::NoContent().finish()) +} + +#[post("run", guard = "admin_key_guard")] +async fn _run( + pool: web::Data, + run_parameters: web::Query, +) -> Result { + run(&**pool, run_parameters.into_inner()).await +} + +#[get("version", guard = "admin_key_guard")] +async fn version(pool: web::Data) -> Result { + Ok(HttpResponse::Ok().json( + sqlx::query_scalar!("SELECT MAX(delphi_version) FROM delphi_reports") + .fetch_one(&**pool) + .await?, + )) +} + +#[derive(Deserialize)] +struct DelphiIssuesSearchOptions { + #[serde(rename = "type")] + ty: Option, + status: Option, + order_by: Option, + count: Option, + offset: Option, +} + +#[get("issues", guard = "admin_key_guard")] +async fn issues( + pool: web::Data, + search_options: web::Query, +) -> Result { + Ok(HttpResponse::Ok().json( + DBDelphiReportIssue::find_all_by( + search_options.ty, + search_options.status, + search_options.order_by, + search_options.count, + search_options + .offset + .map(|offset| offset.try_into()) + .transpose() + .map_err(|err| { + io::Error::other(format!("Invalid offset: {err}")) + })?, + &**pool, + ) + .await?, + )) +} + +#[put("issue/{issue_id}", guard = "admin_key_guard")] +async fn update_issue( + pool: web::Data, + issue_id: web::Path, + web::Json(update_data): web::Json, +) -> Result { + let new_id = issue_id.into_inner(); + + let mut transaction = pool.begin().await?; + + let modified_same_issue = (DBDelphiReportIssue { + id: new_id, // Doesn't matter, upsert done for values of other fields + report_id: update_data.report_id, + issue_type: update_data.issue_type, + status: update_data.status, + }) + .upsert(&mut transaction) + .await? + == new_id; + + transaction.commit().await?; + + if modified_same_issue { + Ok(HttpResponse::NoContent().finish()) + } else { + Ok(HttpResponse::Created().finish()) + } +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 3330ab13ef..74e5a2f83e 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod admin; pub mod billing; +pub mod delphi; pub mod flows; pub mod gdpr; pub mod medal; @@ -26,6 +27,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(billing::config) .configure(gdpr::config) .configure(statuses::config) - .configure(medal::config), + .configure(medal::config) + .configure(delphi::config), ); } diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index c637c79a09..9a6216dc8b 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -145,6 +145,8 @@ pub enum ApiError { RateLimitError(u128, u32), #[error("Error while interacting with payment processor: {0}")] Stripe(#[from] stripe::StripeError), + #[error("Error while interacting with Delphi: {0}")] + Delphi(reqwest::Error), } impl ApiError { @@ -179,6 +181,7 @@ impl ApiError { ApiError::Io(..) => "io_error", ApiError::RateLimitError(..) => "ratelimit_error", ApiError::Stripe(..) => "stripe_error", + ApiError::Delphi(..) => "delphi_error", }, description: self.to_string(), } @@ -216,6 +219,7 @@ impl actix_web::ResponseError for ApiError { ApiError::Io(..) => StatusCode::BAD_REQUEST, ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS, ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY, + ApiError::Delphi(..) => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index cc5c89b1e6..645c0e7e70 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -332,9 +332,6 @@ async fn project_create_inner( redis: &RedisPool, session_queue: &AuthQueue, ) -> Result { - // The base URL for files uploaded to S3 - let cdn_url = dotenvy::var("CDN_URL")?; - // The currently logged in user let current_user = get_user_from_headers( &req, @@ -566,7 +563,6 @@ async fn project_create_inner( uploaded_files, &mut created_version.files, &mut created_version.dependencies, - &cdn_url, &content_disposition, project_id, created_version.version_id.into(), diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index dd49340e26..9802563bf4 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -38,7 +38,6 @@ use sha1::Digest; use sqlx::postgres::PgPool; use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use tracing::error; use validator::Validate; fn default_requested_status() -> VersionStatus { @@ -158,8 +157,6 @@ async fn version_create_inner( session_queue: &AuthQueue, moderation_queue: &AutomatedModerationQueue, ) -> Result { - let cdn_url = dotenvy::var("CDN_URL")?; - let mut initial_version_data = None; let mut version_builder = None; let mut selected_loaders = None; @@ -355,7 +352,6 @@ async fn version_create_inner( uploaded_files, &mut version.files, &mut version.dependencies, - &cdn_url, &content_disposition, version.project_id.into(), version.version_id.into(), @@ -589,8 +585,6 @@ async fn upload_file_to_version_inner( version_id: models::DBVersionId, session_queue: &AuthQueue, ) -> Result { - let cdn_url = dotenvy::var("CDN_URL")?; - let mut initial_file_data: Option = None; let mut file_builders: Vec = Vec::new(); @@ -740,7 +734,6 @@ async fn upload_file_to_version_inner( uploaded_files, &mut file_builders, &mut dependencies, - &cdn_url, &content_disposition, project_id, version_id.into(), @@ -794,7 +787,6 @@ pub async fn upload_file( uploaded_files: &mut Vec, version_files: &mut Vec, dependencies: &mut Vec, - cdn_url: &str, content_disposition: &actix_web::http::header::ContentDisposition, project_id: ProjectId, version_id: VersionId, @@ -941,21 +933,17 @@ pub async fn upload_file( || force_primary || total_files_len == 1; - let file_path_encode = format!( - "data/{}/versions/{}/{}", - project_id, - version_id, + let file_path = format!( + "data/{project_id}/versions/{version_id}/{}", urlencoding::encode(file_name) ); - let file_path = - format!("data/{}/versions/{}/{}", project_id, version_id, &file_name); let upload_data = file_host .upload_file(content_type, &file_path, FileHostPublicity::Public, data) .await?; uploaded_files.push(UploadedFile { - name: file_path, + name: file_path.clone(), publicity: FileHostPublicity::Public, }); @@ -979,33 +967,9 @@ pub async fn upload_file( return Err(CreateError::InvalidInput(msg.to_string())); } - let url = format!("{cdn_url}/{file_path_encode}"); - - let client = reqwest::Client::new(); - let delphi_url = dotenvy::var("DELPHI_URL")?; - match client - .post(delphi_url) - .json(&serde_json::json!({ - "url": url, - "project_id": project_id, - "version_id": version_id, - })) - .send() - .await - { - Ok(res) => { - if !res.status().is_success() { - error!("Failed to upload file to Delphi: {url}"); - } - } - Err(e) => { - error!("Failed to upload file to Delphi: {url}: {e}"); - } - } - version_files.push(VersionFileBuilder { filename: file_name.to_string(), - url: format!("{cdn_url}/{file_path_encode}"), + url: format!("{}/{file_path}", dotenvy::var("CDN_URL")?), hashes: vec![ models::version_item::HashBuilder { algorithm: "sha1".to_string(), From 4283736a142801437c6a5ca636b64f670b480ea7 Mon Sep 17 00:00:00 2001 From: IMB11 Date: Fri, 22 Aug 2025 13:33:03 +0100 Subject: [PATCH 4/6] feat: start on run modal --- .../moderation/ModerationDelphiReportCard.vue | 182 ------- .../ui/moderation/delphi/RunDelphiModal.vue | 475 ++++++++++++++++++ .../moderation/technical-review-mockup.vue | 387 -------------- .../src/pages/moderation/technical-review.vue | 14 +- .../ui/src/components/base/Admonition.vue | 2 +- packages/utils/types.ts | 143 ++++-- 6 files changed, 600 insertions(+), 603 deletions(-) delete mode 100644 apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue create mode 100644 apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue delete mode 100644 apps/frontend/src/pages/moderation/technical-review-mockup.vue diff --git a/apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue b/apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue deleted file mode 100644 index 2b4dc2d562..0000000000 --- a/apps/frontend/src/components/ui/moderation/ModerationDelphiReportCard.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue b/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue new file mode 100644 index 0000000000..461de30e49 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue @@ -0,0 +1,475 @@ + + + diff --git a/apps/frontend/src/pages/moderation/technical-review-mockup.vue b/apps/frontend/src/pages/moderation/technical-review-mockup.vue deleted file mode 100644 index 95e4c1fbb3..0000000000 --- a/apps/frontend/src/pages/moderation/technical-review-mockup.vue +++ /dev/null @@ -1,387 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/moderation/technical-review.vue b/apps/frontend/src/pages/moderation/technical-review.vue index 3a5ae57552..32ee5c62c4 100644 --- a/apps/frontend/src/pages/moderation/technical-review.vue +++ b/apps/frontend/src/pages/moderation/technical-review.vue @@ -1,3 +1,15 @@ + + diff --git a/packages/ui/src/components/base/Admonition.vue b/packages/ui/src/components/base/Admonition.vue index 16cdf23cbf..b4f5886b5f 100644 --- a/packages/ui/src/components/base/Admonition.vue +++ b/packages/ui/src/components/base/Admonition.vue @@ -10,7 +10,7 @@ :class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]" />
-
+
{{ header }}
diff --git a/packages/utils/types.ts b/packages/utils/types.ts index 3d62b28a73..c2c312b87f 100644 --- a/packages/utils/types.ts +++ b/packages/utils/types.ts @@ -273,7 +273,7 @@ export interface VersionFileHash { } export interface VersionFile { - hashes: VersionFileHash[] + hashes: VersionFileHash url: string filename: string primary: boolean @@ -547,35 +547,114 @@ export type SubscriptionMetadata = // Delphi export interface DelphiReport { - id: string - project: Project - version: Version - priority_score: number - detected_at: string - trace_type: - | 'reflection_indirection' - | 'xor_obfuscation' - | 'included_libraries' - | 'suspicious_binaries' - | 'corrupt_classes' - | 'suspicious_classes' - | 'url_usage' - | 'classloader_usage' - | 'processbuilder_usage' - | 'runtime_exec_usage' - | 'jni_usage' - | 'main_method' - | 'native_loading' - | 'malformed_jar' - | 'nested_jar_too_deep' - | 'failed_decompilation' - | 'analysis_failure' - | 'malware_easyforme' - | 'malware_simplyloader' - file_path: string - // pending = not reviewed yet. - // approved = approved as malicious, removed from modrinth - // rejected = not approved as malicious, remains on modrinth? - status: 'pending' | 'approved' | 'rejected' - content?: string + id: number + file_id: number | null + delphi_version: number + artifact_url: string + created: string // ISO 8601 datetime string +} + +export interface DelphiReportIssue { + id: number + report_id: number + issue_type: DelphiReportIssueType + status: DelphiReportIssueStatus + severity: SeverityLevel // Added severity level +} + +export interface DelphiReportIssueJavaClass { + id: number + issue_id: number + internal_class_name: string + decompiled_source: string +} + +export interface DelphiReportIssueResult { + issue: DelphiReportIssue + report: DelphiReport + java_classes: DelphiReportIssueJavaClass[] + project_id: number | null + project_published: string | null // ISO 8601 datetime string +} + +export type DelphiReportIssueStatus = 'pending' | 'approved' | 'rejected' + +export type DelphiReportIssueType = + | 'reflection_indirection' + | 'xor_obfuscation' + | 'included_libraries' + | 'suspicious_binaries' + | 'corrupt_classes' + | 'suspicious_classes' + | 'url_usage' + | 'classloader_usage' + | 'processbuilder_usage' + | 'runtime_exec_usage' + | 'jni_usage' + | 'main_method' + | 'native_loading' + | 'malformed_jar' + | 'nested_jar_too_deep' + | 'failed_decompilation' + | 'analysis_failure' + | 'malware_easyforme' + | 'malware_simplyloader' + | 'unknown' + +export type DelphiReportListOrder = 'created_asc' | 'created_desc' | 'pending_status_first' + +// Request/Response types for API endpoints + +export interface DelphiIngestRequest { + url: string + project_id: string + version_id: string + file_id: string + delphi_version: number + issues: Record> // className -> decompiledSource +} + +export interface DelphiRunRequest { + file_id: number + parameters?: DelphiRunParameters // Added parameters for run request +} + +export interface DelphiIssuesSearchParams { + type?: DelphiReportIssueType + status?: DelphiReportIssueStatus + order_by?: DelphiReportListOrder + count?: number + offset?: number +} + +export interface DelphiUpdateIssueRequest { + report_id: number + issue_type: DelphiReportIssueType + status: DelphiReportIssueStatus +} + +export interface DelphiRunParameters { + query_type: 'projects' | 'time_range' | 'authors' | 'specific' + project_types?: string[] + date_range?: { start: string; end: string } + authors?: string[] + specific_projects?: string[] + ignore_scanned?: boolean +} + +export interface AllowlistEntry { + id: number + type: 'url' | 'trace' | 'class_path' + pattern: string + description: string + created: string + projects_affected: number +} + +export type SeverityLevel = 'critical' | 'high' | 'medium' | 'low' + +export interface ExtendedDelphiReportIssueResult extends DelphiReportIssueResult { + severity: SeverityLevel + fingerprint?: string + duplicate_count?: number } From abf88fe2b343881962e581965c5e0fc4c1b5126c Mon Sep 17 00:00:00 2001 From: IMB11 Date: Fri, 22 Aug 2025 13:35:59 +0100 Subject: [PATCH 5/6] fix: use internal: true --- .../src/components/ui/moderation/delphi/RunDelphiModal.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue b/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue index 461de30e49..539837b57f 100644 --- a/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue +++ b/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue @@ -195,10 +195,10 @@ async function confirmRunDelphi() { submitting.value = true try { - await useBaseFetch('internal/delphi/run', { + await useBaseFetch('delphi/run', { method: 'POST', - apiVersion: 2, body: { file_id: fileId.value }, + internal: true, }) notifications.addNotification({ From 704918e1f1e85038900544f88f5f2c3d150f45c0 Mon Sep 17 00:00:00 2001 From: IMB11 Date: Fri, 22 Aug 2025 16:42:33 +0100 Subject: [PATCH 6/6] fix: str file_id = sha512 --- .../components/ui/moderation/delphi/RunDelphiModal.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue b/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue index 539837b57f..d90eb7a8c2 100644 --- a/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue +++ b/apps/frontend/src/components/ui/moderation/delphi/RunDelphiModal.vue @@ -21,7 +21,7 @@ const notifications = injectNotificationManager() const selectedProject = ref(null) const selectedVersion = ref(null) const selectedFile = ref(null) -const fileId = ref(null) +const fileId = ref(null) const projectQuery = ref('') const projects = ref([]) @@ -116,7 +116,7 @@ function chooseVersion(version: Version) { function chooseFile(file: VersionFile) { selectedFile.value = file - fileId.value = Number(file.hashes.sha512) + fileId.value = file.hashes.sha512 setStep('review', true) } @@ -178,7 +178,7 @@ defineExpose({ show }) const emit = defineEmits<{ ( e: 'started', - payload: { fileId: number; project?: Project; version?: Version; file?: VersionFile }, + payload: { fileId: string; project?: Project; version?: Version; file?: VersionFile }, ): void (e: 'hide'): void }>() @@ -402,7 +402,7 @@ function formatRelativeTime(date?: string) { >