Skip to main content

mockforge_registry_server/handlers/
plugins.rs

1//! Plugin-related handlers
2
3use axum::{
4    extract::{Path, State},
5    response::Redirect,
6    Json,
7};
8use mockforge_plugin_registry::{
9    AuthorInfo, PluginCategory, RegistryEntry, SearchQuery, SearchResults, SortOrder, VersionEntry,
10};
11use serde::{Deserialize, Serialize};
12
13use crate::{
14    error::{ApiError, ApiResult},
15    middleware::{AuthUser, ScopedAuth},
16    models::{AuditEventType, TokenScope, User},
17    AppState,
18};
19
20/// Build the canonical download URL clients see in API responses. Clients
21/// follow this 302 to the artifact and the server records the hit.
22fn tracked_download_url(name: &str, version: &str) -> String {
23    format!(
24        "/api/v1/plugins/{}/versions/{}/download",
25        urlencoding::encode(name),
26        urlencoding::encode(version)
27    )
28}
29
30pub async fn search_plugins(
31    State(state): State<AppState>,
32    Json(query): Json<SearchQuery>,
33) -> ApiResult<Json<SearchResults>> {
34    let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
35    let pool = state.db.pool();
36
37    // Map sort order
38    let sort_by = match query.sort {
39        SortOrder::Downloads => "downloads",
40        SortOrder::Rating => "rating",
41        SortOrder::Recent => "recent",
42        SortOrder::Name => "name",
43        SortOrder::Popular => "popular",
44        SortOrder::Security => "security",
45        _ => "downloads",
46    };
47
48    // Map category to string
49    let category_str = query.category.as_ref().map(|c| match c {
50        PluginCategory::Auth => "auth",
51        PluginCategory::Template => "template",
52        PluginCategory::Response => "response",
53        PluginCategory::DataSource => "datasource",
54        PluginCategory::Middleware => "middleware",
55        PluginCategory::Testing => "testing",
56        PluginCategory::Observability => "observability",
57        PluginCategory::Other => "other",
58    });
59
60    // Normalize language to lowercase before querying — UI sends display-case
61    // values but the column is canonically lowercase. Empty/whitespace is
62    // treated as "no filter".
63    let language_filter = query.language.as_deref().and_then(|l| {
64        let trimmed = l.trim();
65        if trimmed.is_empty() {
66            None
67        } else {
68            Some(trimmed.to_ascii_lowercase())
69        }
70    });
71
72    // Validate and limit pagination parameters
73    let per_page = query.per_page.clamp(1, 100); // Max 100 items per page
74    let page = query.page;
75    let limit = per_page as i64;
76    let offset = (page * per_page) as i64;
77
78    // Search plugins
79    let plugins = match state
80        .store
81        .search_plugins(
82            query.query.as_deref(),
83            category_str,
84            language_filter.as_deref(),
85            &query.tags,
86            sort_by,
87            limit,
88            offset,
89        )
90        .await
91    {
92        Ok(plugins) => plugins,
93        Err(e) => {
94            metrics.record_search_error("database_error");
95            return Err(e.into());
96        }
97    };
98
99    // Convert to registry entries
100    let mut entries = Vec::new();
101    for plugin in plugins {
102        let tags = state.store.get_plugin_tags(plugin.id).await?;
103
104        let versions = state.store.list_plugin_versions(plugin.id).await?;
105
106        let category = map_category_from_string(&plugin.category);
107
108        // Load versions with dependencies. We rewrite `download_url` to
109        // the in-process tracker endpoint so well-behaved clients
110        // following redirects automatically bump per-version download
111        // counters. The raw S3 URL stays in `v.download_url` server-side
112        // for the redirect target.
113        let mut version_entries = Vec::new();
114        for v in versions {
115            let dependencies = state.store.get_plugin_version_dependencies(v.id).await?;
116
117            let tracked_url = tracked_download_url(&plugin.name, &v.version);
118            version_entries.push(VersionEntry {
119                version: v.version,
120                download_url: tracked_url,
121                checksum: v.checksum,
122                size: v.file_size as u64,
123                published_at: v.published_at.to_rfc3339(),
124                yanked: v.yanked,
125                min_mockforge_version: v.min_mockforge_version,
126                dependencies,
127                downloads: v.downloads.max(0) as u64,
128            });
129        }
130
131        // Fetch author information
132        let author = User::find_by_id(pool, plugin.author_id)
133            .await
134            .map_err(ApiError::Database)?
135            .unwrap_or_else(|| User::placeholder(plugin.author_id));
136
137        let security_score = derive_security_score(&plugin);
138        let language = plugin.language.clone();
139        entries.push(RegistryEntry {
140            name: plugin.name.clone(),
141            description: plugin.description.clone(),
142            version: plugin.current_version.clone(),
143            versions: version_entries,
144            author: AuthorInfo {
145                name: author.username,
146                email: Some(author.email),
147                url: None,
148            },
149            tags,
150            category,
151            downloads: plugin.downloads_total as u64,
152            rating: decimal_to_f64(plugin.rating_avg),
153            reviews_count: plugin.rating_count as u32,
154            security_score,
155            language,
156            repository: plugin.repository,
157            homepage: plugin.homepage,
158            license: plugin.license,
159            created_at: plugin.created_at.to_rfc3339(),
160            updated_at: plugin.updated_at.to_rfc3339(),
161        });
162    }
163
164    // Count total matching results for pagination metadata
165    let total = state
166        .store
167        .count_search_plugins(
168            query.query.as_deref(),
169            category_str,
170            language_filter.as_deref(),
171            &query.tags,
172        )
173        .await? as usize;
174
175    let results = SearchResults {
176        plugins: entries,
177        total,
178        page,
179        per_page,
180    };
181
182    // Record metrics
183    metrics.record_search_success();
184
185    Ok(Json(results))
186}
187
188pub async fn get_plugin(
189    State(state): State<AppState>,
190    Path(name): Path<String>,
191) -> ApiResult<Json<RegistryEntry>> {
192    let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
193
194    let plugin = state
195        .store
196        .find_plugin_by_name(&name)
197        .await?
198        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
199
200    // Hide taken-down plugins from the public detail endpoint.
201    // Admins use the dedicated admin moderation surface to review them.
202    if plugin.taken_down_at.is_some() {
203        return Err(ApiError::PluginNotFound(name));
204    }
205
206    let tags = state.store.get_plugin_tags(plugin.id).await?;
207
208    let versions = state.store.list_plugin_versions(plugin.id).await?;
209
210    let category = map_category_from_string(&plugin.category);
211
212    // Load versions with dependencies. See note in `search_plugins` —
213    // we hand back the tracker URL so downloads count.
214    let mut version_entries = Vec::new();
215    for v in versions {
216        let dependencies = state.store.get_plugin_version_dependencies(v.id).await?;
217
218        let tracked_url = tracked_download_url(&plugin.name, &v.version);
219        version_entries.push(VersionEntry {
220            version: v.version,
221            download_url: tracked_url,
222            checksum: v.checksum,
223            size: v.file_size as u64,
224            published_at: v.published_at.to_rfc3339(),
225            yanked: v.yanked,
226            min_mockforge_version: v.min_mockforge_version,
227            dependencies,
228            downloads: v.downloads.max(0) as u64,
229        });
230    }
231
232    // Fetch author information
233    let author = state
234        .store
235        .find_user_by_id(plugin.author_id)
236        .await?
237        .unwrap_or_else(|| User::placeholder(plugin.author_id));
238
239    let security_score = derive_security_score(&plugin);
240    let language = plugin.language.clone();
241    let entry = RegistryEntry {
242        name: plugin.name.clone(),
243        description: plugin.description.clone(),
244        version: plugin.current_version.clone(),
245        versions: version_entries,
246        author: AuthorInfo {
247            name: author.username,
248            email: Some(author.email),
249            url: None,
250        },
251        tags,
252        category,
253        downloads: plugin.downloads_total as u64,
254        rating: decimal_to_f64(plugin.rating_avg),
255        reviews_count: plugin.rating_count as u32,
256        security_score,
257        language,
258        repository: plugin.repository,
259        homepage: plugin.homepage,
260        license: plugin.license,
261        created_at: plugin.created_at.to_rfc3339(),
262        updated_at: plugin.updated_at.to_rfc3339(),
263    };
264
265    // Record metrics
266    metrics.record_download_success();
267
268    Ok(Json(entry))
269}
270
271pub async fn get_version(
272    State(state): State<AppState>,
273    Path((name, version)): Path<(String, String)>,
274) -> ApiResult<Json<VersionEntry>> {
275    let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
276
277    let plugin = state
278        .store
279        .find_plugin_by_name(&name)
280        .await?
281        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
282
283    if plugin.taken_down_at.is_some() {
284        return Err(ApiError::PluginNotFound(name));
285    }
286
287    let plugin_version = state
288        .store
289        .find_plugin_version(plugin.id, &version)
290        .await?
291        .ok_or_else(|| ApiError::InvalidVersion(version.clone()))?;
292
293    // Load dependencies
294    let dependencies = state.store.get_plugin_version_dependencies(plugin_version.id).await?;
295
296    let tracked_url = tracked_download_url(&name, &plugin_version.version);
297    let entry = VersionEntry {
298        version: plugin_version.version,
299        download_url: tracked_url,
300        checksum: plugin_version.checksum,
301        size: plugin_version.file_size as u64,
302        published_at: plugin_version.published_at.to_rfc3339(),
303        yanked: plugin_version.yanked,
304        min_mockforge_version: plugin_version.min_mockforge_version,
305        dependencies,
306        downloads: plugin_version.downloads.max(0) as u64,
307    };
308
309    // Record metrics
310    metrics.record_download_success();
311
312    Ok(Json(entry))
313}
314
315/// Tracked-download redirect. Bumps both the plugin-level and version-level
316/// counters then 302-redirects to the artifact URL stored on the version
317/// row. Yanked versions still resolve so existing installs that re-fetch
318/// the artifact don't break — the registry just stops advertising them
319/// in the catalog.
320pub async fn download_version(
321    State(state): State<AppState>,
322    Path((name, version)): Path<(String, String)>,
323) -> ApiResult<Redirect> {
324    let plugin = state
325        .store
326        .find_plugin_by_name(&name)
327        .await?
328        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
329
330    if plugin.taken_down_at.is_some() {
331        return Err(ApiError::PluginNotFound(name));
332    }
333
334    let plugin_version = state
335        .store
336        .find_plugin_version(plugin.id, &version)
337        .await?
338        .ok_or_else(|| ApiError::InvalidVersion(version.clone()))?;
339
340    // Counter update is best-effort: a failed UPDATE shouldn't block the
341    // user's actual download. We still log so a persistent failure shows
342    // up in metrics.
343    if let Err(e) = state.store.increment_plugin_download(plugin.id, plugin_version.id).await {
344        tracing::warn!(plugin = %name, version = %version, error = %e, "increment_plugin_download failed");
345    }
346
347    Ok(Redirect::temporary(&plugin_version.download_url))
348}
349
350#[derive(Debug, Deserialize)]
351pub struct PublishRequest {
352    pub name: String,
353    pub version: String,
354    pub description: String,
355    pub category: String,
356    pub license: String,
357    pub repository: Option<String>,
358    pub homepage: Option<String>,
359    pub tags: Vec<String>,
360    pub checksum: String,
361    #[serde(alias = "fileSize")]
362    pub file_size: i64,
363    #[serde(alias = "wasmData")]
364    pub wasm_data: String, // Base64 encoded WASM
365    pub dependencies: Option<std::collections::HashMap<String, String>>,
366    /// Source language of the plugin (rust/python/javascript/typescript/go/other).
367    #[serde(default = "default_plugin_language")]
368    pub language: String,
369    /// Optional Software Bill of Materials (typically CycloneDX JSON).
370    /// Stored verbatim on the plugin version and fed to the vulnerability
371    /// scanner. Accepts either JSON object or `null`/omitted.
372    #[serde(default)]
373    pub sbom: Option<serde_json::Value>,
374
375    /// Optional detached Ed25519 signature over
376    /// `SHA-256(artifact_checksum_bytes || canonical(sbom))` produced by
377    /// one of the publisher's registered public keys. When supplied and
378    /// valid, the verifying key id is recorded on the plugin version so
379    /// the scanner can surface a "verified publisher attestation"
380    /// finding. When supplied but invalid, publish is rejected.
381    #[serde(default, alias = "sbomSignature")]
382    pub sbom_signature: Option<String>,
383}
384
385fn default_plugin_language() -> String {
386    "rust".to_string()
387}
388
389#[derive(Debug, Serialize)]
390#[serde(rename_all = "camelCase")]
391pub struct PublishResponse {
392    pub success: bool,
393    pub upload_url: String,
394    pub message: String,
395}
396
397pub async fn publish_plugin(
398    AuthUser(author_id): AuthUser,
399    scoped_auth: ScopedAuth,
400    State(state): State<AppState>,
401    Json(request): Json<PublishRequest>,
402) -> ApiResult<Json<PublishResponse>> {
403    // Check for PublishPackages scope
404    scoped_auth.require_scope(TokenScope::PublishPackages)?;
405
406    let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
407
408    // Check if plugin exists
409    let existing = state.store.find_plugin_by_name(&request.name).await?;
410
411    let plugin = if let Some(mut plugin) = existing {
412        // Update existing plugin
413        plugin.current_version = request.version.clone();
414        plugin.description = request.description.clone();
415        plugin
416    } else {
417        // Create new plugin
418        state
419            .store
420            .create_plugin(
421                &request.name,
422                &request.description,
423                &request.version,
424                &request.category,
425                &request.license,
426                request.repository.as_deref(),
427                request.homepage.as_deref(),
428                author_id,
429                &request.language,
430            )
431            .await?
432    };
433
434    // Validate input fields
435    crate::validation::validate_name(&request.name)?;
436    crate::validation::validate_version(&request.version)?;
437    crate::validation::validate_checksum(&request.checksum)?;
438
439    // Validate base64 encoding
440    crate::validation::validate_base64(&request.wasm_data)?;
441
442    // Decode WASM data
443    use base64::Engine;
444    let wasm_bytes = base64::engine::general_purpose::STANDARD
445        .decode(&request.wasm_data)
446        .map_err(|e| ApiError::InvalidRequest(format!("Invalid base64: {}", e)))?;
447
448    // Validate WASM file
449    crate::validation::validate_wasm_file(&wasm_bytes, request.file_size as u64)?;
450
451    // Verify checksum matches uploaded data
452    use sha2::{Digest, Sha256};
453    let mut hasher = Sha256::new();
454    hasher.update(&wasm_bytes);
455    let calculated_checksum = hex::encode(hasher.finalize());
456
457    if calculated_checksum != request.checksum {
458        return Err(ApiError::InvalidRequest(format!(
459            "Checksum mismatch: expected {}, got {}",
460            request.checksum, calculated_checksum
461        )));
462    }
463
464    // Upload to S3
465    let download_url = state
466        .storage
467        .upload_plugin(&request.name, &request.version, wasm_bytes)
468        .await
469        .map_err(|e| ApiError::Storage(e.to_string()))?;
470
471    // Create version entry
472    let version = state
473        .store
474        .create_plugin_version(
475            plugin.id,
476            &request.version,
477            &download_url,
478            &request.checksum,
479            request.file_size,
480            None,
481            request.sbom.as_ref(),
482        )
483        .await?;
484
485    // Add dependencies if provided
486    if let Some(deps) = request.dependencies {
487        for (dep_name, dep_version) in deps {
488            state
489                .store
490                .add_plugin_version_dependency(version.id, &dep_name, &dep_version)
491                .await?;
492        }
493    }
494
495    // If the publisher submitted an SBOM + signature, verify against
496    // their registered Ed25519 keys *before* writing the scan row. We
497    // reject invalid signatures here (not silently) so a malicious or
498    // badly-signed publish doesn't end up masquerading as "just unsigned."
499    if let (Some(sbom_json), Some(signature)) =
500        (request.sbom.as_ref(), request.sbom_signature.as_deref())
501    {
502        use mockforge_registry_core::models::attestation::{
503            verify_sbom_attestation, SbomAttestationInput, SbomVerifyOutcome,
504        };
505
506        // Pull both the author's own keys and any keys tagged to orgs
507        // they belong to. Lets a CI bot's key registered against an org
508        // verify a teammate's publish without requiring every member to
509        // re-register the same key personally.
510        let keys = state.store.list_keys_for_publisher(author_id).await?;
511        // Canonicalize via RFC 8785 (JCS) — the CLI signs the same form,
512        // so publishers using different JSON libraries produce
513        // byte-identical inputs to the verifier. Without this step, two
514        // "equivalent" SBOMs differing only in key order or whitespace
515        // would reject.
516        let canonical = serde_jcs::to_vec(sbom_json)
517            .map_err(|e| ApiError::InvalidRequest(format!("canonicalizing SBOM: {}", e)))?;
518        let outcome = verify_sbom_attestation(
519            &keys,
520            &SbomAttestationInput {
521                artifact_checksum: &request.checksum,
522                sbom_canonical: &canonical,
523                signature_b64: signature,
524            },
525        );
526        match outcome {
527            SbomVerifyOutcome::Verified { key_id } => {
528                state.store.record_plugin_version_attestation(version.id, Some(key_id)).await?;
529            }
530            SbomVerifyOutcome::NoKeys => {
531                return Err(ApiError::InvalidRequest(
532                    "sbom_signature supplied but no public keys are registered on this account"
533                        .to_string(),
534                ));
535            }
536            SbomVerifyOutcome::Invalid => {
537                return Err(ApiError::InvalidRequest(
538                    "sbom_signature did not verify against any registered key".to_string(),
539                ));
540            }
541            SbomVerifyOutcome::Malformed(reason) => {
542                return Err(ApiError::InvalidRequest(format!(
543                    "sbom_signature is malformed: {}",
544                    reason
545                )));
546            }
547        }
548    }
549
550    // Queue the version for a security scan. The background worker
551    // (`workers::plugin_scanner`) drains these rows, re-downloads the
552    // artifact, and overwrites the result. Writing `"pending"` here rather
553    // than attempting an inline scan keeps publish latency predictable and
554    // lets us retry by just re-running the worker.
555    let pending_findings = serde_json::json!([
556        {
557            "severity": "info",
558            "category": "other",
559            "title": "Automated scan pending",
560            "description": "This plugin version is queued for automated security scanning. Results usually appear within a minute."
561        }
562    ]);
563    state
564        .store
565        .upsert_plugin_security_scan(version.id, "pending", 50, &pending_findings, None)
566        .await?;
567
568    // Record audit event
569    state
570        .store
571        .record_audit_event(
572            uuid::Uuid::nil(),
573            Some(author_id),
574            AuditEventType::PluginPublished,
575            format!("Plugin {} version {} published", request.name, request.version),
576            Some(serde_json::json!({
577                "plugin_name": request.name,
578                "version": request.version,
579                "category": request.category,
580            })),
581            None,
582            None,
583        )
584        .await;
585
586    // Record metrics
587    metrics.record_publish_success();
588
589    Ok(Json(PublishResponse {
590        success: true,
591        upload_url: download_url.clone(),
592        message: format!(
593            "Plugin {} version {} published successfully",
594            request.name, request.version
595        ),
596    }))
597}
598
599pub async fn yank_version(
600    scoped_auth: ScopedAuth,
601    State(state): State<AppState>,
602    Path((name, version)): Path<(String, String)>,
603) -> ApiResult<Json<serde_json::Value>> {
604    // Check for PublishPackages scope (yanking is a publishing operation)
605    scoped_auth.require_scope(TokenScope::PublishPackages)?;
606
607    let plugin = state
608        .store
609        .find_plugin_by_name(&name)
610        .await?
611        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
612
613    let plugin_version = state
614        .store
615        .find_plugin_version(plugin.id, &version)
616        .await?
617        .ok_or_else(|| ApiError::InvalidVersion(version.clone()))?;
618
619    state.store.yank_plugin_version(plugin_version.id).await?;
620
621    Ok(Json(serde_json::json!({
622        "success": true,
623        "message": format!("Version {} of {} yanked successfully", version, name)
624    })))
625}
626
627/// Heuristic security score for a plugin (0-100).
628///
629/// This is a placeholder until the `SecurityScanner` pipeline is wired to the
630/// publish flow and scan results are persisted. It rewards plugins that an
631/// admin has explicitly verified, plus modest signals from freshness and
632/// community traction.
633fn derive_security_score(plugin: &crate::models::Plugin) -> u8 {
634    let mut score: i32 = 50;
635    if plugin.verified_at.is_some() {
636        score += 35;
637    }
638    let ninety_days_ago = chrono::Utc::now() - chrono::Duration::days(90);
639    if plugin.updated_at > ninety_days_ago {
640        score += 5;
641    }
642    if plugin.rating_avg >= rust_decimal::Decimal::new(40, 1) && plugin.rating_count >= 5 {
643        score += 5;
644    }
645    score.clamp(0, 100) as u8
646}
647
648/// NUMERIC plugin ratings come back as `rust_decimal::Decimal` from Postgres
649/// (sqlx's `rust_decimal` feature is enabled). The public marketplace JSON
650/// shape is `f64`, so we lossy-convert at the response boundary rather than
651/// piping `Decimal` through `RegistryEntry`.
652fn decimal_to_f64(d: rust_decimal::Decimal) -> f64 {
653    use rust_decimal::prelude::ToPrimitive;
654    d.to_f64().unwrap_or(0.0)
655}
656
657#[derive(Debug, Serialize)]
658#[serde(rename_all = "camelCase")]
659pub struct SecurityScanResponse {
660    pub status: String,
661    pub score: u8,
662    pub findings: Vec<SecurityFindingDto>,
663    pub scanned: bool,
664}
665
666#[derive(Debug, Serialize)]
667#[serde(rename_all = "camelCase")]
668pub struct SecurityFindingDto {
669    pub severity: String,
670    pub category: String,
671    pub title: String,
672    pub description: String,
673}
674
675pub async fn get_plugin_security(
676    State(state): State<AppState>,
677    Path(name): Path<String>,
678) -> ApiResult<Json<SecurityScanResponse>> {
679    let plugin = state
680        .store
681        .find_plugin_by_name(&name)
682        .await?
683        .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
684
685    // Prefer persisted scan data when available; fall back to the heuristic
686    // score derived from verification + activity if nothing has been written
687    // yet (e.g. legacy rows from before the scan table existed).
688    if let Some(scan) = state.store.latest_security_scan_for_plugin(plugin.id).await? {
689        let status = match scan.status.as_str() {
690            "pass" | "warning" | "fail" | "pending" => scan.status.clone(),
691            _ => "pending".to_string(),
692        };
693        let findings: Vec<serde_json::Value> =
694            serde_json::from_value(scan.findings).unwrap_or_default();
695        let finding_dtos = findings
696            .into_iter()
697            .filter_map(|f| {
698                Some(SecurityFindingDto {
699                    severity: map_severity(f.get("severity")?.as_str()?).to_string(),
700                    category: map_finding_category(f.get("category")?.as_str()?).to_string(),
701                    title: f.get("title")?.as_str()?.to_string(),
702                    description: f.get("description")?.as_str()?.to_string(),
703                })
704            })
705            .collect();
706        let scanned = scan.status != "pending";
707        return Ok(Json(SecurityScanResponse {
708            status,
709            score: scan.score.clamp(0, 100) as u8,
710            findings: finding_dtos,
711            scanned,
712        }));
713    }
714
715    // Fallback for plugins without a persisted scan row.
716    let score = derive_security_score(&plugin);
717    let status = if score >= 70 {
718        "pass"
719    } else if score >= 50 {
720        "warning"
721    } else {
722        "fail"
723    };
724    let findings = if plugin.verified_at.is_some() {
725        Vec::new()
726    } else {
727        vec![SecurityFindingDto {
728            severity: "info".to_string(),
729            category: "other".to_string(),
730            title: "Automated scan pending".to_string(),
731            description:
732                "This plugin has not yet been processed by the security scanner. The score shown is a heuristic based on verification status and activity."
733                    .to_string(),
734        }]
735    };
736
737    Ok(Json(SecurityScanResponse {
738        status: status.to_string(),
739        score,
740        findings,
741        scanned: false,
742    }))
743}
744
745fn map_severity(s: &str) -> &'static str {
746    match s.to_ascii_lowercase().as_str() {
747        "critical" => "critical",
748        "high" => "high",
749        "medium" => "medium",
750        "low" => "low",
751        _ => "info",
752    }
753}
754
755fn map_finding_category(s: &str) -> &'static str {
756    match s.to_ascii_lowercase().as_str() {
757        "malware" => "malware",
758        "vulnerable_dependency" | "vulnerabledependency" => "vulnerable_dependency",
759        "insecure_coding" | "insecurecoding" => "insecure_coding",
760        "data_exfiltration" | "dataexfiltration" => "data_exfiltration",
761        "supply_chain" | "supplychain" => "supply_chain",
762        "licensing" => "licensing",
763        "configuration" => "configuration",
764        "obfuscation" => "obfuscation",
765        _ => "other",
766    }
767}
768
769fn map_category_from_string(cat: &str) -> PluginCategory {
770    match cat {
771        "auth" => PluginCategory::Auth,
772        "template" => PluginCategory::Template,
773        "response" => PluginCategory::Response,
774        "datasource" => PluginCategory::DataSource,
775        "middleware" => PluginCategory::Middleware,
776        "testing" => PluginCategory::Testing,
777        "observability" => PluginCategory::Observability,
778        _ => PluginCategory::Other,
779    }
780}