| 12 | 12 | | //! need the g1t plan, which Security and quality comes with (a 402 says so); this module |
| 13 | 13 | | //! reads the input and gives each answer its public shape. |
| 14 | 14 | | |
| 15 | + | use g1t_contracts::advisories::{ |
| 16 | + | Advisory, AdvisoryInput, CreateAdvisoryArgs, GetAdvisoryArgs, ListAdvisoriesArgs, ListReportsArgs, ReportVulnerabilityArgs, ReviewReportArgs, |
| 17 | + | SetAdvisoryStateArgs, UpdateAdvisoryArgs, |
| 18 | + | }; |
| 15 | 19 | | use g1t_contracts::repos::RepoPath; |
| 16 | 20 | | use g1t_contracts::security::{AlertChange, AlertState, DismissArgs, DismissReason, ReopenArgs, SecretFinding, SecurityOverview, Vulnerability}; |
| 17 | 21 | | use g1t_contracts::security_suite::*; |
| ⋯ |
| 57 | 61 | | GetWorkspaceSettings, |
| 58 | 62 | | UpdateWorkspaceSettings, |
| 59 | 63 | | GetOverview, |
| 64 | + | ListAdvisories, |
| 65 | + | GetAdvisory, |
| 66 | + | CreateAdvisory, |
| 67 | + | UpdateAdvisory, |
| 68 | + | SetAdvisoryState, |
| 69 | + | ListReports, |
| 70 | + | ReportVulnerability, |
| 71 | + | ReviewReport, |
| 60 | 72 | | } |
| 61 | 73 | | |
| 62 | 74 | | impl SecurityOp { |
| 63 | 75 | | /// Every one: `Op::ALL` lists each as `Op::Security(…)`, which a test |
| 64 | 76 | | /// checks against this. |
| 65 | 77 | | #[cfg(test)] |
| 66 | | − | pub const ALL: [SecurityOp; 31] = [ |
| 78 | + | pub const ALL: [SecurityOp; 39] = [ |
| 67 | 79 | | SecurityOp::ListSecretAlerts, |
| 68 | 80 | | SecurityOp::GetSecretAlert, |
| 69 | 81 | | SecurityOp::UpdateSecretAlert, |
| ⋯ |
| 95 | 107 | | SecurityOp::GetWorkspaceSettings, |
| 96 | 108 | | SecurityOp::UpdateWorkspaceSettings, |
| 97 | 109 | | SecurityOp::GetOverview, |
| 110 | + | SecurityOp::ListAdvisories, |
| 111 | + | SecurityOp::GetAdvisory, |
| 112 | + | SecurityOp::CreateAdvisory, |
| 113 | + | SecurityOp::UpdateAdvisory, |
| 114 | + | SecurityOp::SetAdvisoryState, |
| 115 | + | SecurityOp::ListReports, |
| 116 | + | SecurityOp::ReportVulnerability, |
| 117 | + | SecurityOp::ReviewReport, |
| 98 | 118 | | ]; |
| 99 | 119 | | |
| 100 | 120 | | pub fn name(self) -> &'static str { |
| ⋯ |
| 130 | 150 | | SecurityOp::GetWorkspaceSettings => "get_workspace_security_settings", |
| 131 | 151 | | SecurityOp::UpdateWorkspaceSettings => "update_workspace_security_settings", |
| 132 | 152 | | SecurityOp::GetOverview => "get_security_overview", |
| 153 | + | SecurityOp::ListAdvisories => "list_security_advisories", |
| 154 | + | SecurityOp::GetAdvisory => "get_security_advisory", |
| 155 | + | SecurityOp::CreateAdvisory => "create_security_advisory", |
| 156 | + | SecurityOp::UpdateAdvisory => "update_security_advisory", |
| 157 | + | SecurityOp::SetAdvisoryState => "set_security_advisory_state", |
| 158 | + | SecurityOp::ListReports => "list_vulnerability_reports", |
| 159 | + | SecurityOp::ReportVulnerability => "report_vulnerability", |
| 160 | + | SecurityOp::ReviewReport => "review_vulnerability_report", |
| 133 | 161 | | } |
| 134 | 162 | | } |
| 135 | 163 | | |
| ⋯ |
| 167 | 195 | | SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings", |
| 168 | 196 | | SecurityOp::UpdateWorkspaceSettings => "Update a workspace's security settings", |
| 169 | 197 | | SecurityOp::GetOverview => "Get the security overview", |
| 198 | + | SecurityOp::ListAdvisories => "List security advisories", |
| 199 | + | SecurityOp::GetAdvisory => "Get a security advisory", |
| 200 | + | SecurityOp::CreateAdvisory => "Create a security advisory", |
| 201 | + | SecurityOp::UpdateAdvisory => "Update a security advisory", |
| 202 | + | SecurityOp::SetAdvisoryState => "Publish or withdraw a security advisory", |
| 203 | + | SecurityOp::ListReports => "List vulnerability reports", |
| 204 | + | SecurityOp::ReportVulnerability => "Report a vulnerability privately", |
| 205 | + | SecurityOp::ReviewReport => "Accept or decline a vulnerability report", |
| 170 | 206 | | } |
| 171 | 207 | | } |
| 172 | 208 | | |
| ⋯ |
| 198 | 234 | | SecurityOp::GetDependencyGraph => "Get the dependency graph: every package the lockfiles on the default branch resolve, per lockfile, with whether it is direct or transitive (where the lockfile says), for development, its license when recorded, its package URL and its open vulnerability alerts.", |
| 199 | 235 | | SecurityOp::GetSbom => "Export the dependency graph as an SPDX 2.3 JSON document, in sbom. Every package is named by its package URL.", |
| 200 | 236 | | SecurityOp::CompareDependencies => "Compare the dependencies at two commits, branches or tags (basehead, as base...head): what was added and removed per lockfile, with the known vulnerabilities of what was added and whether it passes the repository's dependency review policy. Needs the g1t plan on a private repository.", |
| 201 | | − | SecurityOp::GetSettings => "Get a repository's security settings: when the Code scanning check fails, dependency review and its policy, its workspace's settings, and whether the paid features are on for it.", |
| 202 | | − | SecurityOp::UpdateSettings => "Change a repository's security settings: code_scanning_gate (none, errors, critical, high, medium or any), dependency_review, review_fail_on (critical, high, medium, low or none), review_deny_licenses (SPDX ids) and review_comment. Takes the Admin role, or a security manager of the workspace. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.", |
| 237 | + | SecurityOp::GetSettings => "Get a repository's security settings: when the Code scanning check fails, dependency review and its policy, whether secrets in the history open alerts, whether it takes private vulnerability reports, its workspace's settings, and whether the paid features are on for it.", |
| 238 | + | SecurityOp::UpdateSettings => "Change a repository's security settings: code_scanning_gate (none, errors, critical, high, medium or any), dependency_review, review_fail_on (critical, high, medium, low or none), review_deny_licenses (SPDX ids), review_comment, history_alerts (whether secrets found in the history open alerts; push protection stays on) and private_vulnerability_reporting. Takes the Admin role, or a security manager of the workspace. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.", |
| 203 | 239 | | SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings (delegated bypass, validity checks) and whether it has Security and quality, which comes with the g1t plan.", |
| 204 | 240 | | SecurityOp::UpdateWorkspaceSettings => "Turn delegated bypass and validity checks on or off for a workspace. Owners only.", |
| 205 | 241 | | SecurityOp::GetOverview => "Get a workspace's security overview: open alerts by type and severity, how many opened and closed in the last days (7 to 90, 30 by default), a daily trend, and for each repository which features are on and what is open, most in need first. Private repositories count only on the g1t plan.", |
| 242 | + | SecurityOp::ListAdvisories => "List a repository's security advisories, newest first: what it says about vulnerabilities in what it ships. Anyone who can see the repository sees the published ones; those who see its findings (Write, or a security manager) see drafts and withdrawn ones too. Filter by state (draft, published, withdrawn).", |
| 243 | + | SecurityOp::GetAdvisory => "Get one security advisory by its adv_… id or its G1TSA-… id, with the same visibility as the list.", |
| 244 | + | SecurityOp::CreateAdvisory => "Create a draft security advisory: title, description (Markdown), severity (critical, high, medium or low), and optionally cvss_vector and cvss_score, cve_id, the package affected (ecosystem, package, affected_versions, patched_versions) and credits. Admin on the repository, or a security manager of its workspace. Nobody is told of a draft.", |
| 245 | + | SecurityOp::UpdateAdvisory => "Change an advisory's fields, in any state; what is not given stays as it is. A published advisory's change is visible at once. Admin, or a security manager.", |
| 246 | + | SecurityOp::SetAdvisoryState => "Publish a draft (state published), withdraw a published advisory (state withdrawn), or publish a withdrawn one again. Publishing needs a description, tells webhooks (security_advisory.published) and the repository's watchers who chose security alerts, and makes the advisory visible to anyone who can see the repository. Admin, or a security manager.", |
| 247 | + | SecurityOp::ListReports => "List the vulnerability reports people sent the repository's maintainers privately, new ones first. Those who see findings (Write, or a security manager). Filter by state (new, accepted, declined). Reporters never see this list.", |
| 248 | + | SecurityOp::ReportVulnerability => "Report a vulnerability privately to a repository's maintainers: title, description and, if you like, severity. Needs private vulnerability reporting on for the repository. Anyone who can see the repository may report; on a public one you may report without a token by giving an email the maintainers can answer at. At most 5 reports a day per reporter and repository. The workspace's owners and security managers are told at once. You see only your own report.", |
| 249 | + | SecurityOp::ReviewReport => "Accept a report (decision accept), which makes a draft advisory from it crediting the reporter, or decline it (decision decline), with an optional note. Admin, or a security manager.", |
| 206 | 250 | | } |
| 207 | 251 | | } |
| 208 | 252 | | |
| ⋯ |
| 248 | 292 | | properties |
| 249 | 293 | | }; |
| 250 | 294 | | let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }); |
| 295 | + | let advisory_fields = |mut properties: Value| { |
| 296 | + | properties["title"] = json!({ "type": "string", "description": "One line: what the vulnerability is. At most 200 characters." }); |
| 297 | + | properties["description"] = json!({ "type": "string", "description": "Markdown: what is affected, how it is exploited, what to do. At most 20,000 characters." }); |
| 298 | + | properties["severity"] = json!({ "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad it is." }); |
| 299 | + | properties["cvss_vector"] = json!({ "type": "string", "description": "A CVSS vector string, when scored: CVSS:3.1/AV:N/…" }); |
| 300 | + | properties["cvss_score"] = json!({ "type": "number", "description": "The CVSS base score, 0 to 10." }); |
| 301 | + | properties["cve_id"] = json!({ "type": "string", "description": "CVE-2026-12345, when one was issued elsewhere." }); |
| 302 | + | properties["ecosystem"] = json!({ "type": "string", "description": "How the affected package is distributed: npm, crates.io, Go, PyPI, or how the project ships." }); |
| 303 | + | properties["package"] = json!({ "type": "string", "description": "The package affected." }); |
| 304 | + | properties["affected_versions"] = json!({ "type": "string", "description": "The versions affected, as a range: >= 1.2.0, < 1.4.1" }); |
| 305 | + | properties["patched_versions"] = json!({ "type": "string", "description": "The versions that fix it: >= 1.4.1" }); |
| 306 | + | properties["credits"] = json!({ "type": "array", "items": { "type": "string" }, "description": "Who found or fixed it: usernames or names, up to 20." }); |
| 307 | + | properties |
| 308 | + | }; |
| 309 | + | let advisory_id = || json!({ "type": "string", "description": "The advisory's id: adv_…, or its G1TSA-… id." }); |
| 251 | 310 | | let (properties, required): (Value, &[&str]) = match self { |
| 252 | 311 | | SecurityOp::ListSecretAlerts => ( |
| 253 | 312 | | either(json!({ |
| ⋯ |
| 394 | 453 | | "review_fail_on": { "type": "string", "enum": ["critical", "high", "medium", "low", "none"], "description": "The lowest severity of a known vulnerability in an added package that fails the review." }, |
| 395 | 454 | | "review_deny_licenses": { "type": "array", "items": { "type": "string" }, "description": "SPDX license ids an added package may not have." }, |
| 396 | 455 | | "review_comment": { "type": "boolean", "description": "Whether the review comments its summary on the pull request." }, |
| 456 | + | "history_alerts": { "type": "boolean", "description": "Whether secrets found in the history, and in pushes too large to scan first, open alerts and tell people. Push protection stays on either way." }, |
| 457 | + | "private_vulnerability_reporting": { "type": "boolean", "description": "Whether anyone who can see the repository may report a vulnerability privately to its maintainers." }, |
| 397 | 458 | | }), |
| 398 | 459 | | &["repo"], |
| 399 | 460 | | ), |
| ⋯ |
| 410 | 471 | | json!({ "workspace": workspace(), "days": { "type": "integer", "description": "Days of trend, 7 to 90. Default 30." } }), |
| 411 | 472 | | &["workspace"], |
| 412 | 473 | | ), |
| 474 | + | SecurityOp::ListAdvisories => ( |
| 475 | + | json!({ "repo": repo(), "state": { "type": "string", "enum": ["draft", "published", "withdrawn"], "description": "Only advisories in this state." } }), |
| 476 | + | &["repo"], |
| 477 | + | ), |
| 478 | + | SecurityOp::GetAdvisory => (json!({ "repo": repo(), "id": advisory_id() }), &["repo", "id"]), |
| 479 | + | SecurityOp::CreateAdvisory => (advisory_fields(json!({ "repo": repo() })), &["repo", "title", "severity"]), |
| 480 | + | SecurityOp::UpdateAdvisory => (advisory_fields(json!({ "repo": repo(), "id": advisory_id() })), &["repo", "id"]), |
| 481 | + | SecurityOp::SetAdvisoryState => ( |
| 482 | + | json!({ |
| 483 | + | "repo": repo(), |
| 484 | + | "id": advisory_id(), |
| 485 | + | "state": { "type": "string", "enum": ["published", "withdrawn"], "description": "published to publish (or publish again), withdrawn to withdraw." }, |
| 486 | + | }), |
| 487 | + | &["repo", "id", "state"], |
| 488 | + | ), |
| 489 | + | SecurityOp::ListReports => ( |
| 490 | + | json!({ "repo": repo(), "state": { "type": "string", "enum": ["new", "accepted", "declined"], "description": "Only reports in this state." } }), |
| 491 | + | &["repo"], |
| 492 | + | ), |
| 493 | + | SecurityOp::ReportVulnerability => ( |
| 494 | + | json!({ |
| 495 | + | "repo": repo(), |
| 496 | + | "title": { "type": "string", "description": "One line: what you found. At most 200 characters." }, |
| 497 | + | "description": { "type": "string", "description": "What is affected and how to reproduce it. At most 20,000 characters." }, |
| 498 | + | "severity": { "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad you think it is, if you like." }, |
| 499 | + | "email": { "type": "string", "description": "Without a token: where the maintainers can answer you." }, |
| 500 | + | }), |
| 501 | + | &["repo", "title", "description"], |
| 502 | + | ), |
| 503 | + | SecurityOp::ReviewReport => ( |
| 504 | + | json!({ |
| 505 | + | "repo": repo(), |
| 506 | + | "id": { "type": "string", "description": "The report's id: vrp_…" }, |
| 507 | + | "decision": { "type": "string", "enum": ["accept", "decline"], "description": "accept makes a draft advisory from it; decline closes it." }, |
| 508 | + | "note": { "type": "string", "description": "A note for the record, in a sentence. At most 1,000 characters." }, |
| 509 | + | }), |
| 510 | + | &["repo", "id", "decision"], |
| 511 | + | ), |
| 413 | 512 | | }; |
| 414 | 513 | | let mut schema = json!({ "type": "object", "properties": properties }); |
| 415 | 514 | | if !required.is_empty() { |
| ⋯ |
| 601 | 700 | | crate::operations::repo_path(input) |
| 602 | 701 | | } |
| 603 | 702 | | |
| 703 | + | /// An advisory's fields as given, over `current` for what is not. |
| 704 | + | pub(crate) fn advisory_input(input: &Value, current: &AdvisoryInput) -> AdvisoryInput { |
| 705 | + | let given = |key: &str, now: &Option<String>| if input.get(key).is_some() { text(input, key) } else { now.clone() }; |
| 706 | + | AdvisoryInput { |
| 707 | + | title: text(input, "title").unwrap_or_else(|| current.title.clone()), |
| 708 | + | description: input["description"].as_str().map(str::to_owned).unwrap_or_else(|| current.description.clone()), |
| 709 | + | severity: text(input, "severity").map(|severity| severity.to_lowercase()).unwrap_or_else(|| current.severity.clone()), |
| 710 | + | cvss_vector: given("cvss_vector", ¤t.cvss_vector), |
| 711 | + | cvss_score: if input.get("cvss_score").is_some() { input["cvss_score"].as_f64() } else { current.cvss_score }, |
| 712 | + | cve_id: given("cve_id", ¤t.cve_id), |
| 713 | + | ecosystem: given("ecosystem", ¤t.ecosystem), |
| 714 | + | package: given("package", ¤t.package), |
| 715 | + | affected_versions: given("affected_versions", ¤t.affected_versions), |
| 716 | + | patched_versions: given("patched_versions", ¤t.patched_versions), |
| 717 | + | credits: if input["credits"].is_array() { strings(input, "credits") } else { current.credits.clone() }, |
| 718 | + | } |
| 719 | + | } |
| 720 | + | |
| 721 | + | /// An advisory as it stands, as input to change it from. |
| 722 | + | fn advisory_as_input(advisory: &Advisory) -> AdvisoryInput { |
| 723 | + | AdvisoryInput { |
| 724 | + | title: advisory.title.clone(), |
| 725 | + | description: advisory.description.clone(), |
| 726 | + | severity: advisory.severity.clone(), |
| 727 | + | cvss_vector: advisory.cvss_vector.clone(), |
| 728 | + | cvss_score: advisory.cvss_score, |
| 729 | + | cve_id: advisory.cve_id.clone(), |
| 730 | + | ecosystem: advisory.ecosystem.clone(), |
| 731 | + | package: advisory.package.clone(), |
| 732 | + | affected_versions: advisory.affected_versions.clone(), |
| 733 | + | patched_versions: advisory.patched_versions.clone(), |
| 734 | + | credits: advisory.credits.clone(), |
| 735 | + | } |
| 736 | + | } |
| 737 | + | |
| 604 | 738 | | pub async fn run(op: SecurityOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 605 | 739 | | let actor = || viewer.clone().unwrap_or_default(); |
| 606 | 740 | | let repo = path_of(input); |
| ⋯ |
| 945 | 1079 | | if let Some(on) = flag(input, "review_comment") { |
| 946 | 1080 | | settings.review_comment = on; |
| 947 | 1081 | | } |
| 1082 | + | if let Some(on) = flag(input, "history_alerts") { |
| 1083 | + | settings.history_alerts = on; |
| 1084 | + | } |
| 1085 | + | if let Some(on) = flag(input, "private_vulnerability_reporting") { |
| 1086 | + | settings.private_vulnerability_reporting = on; |
| 1087 | + | } |
| 948 | 1088 | | pass(services, "set_security_settings", &SetSecuritySettingsArgs { actor: actor(), repo, settings }).await |
| 949 | 1089 | | } |
| 1090 | + | SecurityOp::ListAdvisories => { |
| 1091 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1092 | + | let state = match one_of(input, "state", &["draft", "published", "withdrawn"]) { |
| 1093 | + | Ok(state) => state, |
| 1094 | + | Err(message) => return failed(FailureCode::Invalid, &message), |
| 1095 | + | }; |
| 1096 | + | pass(services, "list_advisories", &ListAdvisoriesArgs { viewer: viewer.clone(), repo, state }).await |
| 1097 | + | } |
| 1098 | + | SecurityOp::GetAdvisory => { |
| 1099 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1100 | + | pass(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo, id: text(input, "id").unwrap_or_default() }).await |
| 1101 | + | } |
| 1102 | + | SecurityOp::CreateAdvisory => { |
| 1103 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1104 | + | let advisory = advisory_input(input, &AdvisoryInput::default()); |
| 1105 | + | pass(services, "create_advisory", &CreateAdvisoryArgs { actor: actor(), repo, advisory }).await |
| 1106 | + | } |
| 1107 | + | SecurityOp::UpdateAdvisory => { |
| 1108 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1109 | + | let id = text(input, "id").unwrap_or_default(); |
| 1110 | + | // What is not given stays as it is. |
| 1111 | + | let current: Outcome<Advisory> = call(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo: repo.clone(), id: id.clone() }).await?; |
| 1112 | + | let current = match current { |
| 1113 | + | Outcome::Ok(advisory) => advisory, |
| 1114 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 1115 | + | }; |
| 1116 | + | let advisory = advisory_input(input, &advisory_as_input(¤t)); |
| 1117 | + | pass(services, "update_advisory", &UpdateAdvisoryArgs { actor: actor(), repo, id, advisory }).await |
| 1118 | + | } |
| 1119 | + | SecurityOp::SetAdvisoryState => { |
| 1120 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1121 | + | let state = match one_of(input, "state", &["published", "withdrawn"]) { |
| 1122 | + | Ok(Some(state)) => state, |
| 1123 | + | Ok(None) => return failed(FailureCode::Invalid, "state is published or withdrawn."), |
| 1124 | + | Err(message) => return failed(FailureCode::Invalid, &message), |
| 1125 | + | }; |
| 1126 | + | pass(services, "set_advisory_state", &SetAdvisoryStateArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default(), state }).await |
| 1127 | + | } |
| 1128 | + | SecurityOp::ListReports => { |
| 1129 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1130 | + | let state = match one_of(input, "state", &["new", "accepted", "declined"]) { |
| 1131 | + | Ok(state) => state, |
| 1132 | + | Err(message) => return failed(FailureCode::Invalid, &message), |
| 1133 | + | }; |
| 1134 | + | pass(services, "list_vulnerability_reports", &ListReportsArgs { viewer: viewer.clone(), repo, state }).await |
| 1135 | + | } |
| 1136 | + | SecurityOp::ReportVulnerability => { |
| 1137 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1138 | + | let args = ReportVulnerabilityArgs { |
| 1139 | + | viewer: viewer.clone(), |
| 1140 | + | repo, |
| 1141 | + | title: text(input, "title").unwrap_or_default(), |
| 1142 | + | description: text(input, "description").unwrap_or_default(), |
| 1143 | + | severity: text(input, "severity").map(|severity| severity.to_lowercase()), |
| 1144 | + | email: text(input, "email"), |
| 1145 | + | }; |
| 1146 | + | pass(services, "report_vulnerability", &args).await |
| 1147 | + | } |
| 1148 | + | SecurityOp::ReviewReport => { |
| 1149 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; |
| 1150 | + | let args = ReviewReportArgs { |
| 1151 | + | actor: actor(), |
| 1152 | + | repo, |
| 1153 | + | id: text(input, "id").unwrap_or_default(), |
| 1154 | + | decision: text(input, "decision").unwrap_or_default().to_lowercase(), |
| 1155 | + | note: text(input, "note").unwrap_or_default(), |
| 1156 | + | }; |
| 1157 | + | pass(services, "review_vulnerability_report", &args).await |
| 1158 | + | } |
| 950 | 1159 | | SecurityOp::GetWorkspaceSettings => { |
| 951 | 1160 | | let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") }; |
| 952 | 1161 | | pass(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace }).await |