This commit is contained in:
aecsocket
2026-07-27 17:47:17 +01:00
parent 849943ed4e
commit b92fc10e54
4 changed files with 228 additions and 261 deletions
@@ -434,26 +434,62 @@ const RULE_EDITOR_OPTIONS: Partial<Ace.EditorOptions> = {
useSoftTabs: true, useSoftTabs: true,
} }
const TEST_TRACES: Labrinth.TechReview.Internal.TestDelphiRuleTrace[] = [ const TEST_INPUTS: Labrinth.TechReview.Internal.RuleInput[] = [
{ {
key: 'known-safe:obfuscated-bootstrap', schema_version: 1,
issue_type: 'OBFUSCATED_NAMES', trace: {
severity: 'high', key: 'known-safe:obfuscated-bootstrap',
jar: 'META-INF/jars/embedded.jar', issue_type: 'OBFUSCATED_NAMES',
file_path: 'com/example/Bootstrap.class', severity: 'high',
data: { jar: 'META-INF/jars/embedded.jar',
confidence: 0.97, file_path: 'com/example/Bootstrap.class',
symbol_count: 42, data: {
confidence: 0.97,
symbol_count: 42,
},
},
scan: {
delphi_version: 17,
},
artifact: {
size: 412_892,
hashes: {
sha1: '0123456789abcdef',
sha512: 'fedcba9876543210',
},
},
scope: {
project_id: 'example-project',
version_id: 'example-version',
file_id: 'example-file',
}, },
}, },
{ {
key: 'network/known-telemetry-host', schema_version: 1,
issue_type: 'SUSPICIOUS_NETWORK_ACCESS', trace: {
severity: 'medium', key: 'network/known-telemetry-host',
jar: null, issue_type: 'SUSPICIOUS_NETWORK_ACCESS',
file_path: 'com/example/Telemetry.class', severity: 'medium',
data: { jar: null,
host: 'telemetry.example.com', file_path: 'com/example/Telemetry.class',
data: {
host: 'telemetry.example.com',
},
},
scan: {
delphi_version: 18,
},
artifact: {
size: 98_304,
hashes: {
sha1: 'abcdef0123456789',
sha512: '0123456789abcdef',
},
},
scope: {
project_id: 'telemetry-project',
version_id: 'telemetry-version',
file_id: 'telemetry-file',
}, },
}, },
] ]
@@ -511,7 +547,7 @@ const ruleOutputSchemaText = computed(() =>
ruleSchema.value ? formatRuleSchema(ruleSchema.value.output, ruleSchema.value.components) : '', ruleSchema.value ? formatRuleSchema(ruleSchema.value.output, ruleSchema.value.components) : '',
) )
const previewExamples = computed(() => const previewExamples = computed(() =>
TEST_TRACES.map((original, index) => { TEST_INPUTS.map(({ trace: original }, index) => {
const effect = ruleTestEffects.value[index] ?? null const effect = ruleTestEffects.value[index] ?? null
const effectiveSeverity = effect?.severity ?? original.severity const effectiveSeverity = effect?.severity ?? original.severity
let summary: string let summary: string
@@ -665,7 +701,7 @@ async function testRule() {
try { try {
const response = await client.labrinth.tech_review_internal.testRule({ const response = await client.labrinth.tech_review_internal.testRule({
rule, rule,
traces: TEST_TRACES, inputs: TEST_INPUTS,
}) })
if (requestId !== ruleTestRequestId) return if (requestId !== ruleTestRequestId) return
@@ -1,14 +1,11 @@
use std::collections::{BTreeMap, HashMap};
use actix_web::{HttpRequest, delete, get, post, put, web}; use actix_web::{HttpRequest, delete, get, post, put, web};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use eyre::eyre; use eyre::eyre;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use validator::Validate;
use xredis::RedisPool; use xredis::RedisPool;
use super::rules_scan::{ use super::rules_scan::RuleInput;
RuleArtifact, RuleInput, RuleScan, RuleScope, RuleTrace,
};
use crate::{ use crate::{
auth::check_is_moderator_from_headers, auth::check_is_moderator_from_headers,
database::{ database::{
@@ -25,13 +22,9 @@ use crate::{
}, },
queue::session::AuthQueue, queue::session::AuthQueue,
routes::ApiError, routes::ApiError,
util::error::Context, util::{error::Context, validate::validation_errors_to_string},
}; };
const MAX_RULE_NAME_LENGTH: usize = 256;
const MAX_RULE_EXPRESSION_LENGTH: usize = 65_536;
const MAX_RULE_TEST_TRACES: usize = 10;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) { pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(get_rules) cfg.service(get_rules)
.service(test_rule) .service(test_rule)
@@ -75,28 +68,22 @@ pub struct DelphiRuleAffectedDetail {
pub hidden: bool, pub hidden: bool,
} }
#[derive(Debug, Deserialize, utoipa::ToSchema)] #[derive(Debug, Deserialize, Validate, utoipa::ToSchema)]
pub struct WriteDelphiRule { pub struct WriteDelphiRule {
#[validate(length(min = 1, max = 256))]
pub name: String, pub name: String,
#[validate(length(min = 1, max = 65536))]
pub rule: String, pub rule: String,
#[serde(default)] #[serde(default)]
pub priority: i32, pub priority: i32,
} }
#[derive(Debug, Deserialize, utoipa::ToSchema)] #[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct TestDelphiRule { pub struct TestDelphiRule {
#[validate(length(min = 1, max = 65536))]
pub rule: String, pub rule: String,
pub traces: Vec<TestDelphiRuleTrace>, #[validate(length(max = 10))]
} pub inputs: Vec<RuleInput>,
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct TestDelphiRuleTrace {
pub key: String,
pub issue_type: String,
pub severity: DelphiSeverity,
pub jar: Option<String>,
pub file_path: String,
pub data: HashMap<String, serde_json::Value>,
} }
#[derive(Debug, Serialize, utoipa::ToSchema)] #[derive(Debug, Serialize, utoipa::ToSchema)]
@@ -120,36 +107,24 @@ struct ValidatedRule {
} }
impl WriteDelphiRule { impl WriteDelphiRule {
fn validate(self) -> Result<ValidatedRule, ApiError> { async fn validate(mut self) -> Result<ValidatedRule, ApiError> {
let name = self.name.trim().to_string(); self.name = self.name.trim().to_string();
if name.is_empty() { self.rule = self.rule.trim().to_string();
return Err(ApiError::Request(eyre!("rule name cannot be empty"))); Validate::validate(&self).map_err(|error| {
} ApiError::Validation(validation_errors_to_string(error, None))
if name.chars().count() > MAX_RULE_NAME_LENGTH {
return Err(ApiError::Request(eyre!(
"rule name cannot exceed {MAX_RULE_NAME_LENGTH} characters"
)));
}
let rule = self.rule.trim().to_string();
if rule.is_empty() {
return Err(ApiError::Request(eyre!(
"rule expression cannot be empty"
)));
}
if rule.len() > MAX_RULE_EXPRESSION_LENGTH {
return Err(ApiError::Request(eyre!(
"rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes"
)));
}
cel::Program::compile(&rule).map_err(|error| {
ApiError::Request(eyre!("invalid cel expression: {error}"))
})?; })?;
let expression = self.rule.clone();
tokio::task::spawn_blocking(move || cel::Program::compile(&expression))
.await
.wrap_internal_err("failed to join cel compilation task")?
.map_err(|error| {
ApiError::Request(eyre!("invalid cel expression: {error}"))
})?;
Ok(ValidatedRule { Ok(ValidatedRule {
name, name: self.name,
rule, rule: self.rule,
priority: self.priority, priority: self.priority,
}) })
} }
@@ -180,35 +155,27 @@ pub async fn test_rule(
) )
.await?; .await?;
let request = body.into_inner(); let mut request = body.into_inner();
let rule = request.rule.trim(); request.rule = request.rule.trim().to_string();
if rule.is_empty() { request.validate().map_err(|error| {
return Err(ApiError::Request(eyre!( ApiError::Validation(validation_errors_to_string(error, None))
"rule expression cannot be empty"
)));
}
if rule.len() > MAX_RULE_EXPRESSION_LENGTH {
return Err(ApiError::Request(eyre!(
"rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes"
)));
}
if request.traces.len() > MAX_RULE_TEST_TRACES {
return Err(ApiError::Request(eyre!(
"cannot test more than {MAX_RULE_TEST_TRACES} traces at once"
)));
}
let program = cel::Program::compile(rule).map_err(|error| {
ApiError::Request(eyre!("invalid cel expression: {error}"))
})?; })?;
let mut effects = Vec::with_capacity(request.traces.len());
for (index, trace) in request.traces.iter().enumerate() { let rule = request.rule;
let input = test_rule_input(trace); let program =
tokio::task::spawn_blocking(move || cel::Program::compile(&rule))
.await
.wrap_internal_err("failed to join cel compilation task")?
.map_err(|error| {
ApiError::Request(eyre!("invalid cel expression: {error}"))
})?;
let mut effects = Vec::with_capacity(request.inputs.len());
for (index, input) in request.inputs.iter().enumerate() {
let effect = super::rules_scan::evaluate_rule(&program, input) let effect = super::rules_scan::evaluate_rule(&program, input)
.map_err(|error| { .map_err(|error| {
ApiError::Request(eyre!( ApiError::Request(eyre!(
"failed to evaluate test trace {index}: {error}" "failed to evaluate test input {index}: {error}"
)) ))
})?; })?;
effects.push(effect); effects.push(effect);
@@ -217,33 +184,6 @@ pub async fn test_rule(
Ok(web::Json(TestDelphiRuleResponse { effects })) Ok(web::Json(TestDelphiRuleResponse { effects }))
} }
fn test_rule_input(trace: &TestDelphiRuleTrace) -> RuleInput {
RuleInput {
schema_version: 1,
trace: RuleTrace {
key: trace.key.clone(),
issue_type: trace.issue_type.clone(),
severity: trace.severity,
jar: trace.jar.clone(),
file_path: trace.file_path.clone(),
data: trace.data.clone(),
},
scan: RuleScan { delphi_version: 17 },
artifact: RuleArtifact {
size: Some(412_892),
hashes: BTreeMap::from([
("sha1".to_string(), "0123456789abcdef".to_string()),
("sha512".to_string(), "fedcba9876543210".to_string()),
]),
},
scope: RuleScope {
project_id: Some("example-project".to_string()),
version_id: Some("example-version".to_string()),
file_id: Some("example-file".to_string()),
},
}
}
/// List all Delphi rules that are not pending deletion. /// List all Delphi rules that are not pending deletion.
#[utoipa::path( #[utoipa::path(
context_path = "/moderation/tech-review", context_path = "/moderation/tech-review",
@@ -345,26 +285,7 @@ pub async fn get_rules(
let mut response = Vec::<DelphiRule>::new(); let mut response = Vec::<DelphiRule>::new();
for rule in rules { for rule in rules {
if response let affected_detail = if let (
.last()
.is_none_or(|existing| existing.id != rule.id)
{
response.push(DelphiRule {
id: rule.id,
name: rule.name,
rule: rule.rule,
priority: rule.priority,
revision: rule.revision,
created_at: rule.created_at,
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
affected_details_count: rule.affected_details_count,
affected_details: Vec::new(),
});
}
if let (
Some(detail_id), Some(detail_id),
Some(issue_id), Some(issue_id),
Some(issue_type), Some(issue_type),
@@ -381,28 +302,49 @@ pub async fn get_rules(
rule.original_severity, rule.original_severity,
rule.hidden, rule.hidden,
) { ) {
response Some(DelphiRuleAffectedDetail {
.last_mut() detail_id,
.expect("a delphi rule was inserted above") issue_id,
.affected_details project_id: rule.project_id.map(ProjectId::from),
.push(DelphiRuleAffectedDetail { project_name: rule.project_name,
detail_id, project_icon_url: rule.project_icon_url,
issue_id, version_id: rule.version_id.map(VersionId::from),
project_id: rule.project_id.map(ProjectId::from), version_name: rule.version_name,
project_name: rule.project_name, version_number: rule.version_number,
project_icon_url: rule.project_icon_url, issue_type,
version_id: rule.version_id.map(VersionId::from), key,
version_name: rule.version_name, jar: rule.jar,
version_number: rule.version_number, file_path,
issue_type, original_severity,
key, severity: rule.effect_severity,
jar: rule.jar, hidden,
file_path, })
original_severity, } else {
severity: rule.effect_severity, None
hidden, };
});
if let Some(existing) = response.last_mut()
&& existing.id == rule.id
{
if let Some(affected_detail) = affected_detail {
existing.affected_details.push(affected_detail);
}
continue;
} }
response.push(DelphiRule {
id: rule.id,
name: rule.name,
rule: rule.rule,
priority: rule.priority,
revision: rule.revision,
created_at: rule.created_at,
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
affected_details_count: rule.affected_details_count,
affected_details: affected_detail.into_iter().collect(),
});
} }
Ok(web::Json(response)) Ok(web::Json(response))
@@ -519,7 +461,7 @@ pub async fn create_rule(
Scopes::PROJECT_WRITE, Scopes::PROJECT_WRITE,
) )
.await?; .await?;
let rule = body.into_inner().validate()?; let rule = body.into_inner().validate().await?;
let user_id = user.id.0 as i64; let user_id = user.id.0 as i64;
let rule = sqlx::query!( let rule = sqlx::query!(
@@ -601,7 +543,7 @@ pub async fn update_rule(
) )
.await?; .await?;
let (id,) = path.into_inner(); let (id,) = path.into_inner();
let rule = body.into_inner().validate()?; let rule = body.into_inner().validate().await?;
let user_id = user.id.0 as i64; let user_id = user.id.0 as i64;
let rule = sqlx::query!( let rule = sqlx::query!(
@@ -5,7 +5,7 @@ use ariadne::ids::base62_impl::to_base62;
use bytes::Bytes; use bytes::Bytes;
use eyre::{Context as _, Result, eyre}; use eyre::{Context as _, Result, eyre};
use futures_util::{StreamExt, TryStreamExt}; use futures_util::{StreamExt, TryStreamExt};
use serde::Serialize; use serde::{Deserialize, Serialize};
use sqlx::types::Json; use sqlx::types::Json;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
@@ -52,7 +52,7 @@ struct RuleScanErrorEvent<'a> {
message: &'a str, message: &'a str,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct RuleInput { pub struct RuleInput {
pub schema_version: u32, pub schema_version: u32,
pub trace: RuleTrace, pub trace: RuleTrace,
@@ -61,7 +61,7 @@ pub struct RuleInput {
pub scope: RuleScope, pub scope: RuleScope,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct RuleTrace { pub struct RuleTrace {
pub key: String, pub key: String,
pub issue_type: String, pub issue_type: String,
@@ -71,18 +71,18 @@ pub struct RuleTrace {
pub data: HashMap<String, serde_json::Value>, pub data: HashMap<String, serde_json::Value>,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct RuleScan { pub struct RuleScan {
pub delphi_version: i32, pub delphi_version: i32,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct RuleArtifact { pub struct RuleArtifact {
pub size: Option<i32>, pub size: Option<i32>,
pub hashes: BTreeMap<String, String>, pub hashes: BTreeMap<String, String>,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct RuleScope { pub struct RuleScope {
pub project_id: Option<String>, pub project_id: Option<String>,
pub version_id: Option<String>, pub version_id: Option<String>,
@@ -238,9 +238,9 @@ pub async fn get_detail_rule_input(
hashes: detail.hashes.0, hashes: detail.hashes.0,
}, },
scope: RuleScope { scope: RuleScope {
project_id: detail.project_id.map(to_public_id), project_id: detail.project_id.map(|id| to_base62(id as u64)),
version_id: detail.version_id.map(to_public_id), version_id: detail.version_id.map(|id| to_base62(id as u64)),
file_id: detail.file_id.map(to_public_id), file_id: detail.file_id.map(|id| to_base62(id as u64)),
}, },
})) }))
} }
@@ -316,27 +316,28 @@ pub async fn scan_rules(
actix_web::rt::spawn(async move { actix_web::rt::spawn(async move {
match run_scan(transaction, &sender).await { match run_scan(transaction, &sender).await {
Ok(summary) => { Ok(summary) => {
send_event( let event = RuleScanEvent {
&sender, phase: "complete",
"complete", revision: summary.revision,
&RuleScanEvent { scanned: summary.scanned,
phase: "complete", total: summary.total,
revision: summary.revision, effects: summary.effects,
scanned: summary.scanned, };
total: summary.total, if let Ok(data) = serde_json::to_string(&event) {
effects: summary.effects, let _ = sender.send(Bytes::from(format!(
}, "event: complete\ndata: {data}\n\n"
); )));
}
} }
Err(error) => { Err(error) => {
tracing::error!(error = ?error, "delphi rule scan failed"); tracing::error!(error = ?error, "delphi rule scan failed");
send_event( let message = error.to_string();
&sender, let event = RuleScanErrorEvent { message: &message };
"failed", if let Ok(data) = serde_json::to_string(&event) {
&RuleScanErrorEvent { let _ = sender.send(Bytes::from(format!(
message: &error.to_string(), "event: failed\ndata: {data}\n\n"
}, )));
); }
} }
} }
}); });
@@ -422,7 +423,17 @@ async fn run_scan(
let mut effects = Vec::new(); let mut effects = Vec::new();
let mut scanned = 0; let mut scanned = 0;
send_progress(sender, "scanning", revision, 0, total, 0); let event = RuleScanEvent {
phase: "scanning",
revision,
scanned: 0,
total,
effects: 0,
};
if let Ok(data) = serde_json::to_string(&event) {
let _ = sender
.send(Bytes::from(format!("event: progress\ndata: {data}\n\n")));
}
while let Some(detail) = details while let Some(detail) = details
.try_next() .try_next()
@@ -448,9 +459,9 @@ async fn run_scan(
hashes: detail.hashes.0, hashes: detail.hashes.0,
}, },
scope: RuleScope { scope: RuleScope {
project_id: detail.project_id.map(to_public_id), project_id: detail.project_id.map(|id| to_base62(id as u64)),
version_id: detail.version_id.map(to_public_id), version_id: detail.version_id.map(|id| to_base62(id as u64)),
file_id: detail.file_id.map(to_public_id), file_id: detail.file_id.map(|id| to_base62(id as u64)),
}, },
}; };
@@ -474,20 +485,34 @@ async fn run_scan(
scanned += 1; scanned += 1;
if scanned % PROGRESS_INTERVAL == 0 || scanned == total { if scanned % PROGRESS_INTERVAL == 0 || scanned == total {
send_progress( let event = RuleScanEvent {
sender, phase: "scanning",
"scanning",
revision, revision,
scanned, scanned,
total, total,
effects.len(), effects: effects.len(),
); };
if let Ok(data) = serde_json::to_string(&event) {
let _ = sender.send(Bytes::from(format!(
"event: progress\ndata: {data}\n\n"
)));
}
tokio::task::yield_now().await; tokio::task::yield_now().await;
} }
} }
drop(details); drop(details);
send_progress(sender, "publishing", revision, total, total, effects.len()); let event = RuleScanEvent {
phase: "publishing",
revision,
scanned: total,
total,
effects: effects.len(),
};
if let Ok(data) = serde_json::to_string(&event) {
let _ = sender
.send(Bytes::from(format!("event: progress\ndata: {data}\n\n")));
}
insert_materialized_effects(revision, &effects, &mut transaction).await?; insert_materialized_effects(revision, &effects, &mut transaction).await?;
@@ -656,9 +681,9 @@ pub(crate) async fn materialize_current_rule_effects(
hashes: detail.hashes.0, hashes: detail.hashes.0,
}, },
scope: RuleScope { scope: RuleScope {
project_id: detail.project_id.map(to_public_id), project_id: detail.project_id.map(|id| to_base62(id as u64)),
version_id: detail.version_id.map(to_public_id), version_id: detail.version_id.map(|id| to_base62(id as u64)),
file_id: detail.file_id.map(to_public_id), file_id: detail.file_id.map(|id| to_base62(id as u64)),
}, },
}; };
@@ -687,7 +712,7 @@ pub(crate) async fn materialize_current_rule_effects(
async fn fetch_compiled_rules( async fn fetch_compiled_rules(
transaction: &mut PgTransaction<'_>, transaction: &mut PgTransaction<'_>,
) -> Result<Vec<CompiledRule>> { ) -> Result<Vec<CompiledRule>> {
sqlx::query!( let rules = sqlx::query!(
r#" r#"
SELECT id AS "id!: DelphiRuleId", rule SELECT id AS "id!: DelphiRuleId", rule
FROM delphi_rules FROM delphi_rules
@@ -697,18 +722,28 @@ async fn fetch_compiled_rules(
) )
.fetch_all(&mut *transaction) .fetch_all(&mut *transaction)
.await .await
.wrap_err("failed to fetch delphi rules")? .wrap_err("failed to fetch delphi rules")?;
.into_iter()
.map(|rule| { tokio::task::spawn_blocking(move || {
let program = cel::Program::compile(&rule.rule).map_err(|error| { rules
eyre!("failed to compile delphi rule {}: {error}", rule.id.0) .into_iter()
})?; .map(|rule| {
Ok(CompiledRule { let program =
id: rule.id, cel::Program::compile(&rule.rule).map_err(|error| {
program, eyre!(
}) "failed to compile delphi rule {}: {error}",
rule.id.0
)
})?;
Ok(CompiledRule {
id: rule.id,
program,
})
})
.collect()
}) })
.collect() .await
.wrap_err("failed to join cel compilation task")?
} }
async fn insert_materialized_effects( async fn insert_materialized_effects(
@@ -790,40 +825,3 @@ pub(super) fn evaluate_rule(
.wrap_err("cel expression returned an invalid rule effect"), .wrap_err("cel expression returned an invalid rule effect"),
} }
} }
fn to_public_id(id: i64) -> String {
to_base62(id as u64)
}
fn send_progress(
sender: &mpsc::UnboundedSender<Bytes>,
phase: &'static str,
revision: i64,
scanned: usize,
total: usize,
effects: usize,
) {
send_event(
sender,
"progress",
&RuleScanEvent {
phase,
revision,
scanned,
total,
effects,
},
);
}
fn send_event(
sender: &mpsc::UnboundedSender<Bytes>,
event: &str,
data: &impl Serialize,
) {
let Ok(data) = serde_json::to_string(data) else {
return;
};
let _ =
sender.send(Bytes::from(format!("event: {event}\ndata: {data}\n\n")));
}
@@ -2288,18 +2288,9 @@ export namespace Labrinth {
priority: number priority: number
} }
export type TestDelphiRuleTrace = {
key: string
issue_type: string
severity: DelphiSeverity
jar: string | null
file_path: string
data: Record<string, unknown>
}
export type TestDelphiRuleRequest = { export type TestDelphiRuleRequest = {
rule: string rule: string
traces: TestDelphiRuleTrace[] inputs: RuleInput[]
} }
export type DelphiRuleEffect = { export type DelphiRuleEffect = {