Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! The security suite over REST and MCP: secret scanning (alerts, where |
| 2 | //! each secret is, push protection bypasses and their review, validity | |
| 3 | //! checks, custom patterns), code scanning (alerts, analyses, SARIF | |
| 4 | //! uploads), vulnerability alerts, the dependency graph with its SBOM and | |
| 5 | //! dependency review, "Fix with g1t", settings, and the workspace's | |
| 6 | //! overview. | |
| 7 | //! | |
| 8 | //! The addresses follow the common ones (`/repos/{owner}/{name}/secret- | |
| 9 | //! scanning/alerts`, `/code-scanning/sarifs`, `/dependency-graph/sbom`), | |
| 10 | //! in g1t's spelling: no version prefix, `snake_case` throughout. The | |
| 11 | //! security service decides who may see and change what, and which parts | |
| 12 | //! need the Security and quality activation (a 402 says so); this module | |
| 13 | //! reads the input and gives each answer its public shape. | |
| 14 | ||
| 15 | use g1t_contracts::repos::RepoPath; | |
| 16 | use g1t_contracts::security::{AlertChange, AlertState, DismissArgs, DismissReason, ReopenArgs, SecretFinding, SecurityOverview, Vulnerability}; | |
| 17 | use g1t_contracts::security_suite::*; | |
| 18 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 19 | use serde::Serialize; | |
| 20 | use serde::de::DeserializeOwned; | |
| 21 | use serde_json::{Value, json}; | |
| 22 | use worker::Result; | |
| 23 | ||
| 24 | use crate::operations::Services; | |
| 25 | ||
| 26 | /// One operation of the suite. | |
| 27 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 28 | pub enum SecurityOp { | |
| 29 | ListSecretAlerts, | |
| 30 | GetSecretAlert, | |
| 31 | UpdateSecretAlert, | |
| 32 | ListSecretLocations, | |
| 33 | BypassPushProtection, | |
| 34 | CheckSecretValidity, | |
| 35 | ListBypassRequests, | |
| 36 | ReviewBypassRequest, | |
| 37 | ListCustomPatterns, | |
| 38 | CreateCustomPattern, | |
| 39 | UpdateCustomPattern, | |
| 40 | DeleteCustomPattern, | |
| 41 | DryRunCustomPattern, | |
| 42 | ListCodeAlerts, | |
| 43 | GetCodeAlert, | |
| 44 | UpdateCodeAlert, | |
| 45 | ListAnalyses, | |
| 46 | UploadSarif, | |
| 47 | GetSarifUpload, | |
| 48 | ListVulnerabilityAlerts, | |
| 49 | GetVulnerabilityAlert, | |
| 50 | UpdateVulnerabilityAlert, | |
| 51 | FixAlert, | |
| 52 | GetDependencyGraph, | |
| 53 | GetSbom, | |
| 54 | CompareDependencies, | |
| 55 | GetSettings, | |
| 56 | UpdateSettings, | |
| 57 | GetWorkspaceSettings, | |
| 58 | UpdateWorkspaceSettings, | |
| 59 | GetOverview, | |
| 60 | } | |
| 61 | ||
| 62 | impl SecurityOp { | |
| 63 | /// Every one: `Op::ALL` lists each as `Op::Security(…)`, which a test | |
| 64 | /// checks against this. | |
| 65 | #[cfg(test)] | |
| 66 | pub const ALL: [SecurityOp; 31] = [ | |
| 67 | SecurityOp::ListSecretAlerts, | |
| 68 | SecurityOp::GetSecretAlert, | |
| 69 | SecurityOp::UpdateSecretAlert, | |
| 70 | SecurityOp::ListSecretLocations, | |
| 71 | SecurityOp::BypassPushProtection, | |
| 72 | SecurityOp::CheckSecretValidity, | |
| 73 | SecurityOp::ListBypassRequests, | |
| 74 | SecurityOp::ReviewBypassRequest, | |
| 75 | SecurityOp::ListCustomPatterns, | |
| 76 | SecurityOp::CreateCustomPattern, | |
| 77 | SecurityOp::UpdateCustomPattern, | |
| 78 | SecurityOp::DeleteCustomPattern, | |
| 79 | SecurityOp::DryRunCustomPattern, | |
| 80 | SecurityOp::ListCodeAlerts, | |
| 81 | SecurityOp::GetCodeAlert, | |
| 82 | SecurityOp::UpdateCodeAlert, | |
| 83 | SecurityOp::ListAnalyses, | |
| 84 | SecurityOp::UploadSarif, | |
| 85 | SecurityOp::GetSarifUpload, | |
| 86 | SecurityOp::ListVulnerabilityAlerts, | |
| 87 | SecurityOp::GetVulnerabilityAlert, | |
| 88 | SecurityOp::UpdateVulnerabilityAlert, | |
| 89 | SecurityOp::FixAlert, | |
| 90 | SecurityOp::GetDependencyGraph, | |
| 91 | SecurityOp::GetSbom, | |
| 92 | SecurityOp::CompareDependencies, | |
| 93 | SecurityOp::GetSettings, | |
| 94 | SecurityOp::UpdateSettings, | |
| 95 | SecurityOp::GetWorkspaceSettings, | |
| 96 | SecurityOp::UpdateWorkspaceSettings, | |
| 97 | SecurityOp::GetOverview, | |
| 98 | ]; | |
| 99 | ||
| 100 | pub fn name(self) -> &'static str { | |
| 101 | match self { | |
| 102 | SecurityOp::ListSecretAlerts => "list_secret_scanning_alerts", | |
| 103 | SecurityOp::GetSecretAlert => "get_secret_scanning_alert", | |
| 104 | SecurityOp::UpdateSecretAlert => "update_secret_scanning_alert", | |
| 105 | SecurityOp::ListSecretLocations => "list_secret_scanning_locations", | |
| 106 | SecurityOp::BypassPushProtection => "bypass_push_protection", | |
| 107 | SecurityOp::CheckSecretValidity => "check_secret_validity", | |
| 108 | SecurityOp::ListBypassRequests => "list_bypass_requests", | |
| 109 | SecurityOp::ReviewBypassRequest => "review_bypass_request", | |
| 110 | SecurityOp::ListCustomPatterns => "list_custom_patterns", | |
| 111 | SecurityOp::CreateCustomPattern => "create_custom_pattern", | |
| 112 | SecurityOp::UpdateCustomPattern => "update_custom_pattern", | |
| 113 | SecurityOp::DeleteCustomPattern => "delete_custom_pattern", | |
| 114 | SecurityOp::DryRunCustomPattern => "dry_run_custom_pattern", | |
| 115 | SecurityOp::ListCodeAlerts => "list_code_scanning_alerts", | |
| 116 | SecurityOp::GetCodeAlert => "get_code_scanning_alert", | |
| 117 | SecurityOp::UpdateCodeAlert => "update_code_scanning_alert", | |
| 118 | SecurityOp::ListAnalyses => "list_code_scanning_analyses", | |
| 119 | SecurityOp::UploadSarif => "upload_sarif", | |
| 120 | SecurityOp::GetSarifUpload => "get_sarif_upload", | |
| 121 | SecurityOp::ListVulnerabilityAlerts => "list_vulnerability_alerts", | |
| 122 | SecurityOp::GetVulnerabilityAlert => "get_vulnerability_alert", | |
| 123 | SecurityOp::UpdateVulnerabilityAlert => "update_vulnerability_alert", | |
| 124 | SecurityOp::FixAlert => "fix_security_alert", | |
| 125 | SecurityOp::GetDependencyGraph => "get_dependency_graph", | |
| 126 | SecurityOp::GetSbom => "get_sbom", | |
| 127 | SecurityOp::CompareDependencies => "compare_dependencies", | |
| 128 | SecurityOp::GetSettings => "get_security_settings", | |
| 129 | SecurityOp::UpdateSettings => "update_security_settings", | |
| 130 | SecurityOp::GetWorkspaceSettings => "get_workspace_security_settings", | |
| 131 | SecurityOp::UpdateWorkspaceSettings => "update_workspace_security_settings", | |
| 132 | SecurityOp::GetOverview => "get_security_overview", | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | /// For the API reference: "List secret scanning alerts". | |
| 137 | pub fn title(self) -> &'static str { | |
| 138 | match self { | |
| 139 | SecurityOp::ListSecretAlerts => "List secret scanning alerts", | |
| 140 | SecurityOp::GetSecretAlert => "Get a secret scanning alert", | |
| 141 | SecurityOp::UpdateSecretAlert => "Dismiss or reopen a secret scanning alert", | |
| 142 | SecurityOp::ListSecretLocations => "List where a secret was found", | |
| 143 | SecurityOp::BypassPushProtection => "Bypass push protection", | |
| 144 | SecurityOp::CheckSecretValidity => "Check whether a secret still works", | |
| 145 | SecurityOp::ListBypassRequests => "List push protection bypass requests", | |
| 146 | SecurityOp::ReviewBypassRequest => "Review a bypass request", | |
| 147 | SecurityOp::ListCustomPatterns => "List custom patterns", | |
| 148 | SecurityOp::CreateCustomPattern => "Create a custom pattern", | |
| 149 | SecurityOp::UpdateCustomPattern => "Update a custom pattern", | |
| 150 | SecurityOp::DeleteCustomPattern => "Delete a custom pattern", | |
| 151 | SecurityOp::DryRunCustomPattern => "Dry-run a custom pattern", | |
| 152 | SecurityOp::ListCodeAlerts => "List code scanning alerts", | |
| 153 | SecurityOp::GetCodeAlert => "Get a code scanning alert", | |
| 154 | SecurityOp::UpdateCodeAlert => "Dismiss or reopen a code scanning alert", | |
| 155 | SecurityOp::ListAnalyses => "List code scanning analyses", | |
| 156 | SecurityOp::UploadSarif => "Upload a SARIF file", | |
| 157 | SecurityOp::GetSarifUpload => "Get a SARIF upload", | |
| 158 | SecurityOp::ListVulnerabilityAlerts => "List vulnerability alerts", | |
| 159 | SecurityOp::GetVulnerabilityAlert => "Get a vulnerability alert", | |
| 160 | SecurityOp::UpdateVulnerabilityAlert => "Dismiss or reopen a vulnerability alert", | |
| 161 | SecurityOp::FixAlert => "Fix an alert with g1t", | |
| 162 | SecurityOp::GetDependencyGraph => "Get the dependency graph", | |
| 163 | SecurityOp::GetSbom => "Export an SBOM", | |
| 164 | SecurityOp::CompareDependencies => "Compare dependencies", | |
| 165 | SecurityOp::GetSettings => "Get a repository's security settings", | |
| 166 | SecurityOp::UpdateSettings => "Update a repository's security settings", | |
| 167 | SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings", | |
| 168 | SecurityOp::UpdateWorkspaceSettings => "Update a workspace's security settings", | |
| 169 | SecurityOp::GetOverview => "Get the security overview", | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | pub fn description(self) -> &'static str { | |
| 174 | match self { | |
| 175 | SecurityOp::ListSecretAlerts => "List secret scanning alerts: secrets found in pushes (blocked) and in history (open), newest first, in a repository or (with workspace) across a workspace. Filter by state (open, dismissed, fixed), secret_type, validity (active, inactive, unknown, unsupported) and bypassed. The secret itself is never returned: a preview and a fingerprint-based id only.", | |
| 176 | SecurityOp::GetSecretAlert => "Get one secret scanning alert by id (sec_…), with every place it was found, its activity, its bypass requests, and whether you may bypass it or only ask to.", | |
| 177 | SecurityOp::UpdateSecretAlert => "Dismiss a secret scanning alert (state dismissed, with a reason: false_positive, used_in_tests, revoked or wont_fix, and an optional comment) or reopen it (state open). Revoked marks it fixed; the others let pushes carrying it through. Takes the Admin role.", | |
| 178 | SecurityOp::ListSecretLocations => "List every place a secret was found: file, line, commit and whether a push or the history scan found it.", | |
| 179 | SecurityOp::BypassPushProtection => "Push past push protection for a blocked secret, with a reason: false_positive or used_in_tests (the alert is closed with that reason) or will_fix_later (it stays open, to be rotated). Recorded on the alert and in the audit log. With delegated bypass on, someone who does not review bypasses makes a request instead, which owners and the repository's admins approve or deny; the answer says which happened. Push again once it is bypassed or approved.", | |
| 180 | SecurityOp::CheckSecretValidity => "Ask a landed secret's issuer whether it still works, and mark the alert active or inactive. The check is the issuer's own read-only identity call over HTTPS; the secret goes nowhere else. Needs validity checks on for the workspace (and the Security and quality activation on a private repository). Formats with no safe check answer unsupported.", | |
| 181 | SecurityOp::ListBypassRequests => "List a workspace's push protection bypass requests, pending first. Owners and repository admins see every request; anyone else their own. Filter by state (pending, approved, denied, cancelled) or repo.", | |
| 182 | SecurityOp::ReviewBypassRequest => "Approve or deny a bypass request (owners and the repository's admins, never your own), or cancel your own. An approved request bypasses push protection for that secret, as its requester asked.", | |
| 183 | SecurityOp::ListCustomPatterns => "List custom secret patterns: a repository's own and the ones it inherits from its workspace (with repo), or a workspace's (with workspace).", | |
| 184 | SecurityOp::CreateCustomPattern => "Create a custom secret pattern: a name, a regular expression for the secret, optional regular expressions for what comes right before and after it, and test strings. Patterns run in linear time (no look-around or back-references) and within size limits. With publish true, push protection and scans use it at once and the history is scanned again for it; otherwise it is a draft. A repository's takes Admin; a workspace's, an owner. On a private repository it needs the Security and quality activation.", | |
| 185 | SecurityOp::UpdateCustomPattern => "Change a custom pattern, publish it, or turn it back into a draft (publish false). Returns where it matched each test string.", | |
| 186 | SecurityOp::DeleteCustomPattern => "Delete a custom pattern. Alerts it found stay.", | |
| 187 | SecurityOp::DryRunCustomPattern => "Run a pattern over the default branch without saving it: of the repository, or (with workspace) of up to ten of its repositories, or those named in repos. Returns the files read and up to fifty matches each, masked.", | |
| 188 | SecurityOp::ListCodeAlerts => "List code scanning alerts: problems a tool reported on the default branch, one per tool, category and fingerprint, open first and worst first. In a repository, or (with workspace) across a workspace. Filter by state, severity, tool and rule_id.", | |
| 189 | SecurityOp::GetCodeAlert => "Get one code scanning alert by number, with its rule, location, activity and the analyses that reported it.", | |
| 190 | SecurityOp::UpdateCodeAlert => "Dismiss a code scanning alert (state dismissed, dismissed_reason false_positive, wont_fix or used_in_tests, optional dismissed_comment) or reopen it (state open). A fixed alert reopens by itself when an analysis reports it again.", | |
| 191 | SecurityOp::ListAnalyses => "List code scanning analyses, newest first: each upload's run of one tool on one commit, with how many results it had and the alerts it opened and fixed.", | |
| 192 | SecurityOp::UploadSarif => "Upload a SARIF 2.1.0 file: sarif is the file gzipped and base64-encoded; commit_sha the full commit; ref refs/heads/<branch> or refs/pull/<number>/head. For the default branch, new results open alerts and results no longer reported fix theirs. For a pull request, its results new to it on lines it changes become review comments and the Code scanning check, which fails at the repository's threshold. Read at once; the answer says complete or failed and why. Needs the Security and quality activation on a private repository.", | |
| 193 | SecurityOp::GetSarifUpload => "Get a SARIF upload by id (sar_…): whether it was read, the analyses it made, and what was wrong.", | |
| 194 | SecurityOp::ListVulnerabilityAlerts => "List vulnerability alerts: a package a lockfile resolves with a known advisory, open first and worst first, with the security update g1t opened for it. In a repository, or (with workspace) across a workspace. Filter by state, severity, ecosystem and package.", | |
| 195 | SecurityOp::GetVulnerabilityAlert => "Get one vulnerability alert by id (vul_…).", | |
| 196 | SecurityOp::UpdateVulnerabilityAlert => "Dismiss a vulnerability alert (state dismissed, with a reason: fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used, and an optional comment) or reopen it (state open).", | |
| 197 | SecurityOp::FixAlert => "Put g1t on an issue to fix an alert: a code scanning alert (cod_…), a vulnerable dependency (vul_…) or a secret in the code (sec_…; rotating it stays with you). Its pull request lands through the repository's required checks. The agent's run is charged as agent usage. Returns the issue, and whether the agent started.", | |
| 198 | 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 | SecurityOp::GetSbom => "Export the dependency graph as an SPDX 2.3 JSON document, in sbom. Every package is named by its package URL.", | |
| 200 | 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 Security and quality activation 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 Maintain role. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.", | |
| 203 | SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings (delegated bypass, validity checks) and whether it has the Security and quality activation.", | |
| 204 | SecurityOp::UpdateWorkspaceSettings => "Turn delegated bypass and validity checks on or off for a workspace. Owners only.", | |
| 205 | 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 with the Security and quality activation only.", | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | /// Whether the operation is about one repository named by `repo` | |
| 210 | /// (rather than a workspace, or either). | |
| 211 | pub fn needs_repo(self) -> bool { | |
| 212 | !matches!( | |
| 213 | self, | |
| 214 | SecurityOp::ListSecretAlerts | |
| 215 | | SecurityOp::ListCodeAlerts | |
| 216 | | SecurityOp::ListVulnerabilityAlerts | |
| 217 | | SecurityOp::ListBypassRequests | |
| 218 | | SecurityOp::ReviewBypassRequest | |
| 219 | | SecurityOp::ListCustomPatterns | |
| 220 | | SecurityOp::CreateCustomPattern | |
| 221 | | SecurityOp::UpdateCustomPattern | |
| 222 | | SecurityOp::DeleteCustomPattern | |
| 223 | | SecurityOp::DryRunCustomPattern | |
| 224 | | SecurityOp::GetWorkspaceSettings | |
| 225 | | SecurityOp::UpdateWorkspaceSettings | |
| 226 | | SecurityOp::GetOverview | |
| 227 | ) | |
| 228 | } | |
| 229 | ||
| 230 | pub fn input(self) -> Value { | |
| 231 | let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 232 | let either = |mut properties: Value| { | |
| 233 | properties["repo"] = json!({ "type": "string", "description": "Repository as \"owner/name\". Or give workspace." }); | |
| 234 | properties["workspace"] = json!({ "type": "string", "description": "Instead of repo: the workspace's slug, for all of it (or its own, for patterns)." }); | |
| 235 | properties | |
| 236 | }; | |
| 237 | let secret_id = || json!({ "type": "string", "description": "The alert's id: sec_…" }); | |
| 238 | let number = || json!({ "type": "integer", "description": "The code scanning alert's number." }); | |
| 239 | let state = || json!({ "type": "string", "enum": ["open", "dismissed", "fixed"], "description": "Only alerts in this state." }); | |
| 240 | let severity = || json!({ "type": "string", "enum": ["critical", "high", "medium", "low", "unknown"], "description": "Only alerts of this severity." }); | |
| 241 | let set_state = || json!({ "type": "string", "enum": ["open", "dismissed"], "description": "dismissed, with a reason, or open to reopen." }); | |
| 242 | let comment = || json!({ "type": "string", "description": "Why, in a sentence; kept with the alert. At most 500 characters." }); | |
| 243 | let pattern_fields = |mut properties: Value| { | |
| 244 | properties["pattern_name"] = json!({ "type": "string", "description": "What people call it: \"Acme API key\"." }); | |
| 245 | properties["pattern"] = json!({ "type": "string", "description": "The secret's format, as a regular expression (the regex crate's syntax: no look-around or back-references). At most 1,000 characters; it may not match an empty string." }); | |
| 246 | properties["before"] = json!({ "type": "string", "description": "What must come right before the secret, as a regular expression. Default: the start of the line or a character that is not a letter or digit." }); | |
| 247 | properties["after"] = json!({ "type": "string", "description": "What must come right after it. Default: the end of the line or a character that is not a letter or digit." }); | |
| 248 | properties | |
| 249 | }; | |
| 250 | let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }); | |
| 251 | let (properties, required): (Value, &[&str]) = match self { | |
| 252 | SecurityOp::ListSecretAlerts => ( | |
| 253 | either(json!({ | |
| 254 | "state": state(), | |
| 255 | "secret_type": { "type": "string", "description": "Only this kind of secret: aws_access_key, github_token, custom_pattern, …" }, | |
| 256 | "validity": { "type": "string", "enum": ["active", "inactive", "unknown", "unsupported"], "description": "Only alerts whose issuer said this when last asked." }, | |
| 257 | "bypassed": { "type": "boolean", "description": "Only alerts someone bypassed push protection for (true), or not (false)." }, | |
| 258 | })), | |
| 259 | &[], | |
| 260 | ), | |
| 261 | SecurityOp::GetSecretAlert | SecurityOp::ListSecretLocations | SecurityOp::CheckSecretValidity => { | |
| 262 | (json!({ "repo": repo(), "id": secret_id() }), &["repo", "id"]) | |
| 263 | } | |
| 264 | SecurityOp::UpdateSecretAlert => ( | |
| 265 | json!({ | |
| 266 | "repo": repo(), | |
| 267 | "id": secret_id(), | |
| 268 | "state": set_state(), | |
| 269 | "reason": { "type": "string", "enum": ["false_positive", "used_in_tests", "revoked", "wont_fix"], "description": "Why it is dismissed. revoked marks it fixed." }, | |
| 270 | "comment": comment(), | |
| 271 | }), | |
| 272 | &["repo", "id", "state"], | |
| 273 | ), | |
| 274 | SecurityOp::BypassPushProtection => ( | |
| 275 | json!({ | |
| 276 | "repo": repo(), | |
| 277 | "id": secret_id(), | |
| 278 | "reason": { "type": "string", "enum": BypassReason::ALL.map(BypassReason::as_str), "description": "false_positive: not a secret. used_in_tests: a value for tests. will_fix_later: real, to be rotated (the alert stays open)." }, | |
| 279 | "comment": comment(), | |
| 280 | }), | |
| 281 | &["repo", "id", "reason"], | |
| 282 | ), | |
| 283 | SecurityOp::ListBypassRequests => ( | |
| 284 | json!({ | |
| 285 | "workspace": workspace(), | |
| 286 | "repo": { "type": "string", "description": "Only this repository's, as \"owner/name\"." }, | |
| 287 | "state": { "type": "string", "enum": ["pending", "approved", "denied", "cancelled"], "description": "Only requests in this state." }, | |
| 288 | }), | |
| 289 | &["workspace"], | |
| 290 | ), | |
| 291 | SecurityOp::ReviewBypassRequest => ( | |
| 292 | json!({ | |
| 293 | "workspace": workspace(), | |
| 294 | "id": { "type": "string", "description": "The request's id: byp_…" }, | |
| 295 | "decision": { "type": "string", "enum": ["approve", "deny", "cancel"], "description": "approve or deny (reviewers), or cancel (your own)." }, | |
| 296 | "comment": comment(), | |
| 297 | }), | |
| 298 | &["workspace", "id", "decision"], | |
| 299 | ), | |
| 300 | SecurityOp::ListCustomPatterns => (either(json!({})), &[]), | |
| 301 | SecurityOp::CreateCustomPattern => ( | |
| 302 | either(pattern_fields(json!({ | |
| 303 | "test_strings": { "type": "array", "items": { "type": "string" }, "description": "Up to 20 strings to show the pattern working on." }, | |
| 304 | "publish": { "type": "boolean", "description": "Use it in push protection and scans now (true), or keep a draft (false, the default)." }, | |
| 305 | }))), | |
| 306 | &["pattern_name", "pattern"], | |
| 307 | ), | |
| 308 | SecurityOp::UpdateCustomPattern => ( | |
| 309 | either(pattern_fields(json!({ | |
| 310 | "id": { "type": "string", "description": "The pattern's id: pat_…" }, | |
| 311 | "test_strings": { "type": "array", "items": { "type": "string" }, "description": "Up to 20 strings to show the pattern working on." }, | |
| 312 | "publish": { "type": "boolean", "description": "Published (true) or a draft (false)." }, | |
| 313 | }))), | |
| 314 | &["id", "pattern_name", "pattern"], | |
| 315 | ), | |
| 316 | SecurityOp::DeleteCustomPattern => (either(json!({ "id": { "type": "string", "description": "The pattern's id: pat_…" } })), &["id"]), | |
| 317 | SecurityOp::DryRunCustomPattern => ( | |
| 318 | either(pattern_fields(json!({ | |
| 319 | "repos": { "type": "array", "items": { "type": "string" }, "description": "With workspace: repository names to run it on; the first ten when empty." }, | |
| 320 | }))), | |
| 321 | &["pattern"], | |
| 322 | ), | |
| 323 | SecurityOp::ListCodeAlerts => ( | |
| 324 | either(json!({ | |
| 325 | "state": state(), | |
| 326 | "severity": severity(), | |
| 327 | "tool": { "type": "string", "description": "Only this tool's: \"ESLint\"." }, | |
| 328 | "rule_id": { "type": "string", "description": "Only this rule's." }, | |
| 329 | })), | |
| 330 | &[], | |
| 331 | ), | |
| 332 | SecurityOp::GetCodeAlert => (json!({ "repo": repo(), "number": number() }), &["repo", "number"]), | |
| 333 | SecurityOp::UpdateCodeAlert => ( | |
| 334 | json!({ | |
| 335 | "repo": repo(), | |
| 336 | "number": number(), | |
| 337 | "state": set_state(), | |
| 338 | "dismissed_reason": { "type": "string", "enum": ["false_positive", "wont_fix", "used_in_tests"], "description": "Why it is dismissed." }, | |
| 339 | "dismissed_comment": comment(), | |
| 340 | }), | |
| 341 | &["repo", "number", "state"], | |
| 342 | ), | |
| 343 | SecurityOp::ListAnalyses => (json!({ "repo": repo() }), &["repo"]), | |
| 344 | SecurityOp::UploadSarif => ( | |
| 345 | json!({ | |
| 346 | "repo": repo(), | |
| 347 | "commit_sha": { "type": "string", "description": "The full hash of the commit analysed." }, | |
| 348 | "ref": { "type": "string", "description": "refs/heads/<branch>, or refs/pull/<number>/head (or /merge) for a pull request." }, | |
| 349 | "sarif": { "type": "string", "description": "The SARIF 2.1.0 file, gzipped, then base64-encoded. At most 10 MB encoded and 40 MB unzipped." }, | |
| 350 | "tool_name": { "type": "string", "description": "The tool's name, when the file has one run and you want another name for it." }, | |
| 351 | "category": { "type": "string", "description": "Which analysis this is, when a repository runs several of one tool. Default: the run's automationDetails.id, or the tool's name." }, | |
| 352 | "checkout_uri": { "type": "string", "description": "Where the files were checked out (file:///home/runner/work/repo), so absolute paths become repository paths." }, | |
| 353 | }), | |
| 354 | &["repo", "commit_sha", "ref", "sarif"], | |
| 355 | ), | |
| 356 | SecurityOp::GetSarifUpload => (json!({ "repo": repo(), "id": { "type": "string", "description": "The upload's id: sar_…" } }), &["repo", "id"]), | |
| 357 | SecurityOp::ListVulnerabilityAlerts => ( | |
| 358 | either(json!({ | |
| 359 | "state": state(), | |
| 360 | "severity": severity(), | |
| 361 | "ecosystem": { "type": "string", "description": "Only this ecosystem's: npm, crates.io, Go or PyPI." }, | |
| 362 | "package": { "type": "string", "description": "Only this package's." }, | |
| 363 | })), | |
| 364 | &[], | |
| 365 | ), | |
| 366 | SecurityOp::GetVulnerabilityAlert => (json!({ "repo": repo(), "id": { "type": "string", "description": "The alert's id: vul_…" } }), &["repo", "id"]), | |
| 367 | SecurityOp::UpdateVulnerabilityAlert => ( | |
| 368 | json!({ | |
| 369 | "repo": repo(), | |
| 370 | "id": { "type": "string", "description": "The alert's id: vul_…" }, | |
| 371 | "state": set_state(), | |
| 372 | "reason": { "type": "string", "enum": ["fix_started", "no_bandwidth", "tolerable_risk", "inaccurate", "not_used"], "description": "Why it is dismissed." }, | |
| 373 | "comment": comment(), | |
| 374 | }), | |
| 375 | &["repo", "id", "state"], | |
| 376 | ), | |
| 377 | SecurityOp::FixAlert => ( | |
| 378 | json!({ "repo": repo(), "id": { "type": "string", "description": "The alert's id: cod_…, vul_… or sec_…" } }), | |
| 379 | &["repo", "id"], | |
| 380 | ), | |
| 381 | SecurityOp::GetDependencyGraph | SecurityOp::GetSbom | SecurityOp::GetSettings => (json!({ "repo": repo() }), &["repo"]), | |
| 382 | SecurityOp::CompareDependencies => ( | |
| 383 | json!({ | |
| 384 | "repo": repo(), | |
| 385 | "basehead": { "type": "string", "description": "base...head: two commits, branches or tags, e.g. main...my-branch." }, | |
| 386 | }), | |
| 387 | &["repo", "basehead"], | |
| 388 | ), | |
| 389 | SecurityOp::UpdateSettings => ( | |
| 390 | json!({ | |
| 391 | "repo": repo(), | |
| 392 | "code_scanning_gate": { "type": "string", "enum": ["none", "errors", "critical", "high", "medium", "any"], "description": "When a pull request's Code scanning check fails: never, on errors, or on new results of this security severity or worse (and errors)." }, | |
| 393 | "dependency_review": { "type": "boolean", "description": "Whether pull requests get the Dependency review check." }, | |
| 394 | "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 | "review_deny_licenses": { "type": "array", "items": { "type": "string" }, "description": "SPDX license ids an added package may not have." }, | |
| 396 | "review_comment": { "type": "boolean", "description": "Whether the review comments its summary on the pull request." }, | |
| 397 | }), | |
| 398 | &["repo"], | |
| 399 | ), | |
| 400 | SecurityOp::GetWorkspaceSettings => (json!({ "workspace": workspace() }), &["workspace"]), | |
| 401 | SecurityOp::UpdateWorkspaceSettings => ( | |
| 402 | json!({ | |
| 403 | "workspace": workspace(), | |
| 404 | "delegated_bypass": { "type": "boolean", "description": "Bypasses need an owner's or the repository's admins' approval." }, | |
| 405 | "validity_checks": { "type": "boolean", "description": "Ask issuers whether secrets still work, where that can be done safely." }, | |
| 406 | }), | |
| 407 | &["workspace"], | |
| 408 | ), | |
| 409 | SecurityOp::GetOverview => ( | |
| 410 | json!({ "workspace": workspace(), "days": { "type": "integer", "description": "Days of trend, 7 to 90. Default 30." } }), | |
| 411 | &["workspace"], | |
| 412 | ), | |
| 413 | }; | |
| 414 | let mut schema = json!({ "type": "object", "properties": properties }); | |
| 415 | if !required.is_empty() { | |
| 416 | schema["required"] = json!(required); | |
| 417 | } | |
| 418 | schema | |
| 419 | } | |
| 420 | } | |
| 421 | ||
| 422 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 423 | Ok(Outcome::fail(code, message)) | |
| 424 | } | |
| 425 | ||
| 426 | fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> { | |
| 427 | Ok(Outcome::Ok(serde_json::to_value(value)?)) | |
| 428 | } | |
| 429 | ||
| 430 | fn text(input: &Value, key: &str) -> Option<String> { | |
| 431 | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned) | |
| 432 | } | |
| 433 | ||
| 434 | fn flag(input: &Value, key: &str) -> Option<bool> { | |
| 435 | match &input[key] { | |
| 436 | Value::Bool(value) => Some(*value), | |
| 437 | Value::String(text) => match text.trim() { | |
| 438 | "true" | "1" => Some(true), | |
| 439 | "false" | "0" => Some(false), | |
| 440 | _ => None, | |
| 441 | }, | |
| 442 | _ => None, | |
| 443 | } | |
| 444 | } | |
| 445 | ||
| 446 | fn whole(input: &Value, key: &str) -> Option<u32> { | |
| 447 | match &input[key] { | |
| 448 | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 449 | Value::String(digits) => digits.trim().parse().ok(), | |
| 450 | _ => None, | |
| 451 | } | |
| 452 | } | |
| 453 | ||
| 454 | fn strings(input: &Value, key: &str) -> Vec<String> { | |
| 455 | input[key].as_array().map(|items| items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect()).unwrap_or_default() | |
| 456 | } | |
| 457 | ||
| 458 | /// One of `allowed`, or why not. | |
| 459 | fn one_of(input: &Value, key: &str, allowed: &[&str]) -> std::result::Result<Option<String>, String> { | |
| 460 | match text(input, key) { | |
| 461 | None => Ok(None), | |
| 462 | Some(given) => { | |
| 463 | let lower = given.to_lowercase(); | |
| 464 | if allowed.contains(&lower.as_str()) { | |
| 465 | Ok(Some(lower)) | |
| 466 | } else { | |
| 467 | Err(format!("{key} is {}, not {given}.", allowed.join(", "))) | |
| 468 | } | |
| 469 | } | |
| 470 | } | |
| 471 | } | |
| 472 | ||
| 473 | /// Filters for secret scanning alerts. | |
| 474 | #[derive(Debug, Default, PartialEq)] | |
| 475 | pub(crate) struct SecretFilters { | |
| 476 | pub state: Option<AlertState>, | |
| 477 | pub secret_type: Option<String>, | |
| 478 | pub validity: Option<String>, | |
| 479 | pub bypassed: Option<bool>, | |
| 480 | } | |
| 481 | ||
| 482 | pub(crate) fn secret_filters(input: &Value) -> std::result::Result<SecretFilters, String> { | |
| 483 | Ok(SecretFilters { | |
| 484 | state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)), | |
| 485 | secret_type: text(input, "secret_type"), | |
| 486 | validity: one_of(input, "validity", &["active", "inactive", "unknown", "unsupported"])?, | |
| 487 | bypassed: match &input["bypassed"] { | |
| 488 | Value::Null => None, | |
| 489 | _ => Some(flag(input, "bypassed").ok_or("bypassed is true or false.")?), | |
| 490 | }, | |
| 491 | }) | |
| 492 | } | |
| 493 | ||
| 494 | impl SecretFilters { | |
| 495 | pub(crate) fn keeps(&self, secret: &SecretFinding) -> bool { | |
| 496 | self.state.is_none_or(|state| secret.state == state) | |
| 497 | && self.secret_type.as_deref().is_none_or(|kind| secret.kind == kind) | |
| 498 | && self.validity.as_deref().is_none_or(|validity| secret.validity.as_deref().unwrap_or("unknown") == validity) | |
| 499 | && self.bypassed.is_none_or(|bypassed| secret.bypass.is_some() == bypassed) | |
| 500 | } | |
| 501 | } | |
| 502 | ||
| 503 | /// Filters for code scanning alerts. | |
| 504 | #[derive(Debug, Default, PartialEq)] | |
| 505 | pub(crate) struct CodeFilters { | |
| 506 | pub state: Option<AlertState>, | |
| 507 | pub severity: Option<String>, | |
| 508 | pub tool: Option<String>, | |
| 509 | pub rule_id: Option<String>, | |
| 510 | } | |
| 511 | ||
| 512 | pub(crate) fn code_filters(input: &Value) -> std::result::Result<CodeFilters, String> { | |
| 513 | Ok(CodeFilters { | |
| 514 | state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)), | |
| 515 | severity: one_of(input, "severity", &["critical", "high", "medium", "low", "unknown"])?, | |
| 516 | tool: text(input, "tool"), | |
| 517 | rule_id: text(input, "rule_id"), | |
| 518 | }) | |
| 519 | } | |
| 520 | ||
| 521 | impl CodeFilters { | |
| 522 | pub(crate) fn keeps(&self, alert: &CodeAlert) -> bool { | |
| 523 | self.state.is_none_or(|state| alert.state == state) | |
| 524 | && self.severity.as_deref().is_none_or(|severity| alert.severity == severity) | |
| 525 | && self.tool.as_deref().is_none_or(|tool| alert.tool.eq_ignore_ascii_case(tool)) | |
| 526 | && self.rule_id.as_deref().is_none_or(|rule| alert.rule_id == rule) | |
| 527 | } | |
| 528 | } | |
| 529 | ||
| 530 | /// Filters for vulnerability alerts. | |
| 531 | #[derive(Debug, Default, PartialEq)] | |
| 532 | pub(crate) struct VulnerabilityFilters { | |
| 533 | pub state: Option<AlertState>, | |
| 534 | pub severity: Option<String>, | |
| 535 | pub ecosystem: Option<String>, | |
| 536 | pub package: Option<String>, | |
| 537 | } | |
| 538 | ||
| 539 | pub(crate) fn vulnerability_filters(input: &Value) -> std::result::Result<VulnerabilityFilters, String> { | |
| 540 | Ok(VulnerabilityFilters { | |
| 541 | state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)), | |
| 542 | severity: one_of(input, "severity", &["critical", "high", "medium", "low", "unknown"])?, | |
| 543 | ecosystem: text(input, "ecosystem"), | |
| 544 | package: text(input, "package"), | |
| 545 | }) | |
| 546 | } | |
| 547 | ||
| 548 | impl VulnerabilityFilters { | |
| 549 | pub(crate) fn keeps(&self, vuln: &Vulnerability) -> bool { | |
| 550 | self.state.is_none_or(|state| vuln.state == state) | |
| 551 | && self.severity.as_deref().is_none_or(|severity| vuln.severity == severity) | |
| 552 | && self.ecosystem.as_deref().is_none_or(|ecosystem| vuln.ecosystem.eq_ignore_ascii_case(ecosystem)) | |
| 553 | && self.package.as_deref().is_none_or(|package| vuln.package == package) | |
| 554 | } | |
| 555 | } | |
| 556 | ||
| 557 | /// `base...head` (or `base..head`), as compare_dependencies reads it. | |
| 558 | pub(crate) fn base_head(text: &str) -> Option<(String, String)> { | |
| 559 | let (base, head) = text.split_once("...").or_else(|| text.split_once(".."))?; | |
| 560 | let (base, head) = (base.trim(), head.trim()); | |
| 561 | (!base.is_empty() && !head.is_empty()).then(|| (base.to_owned(), head.to_owned())) | |
| 562 | } | |
| 563 | ||
| 564 | /// What dismissing or reopening an alert of `kind` asks: the reason, if | |
| 565 | /// dismissing, checked against the reasons that kind takes. | |
| 566 | pub(crate) fn state_change(input: &Value, reason_key: &str, reasons: &[DismissReason]) -> std::result::Result<Option<DismissReason>, String> { | |
| 567 | match text(input, "state").as_deref() { | |
| 568 | Some("open") => Ok(None), | |
| 569 | Some("dismissed") => { | |
| 570 | let names: Vec<&str> = reasons.iter().map(|reason| reason.as_str()).collect(); | |
| 571 | let given = text(input, reason_key).ok_or_else(|| format!("Give {reason_key}: one of {}.", names.join(", ")))?; | |
| 572 | DismissReason::parse(&given) | |
| 573 | .filter(|reason| reasons.contains(reason)) | |
| 574 | .map(Some) | |
| 575 | .ok_or_else(|| format!("{reason_key} is {}, not {given}.", names.join(", "))) | |
| 576 | } | |
| 577 | _ => Err("state is open or dismissed.".to_owned()), | |
| 578 | } | |
| 579 | } | |
| 580 | ||
| 581 | const SECRET_REASONS: [DismissReason; 4] = [DismissReason::FalsePositive, DismissReason::UsedInTests, DismissReason::Revoked, DismissReason::WontFix]; | |
| 582 | const CODE_REASONS: [DismissReason; 3] = [DismissReason::FalsePositive, DismissReason::WontFix, DismissReason::UsedInTests]; | |
| 583 | const DEPENDENCY_REASONS: [DismissReason; 5] = [ | |
| 584 | DismissReason::FixStarted, | |
| 585 | DismissReason::NoBandwidth, | |
| 586 | DismissReason::TolerableRisk, | |
| 587 | DismissReason::Inaccurate, | |
| 588 | DismissReason::NotUsed, | |
| 589 | ]; | |
| 590 | ||
| 591 | async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> { | |
| 592 | g1t_kit::call(&services.security, method, args).await | |
| 593 | } | |
| 594 | ||
| 595 | /// Passes a service's outcome through as it is. | |
| 596 | async fn pass<A: Serialize>(services: &Services, method: &str, args: &A) -> Result<Outcome<Value>> { | |
| 597 | call(services, method, args).await | |
| 598 | } | |
| 599 | ||
| 600 | fn path_of(input: &Value) -> Option<RepoPath> { | |
| 601 | crate::operations::repo_path(input) | |
| 602 | } | |
| 603 | ||
| 604 | pub async fn run(op: SecurityOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 605 | let actor = || viewer.clone().unwrap_or_default(); | |
| 606 | let repo = path_of(input); | |
| 607 | let workspace = text(input, "workspace").map(|slug| slug.to_lowercase()); | |
| 608 | let need_repo = || "Give the repository as \"owner/name\".".to_owned(); | |
| 609 | // Operations on a repository or a workspace: which. | |
| 610 | let scope_repo = repo.clone(); | |
| 611 | let scope_workspace = || workspace.clone().or_else(|| repo.as_ref().map(|repo| repo.namespace.to_lowercase())); | |
| 612 | match op { | |
| 613 | SecurityOp::ListSecretAlerts => { | |
| 614 | let filters = match secret_filters(input) { | |
| 615 | Ok(filters) => filters, | |
| 616 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 617 | }; | |
| 618 | match (scope_repo, workspace) { | |
| 619 | (Some(repo), _) => { | |
| 620 | let overview: Outcome<SecurityOverview> = | |
| 621 | call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?; | |
| 622 | match overview { | |
| 623 | Outcome::Ok(overview) => { | |
| 624 | let alerts: Vec<SecretFinding> = overview.secrets.into_iter().filter(|secret| filters.keeps(secret)).collect(); | |
| 625 | ok(&alerts) | |
| 626 | } | |
| 627 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 628 | } | |
| 629 | } | |
| 630 | (None, Some(workspace)) => { | |
| 631 | let found: Outcome<Vec<WorkspaceAlert>> = call( | |
| 632 | services, | |
| 633 | "workspace_alerts", | |
| 634 | &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::SecretScanning }, | |
| 635 | ) | |
| 636 | .await?; | |
| 637 | match found { | |
| 638 | Outcome::Ok(found) => { | |
| 639 | let alerts: Vec<WorkspaceAlert> = | |
| 640 | found.into_iter().filter(|alert| alert.secret.as_ref().is_some_and(|secret| filters.keeps(secret))).collect(); | |
| 641 | ok(&alerts) | |
| 642 | } | |
| 643 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 644 | } | |
| 645 | } | |
| 646 | (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."), | |
| 647 | } | |
| 648 | } | |
| 649 | SecurityOp::GetSecretAlert | SecurityOp::ListSecretLocations => { | |
| 650 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 651 | let id = text(input, "id").unwrap_or_default(); | |
| 652 | let detail: Outcome<SecretAlertDetail> = call(services, "secret_alert", &SecretAlertArgs { viewer: viewer.clone(), repo, id }).await?; | |
| 653 | match (detail, op) { | |
| 654 | (Outcome::Ok(detail), SecurityOp::ListSecretLocations) => ok(&detail.locations), | |
| 655 | (Outcome::Ok(detail), _) => ok(&detail), | |
| 656 | (Outcome::Fail(failure), _) => Ok(Outcome::Fail(failure)), | |
| 657 | } | |
| 658 | } | |
| 659 | SecurityOp::UpdateSecretAlert | SecurityOp::UpdateVulnerabilityAlert => { | |
| 660 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 661 | let id = text(input, "id").unwrap_or_default(); | |
| 662 | let (reasons, wants): (&[DismissReason], &str) = match op { | |
| 663 | SecurityOp::UpdateSecretAlert => (&SECRET_REASONS, "sec_"), | |
| 664 | _ => (&DEPENDENCY_REASONS, "vul_"), | |
| 665 | }; | |
| 666 | if !id.starts_with(wants) { | |
| 667 | return failed(FailureCode::NotFound, "No such alert."); | |
| 668 | } | |
| 669 | let reason = match state_change(input, "reason", reasons) { | |
| 670 | Ok(reason) => reason, | |
| 671 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 672 | }; | |
| 673 | let changed: Outcome<AlertChange> = match reason { | |
| 674 | Some(reason) => { | |
| 675 | let comment = text(input, "comment").unwrap_or_default(); | |
| 676 | call(services, "dismiss", &DismissArgs { actor: actor(), repo, id, reason, comment }).await? | |
| 677 | } | |
| 678 | None => call(services, "reopen", &ReopenArgs { actor: actor(), repo, id }).await?, | |
| 679 | }; | |
| 680 | match changed { | |
| 681 | Outcome::Ok(AlertChange { secret: Some(secret), .. }) => ok(&secret), | |
| 682 | Outcome::Ok(AlertChange { vulnerability: Some(vuln), .. }) => ok(&vuln), | |
| 683 | Outcome::Ok(_) => failed(FailureCode::NotFound, "No such alert."), | |
| 684 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 685 | } | |
| 686 | } | |
| 687 | SecurityOp::BypassPushProtection => { | |
| 688 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 689 | let Some(reason) = text(input, "reason").as_deref().and_then(BypassReason::parse) else { | |
| 690 | return failed(FailureCode::Invalid, "reason is false_positive, used_in_tests or will_fix_later."); | |
| 691 | }; | |
| 692 | let args = BypassArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default(), reason, comment: text(input, "comment").unwrap_or_default() }; | |
| 693 | pass(services, "bypass", &args).await | |
| 694 | } | |
| 695 | SecurityOp::CheckSecretValidity => { | |
| 696 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 697 | pass(services, "check_validity", &CheckValidityArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default() }).await | |
| 698 | } | |
| 699 | SecurityOp::ListBypassRequests => { | |
| 700 | let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") }; | |
| 701 | let state = match one_of(input, "state", &["pending", "approved", "denied", "cancelled"]) { | |
| 702 | Ok(state) => state, | |
| 703 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 704 | }; | |
| 705 | pass(services, "bypass_requests", &BypassRequestsArgs { viewer: viewer.clone(), workspace, repo, state }).await | |
| 706 | } | |
| 707 | SecurityOp::ReviewBypassRequest => { | |
| 708 | let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") }; | |
| 709 | let decision = match one_of(input, "decision", &["approve", "deny", "cancel"]) { | |
| 710 | Ok(Some(decision)) => decision, | |
| 711 | Ok(None) => return failed(FailureCode::Invalid, "decision is approve, deny or cancel."), | |
| 712 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 713 | }; | |
| 714 | let args = ReviewBypassArgs { | |
| 715 | actor: actor(), | |
| 716 | workspace, | |
| 717 | id: text(input, "id").unwrap_or_default(), | |
| 718 | decision, | |
| 719 | comment: text(input, "comment").unwrap_or_default(), | |
| 720 | }; | |
| 721 | pass(services, "review_bypass", &args).await | |
| 722 | } | |
| 723 | SecurityOp::ListCustomPatterns => { | |
| 724 | let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") }; | |
| 725 | pass(services, "custom_patterns", &CustomPatternsArgs { viewer: viewer.clone(), workspace, repo: scope_repo }).await | |
| 726 | } | |
| 727 | SecurityOp::CreateCustomPattern | SecurityOp::UpdateCustomPattern => { | |
| 728 | let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") }; | |
| 729 | let args = SaveCustomPatternArgs { | |
| 730 | actor: actor(), | |
| 731 | workspace, | |
| 732 | repo: scope_repo, | |
| 733 | id: if op == SecurityOp::UpdateCustomPattern { text(input, "id") } else { None }, | |
| 734 | name: text(input, "pattern_name").unwrap_or_default(), | |
| 735 | pattern: input["pattern"].as_str().unwrap_or_default().to_owned(), | |
| 736 | before: text(input, "before"), | |
| 737 | after: text(input, "after"), | |
| 738 | test_strings: strings(input, "test_strings"), | |
| 739 | publish: flag(input, "publish").unwrap_or(false), | |
| 740 | }; | |
| 741 | pass(services, "save_custom_pattern", &args).await | |
| 742 | } | |
| 743 | SecurityOp::DeleteCustomPattern => { | |
| 744 | let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") }; | |
| 745 | let args = DeleteCustomPatternArgs { actor: actor(), workspace, repo: scope_repo, id: text(input, "id").unwrap_or_default() }; | |
| 746 | match call::<_, bool>(services, "delete_custom_pattern", &args).await? { | |
| 747 | Outcome::Ok(_) => ok(&json!({ "deleted": true })), | |
| 748 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 749 | } | |
| 750 | } | |
| 751 | SecurityOp::DryRunCustomPattern => { | |
| 752 | let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") }; | |
| 753 | let args = DryRunPatternArgs { | |
| 754 | actor: actor(), | |
| 755 | workspace, | |
| 756 | repo: scope_repo, | |
| 757 | repos: strings(input, "repos"), | |
| 758 | pattern: input["pattern"].as_str().unwrap_or_default().to_owned(), | |
| 759 | before: text(input, "before"), | |
| 760 | after: text(input, "after"), | |
| 761 | }; | |
| 762 | pass(services, "dry_run_pattern", &args).await | |
| 763 | } | |
| 764 | SecurityOp::ListCodeAlerts => { | |
| 765 | let filters = match code_filters(input) { | |
| 766 | Ok(filters) => filters, | |
| 767 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 768 | }; | |
| 769 | match (scope_repo, workspace) { | |
| 770 | (Some(repo), _) => match call::<_, CodeScanning>(services, "code_scanning", &CodeScanningArgs { viewer: viewer.clone(), repo }).await? { | |
| 771 | Outcome::Ok(scanning) => { | |
| 772 | let alerts: Vec<CodeAlert> = scanning.alerts.into_iter().filter(|alert| filters.keeps(alert)).collect(); | |
| 773 | ok(&alerts) | |
| 774 | } | |
| 775 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 776 | }, | |
| 777 | (None, Some(workspace)) => { | |
| 778 | let found: Outcome<Vec<WorkspaceAlert>> = call( | |
| 779 | services, | |
| 780 | "workspace_alerts", | |
| 781 | &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::CodeScanning }, | |
| 782 | ) | |
| 783 | .await?; | |
| 784 | match found { | |
| 785 | Outcome::Ok(found) => { | |
| 786 | let alerts: Vec<WorkspaceAlert> = | |
| 787 | found.into_iter().filter(|alert| alert.code.as_ref().is_some_and(|code| filters.keeps(code))).collect(); | |
| 788 | ok(&alerts) | |
| 789 | } | |
| 790 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 791 | } | |
| 792 | } | |
| 793 | (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."), | |
| 794 | } | |
| 795 | } | |
| 796 | SecurityOp::GetCodeAlert => { | |
| 797 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 798 | pass(services, "code_alert", &CodeAlertArgs { viewer: viewer.clone(), repo, number: whole(input, "number").unwrap_or(0) }).await | |
| 799 | } | |
| 800 | SecurityOp::UpdateCodeAlert => { | |
| 801 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 802 | let reason = match state_change(input, "dismissed_reason", &CODE_REASONS) { | |
| 803 | Ok(reason) => reason, | |
| 804 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 805 | }; | |
| 806 | let args = SetCodeAlertStateArgs { | |
| 807 | actor: actor(), | |
| 808 | repo, | |
| 809 | number: whole(input, "number").unwrap_or(0), | |
| 810 | state: if reason.is_some() { AlertState::Dismissed } else { AlertState::Open }, | |
| 811 | reason, | |
| 812 | comment: text(input, "dismissed_comment").unwrap_or_default(), | |
| 813 | }; | |
| 814 | pass(services, "set_code_alert_state", &args).await | |
| 815 | } | |
| 816 | SecurityOp::ListAnalyses => { | |
| 817 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 818 | match call::<_, CodeScanning>(services, "code_scanning", &CodeScanningArgs { viewer: viewer.clone(), repo }).await? { | |
| 819 | Outcome::Ok(scanning) => ok(&scanning.analyses), | |
| 820 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 821 | } | |
| 822 | } | |
| 823 | SecurityOp::UploadSarif => { | |
| 824 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 825 | let (Some(commit_sha), Some(git_ref), Some(sarif)) = (text(input, "commit_sha"), text(input, "ref"), text(input, "sarif")) else { | |
| 826 | return failed(FailureCode::Invalid, "Give commit_sha, ref and sarif (the file gzipped and base64-encoded)."); | |
| 827 | }; | |
| 828 | if sarif.len() > g1t_scan_limits::MAX_UPLOAD_BYTES { | |
| 829 | return failed(FailureCode::Invalid, "The upload is larger than 10 MB."); | |
| 830 | } | |
| 831 | let args = UploadSarifArgs { | |
| 832 | actor: actor(), | |
| 833 | repo, | |
| 834 | commit_sha, | |
| 835 | git_ref, | |
| 836 | sarif, | |
| 837 | tool_name: text(input, "tool_name"), | |
| 838 | category: text(input, "category"), | |
| 839 | checkout_uri: text(input, "checkout_uri"), | |
| 840 | }; | |
| 841 | pass(services, "upload_sarif", &args).await | |
| 842 | } | |
| 843 | SecurityOp::GetSarifUpload => { | |
| 844 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 845 | pass(services, "sarif_status", &SarifStatusArgs { viewer: viewer.clone(), repo, id: text(input, "id").unwrap_or_default() }).await | |
| 846 | } | |
| 847 | SecurityOp::ListVulnerabilityAlerts => { | |
| 848 | let filters = match vulnerability_filters(input) { | |
| 849 | Ok(filters) => filters, | |
| 850 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 851 | }; | |
| 852 | match (scope_repo, workspace) { | |
| 853 | (Some(repo), _) => { | |
| 854 | let overview: Outcome<SecurityOverview> = | |
| 855 | call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?; | |
| 856 | match overview { | |
| 857 | Outcome::Ok(overview) => { | |
| 858 | let alerts: Vec<Vulnerability> = overview.vulnerabilities.into_iter().filter(|vuln| filters.keeps(vuln)).collect(); | |
| 859 | ok(&alerts) | |
| 860 | } | |
| 861 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 862 | } | |
| 863 | } | |
| 864 | (None, Some(workspace)) => { | |
| 865 | let found: Outcome<Vec<WorkspaceAlert>> = call( | |
| 866 | services, | |
| 867 | "workspace_alerts", | |
| 868 | &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::Vulnerability }, | |
| 869 | ) | |
| 870 | .await?; | |
| 871 | match found { | |
| 872 | Outcome::Ok(found) => { | |
| 873 | let alerts: Vec<WorkspaceAlert> = | |
| 874 | found.into_iter().filter(|alert| alert.vulnerability.as_ref().is_some_and(|vuln| filters.keeps(vuln))).collect(); | |
| 875 | ok(&alerts) | |
| 876 | } | |
| 877 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 878 | } | |
| 879 | } | |
| 880 | (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."), | |
| 881 | } | |
| 882 | } | |
| 883 | SecurityOp::GetVulnerabilityAlert => { | |
| 884 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 885 | let id = text(input, "id").unwrap_or_default(); | |
| 886 | let overview: Outcome<SecurityOverview> = | |
| 887 | call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?; | |
| 888 | match overview { | |
| 889 | Outcome::Ok(overview) => match overview.vulnerabilities.into_iter().find(|vuln| vuln.id == id) { | |
| 890 | Some(vuln) => ok(&vuln), | |
| 891 | None => failed(FailureCode::NotFound, "No such alert."), | |
| 892 | }, | |
| 893 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 894 | } | |
| 895 | } | |
| 896 | SecurityOp::FixAlert => { | |
| 897 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 898 | pass(services, "fix_alert", &FixAlertArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default() }).await | |
| 899 | } | |
| 900 | SecurityOp::GetDependencyGraph => { | |
| 901 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 902 | pass(services, "dependency_graph", &DependencyGraphArgs { viewer: viewer.clone(), repo }).await | |
| 903 | } | |
| 904 | SecurityOp::GetSbom => { | |
| 905 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 906 | // The document goes out as SPDX spells it: `sbom` is passed | |
| 907 | // through untouched (g1t_kit::wire::USER_KEYED). | |
| 908 | match call::<_, Value>(services, "sbom", &SbomArgs { viewer: viewer.clone(), repo }).await? { | |
| 909 | Outcome::Ok(document) => ok(&json!({ "sbom": document })), | |
| 910 | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 911 | } | |
| 912 | } | |
| 913 | SecurityOp::CompareDependencies => { | |
| 914 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 915 | let Some((base, head)) = text(input, "basehead").as_deref().and_then(base_head) else { | |
| 916 | return failed(FailureCode::Invalid, "basehead is base...head, e.g. main...my-branch."); | |
| 917 | }; | |
| 918 | pass(services, "dependency_review", &DependencyReviewArgs { viewer: viewer.clone(), repo, base, head }).await | |
| 919 | } | |
| 920 | SecurityOp::GetSettings => { | |
| 921 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 922 | pass(services, "security_settings", &SecuritySettingsArgs { viewer: viewer.clone(), repo }).await | |
| 923 | } | |
| 924 | SecurityOp::UpdateSettings => { | |
| 925 | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 926 | // What is not given stays as it is. | |
| 927 | let current: Outcome<SecuritySettingsView> = | |
| 928 | call(services, "security_settings", &SecuritySettingsArgs { viewer: viewer.clone(), repo: repo.clone() }).await?; | |
| 929 | let mut settings = match current { | |
| 930 | Outcome::Ok(view) => view.settings, | |
| 931 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 932 | }; | |
| 933 | if let Some(gate) = text(input, "code_scanning_gate") { | |
| 934 | settings.code_scanning_gate = gate.to_lowercase(); | |
| 935 | } | |
| 936 | if let Some(on) = flag(input, "dependency_review") { | |
| 937 | settings.dependency_review = on; | |
| 938 | } | |
| 939 | if let Some(fail_on) = text(input, "review_fail_on") { | |
| 940 | settings.review_fail_on = fail_on.to_lowercase(); | |
| 941 | } | |
| 942 | if input["review_deny_licenses"].is_array() { | |
| 943 | settings.review_deny_licenses = strings(input, "review_deny_licenses"); | |
| 944 | } | |
| 945 | if let Some(on) = flag(input, "review_comment") { | |
| 946 | settings.review_comment = on; | |
| 947 | } | |
| 948 | pass(services, "set_security_settings", &SetSecuritySettingsArgs { actor: actor(), repo, settings }).await | |
| 949 | } | |
| 950 | SecurityOp::GetWorkspaceSettings => { | |
| 951 | let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") }; | |
| 952 | pass(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace }).await | |
| 953 | } | |
| 954 | SecurityOp::UpdateWorkspaceSettings => { | |
| 955 | let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") }; | |
| 956 | let current: Outcome<WorkspaceSecurityView> = | |
| 957 | call(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace: workspace.clone() }).await?; | |
| 958 | let mut settings = match current { | |
| 959 | Outcome::Ok(view) => view.settings, | |
| 960 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 961 | }; | |
| 962 | if let Some(on) = flag(input, "delegated_bypass") { | |
| 963 | settings.delegated_bypass = on; | |
| 964 | } | |
| 965 | if let Some(on) = flag(input, "validity_checks") { | |
| 966 | settings.validity_checks = on; | |
| 967 | } | |
| 968 | pass(services, "set_workspace_security_settings", &SetWorkspaceSecuritySettingsArgs { actor: actor(), workspace, settings }).await | |
| 969 | } | |
| 970 | SecurityOp::GetOverview => { | |
| 971 | let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") }; | |
| 972 | pass(services, "security_overview", &WorkspaceOverviewArgs { viewer: viewer.clone(), workspace, days: whole(input, "days") }).await | |
| 973 | } | |
| 974 | } | |
| 975 | } | |
| 976 | ||
| 977 | /// Limits the API checks before passing an upload on. | |
| 978 | mod g1t_scan_limits { | |
| 979 | /// As the security service's: 10 MB gzipped and base64-encoded. | |
| 980 | pub const MAX_UPLOAD_BYTES: usize = 10 * 1024 * 1024; | |
| 981 | } | |
| 982 | ||
| 983 | #[cfg(test)] | |
| 984 | mod tests { | |
| 985 | use super::*; | |
| 986 | ||
| 987 | #[test] | |
| 988 | fn every_one_is_an_operation() { | |
| 989 | for op in SecurityOp::ALL { | |
| 990 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Security(op)), "{}", op.name()); | |
| 991 | } | |
| 992 | } | |
| 993 | ||
| 994 | #[test] | |
| 995 | fn names_are_unique_and_found_again() { | |
| 996 | let mut names: Vec<&str> = SecurityOp::ALL.iter().map(|op| op.name()).collect(); | |
| 997 | names.sort(); | |
| 998 | names.dedup(); | |
| 999 | assert_eq!(names.len(), SecurityOp::ALL.len()); | |
| 1000 | } | |
| 1001 | ||
| 1002 | #[test] | |
| 1003 | fn secret_filters_are_read_as_words() { | |
| 1004 | let filters = secret_filters(&json!({ "state": "OPEN", "validity": "active", "bypassed": "true", "secret_type": "github_token" })).unwrap(); | |
| 1005 | assert_eq!(filters.state, Some(AlertState::Open)); | |
| 1006 | assert_eq!(filters.validity.as_deref(), Some("active")); | |
| 1007 | assert_eq!(filters.bypassed, Some(true)); | |
| 1008 | assert!(secret_filters(&json!({ "validity": "maybe" })).unwrap_err().contains("validity is active, inactive")); | |
| 1009 | assert!(secret_filters(&json!({ "bypassed": "perhaps" })).is_err()); | |
| 1010 | assert_eq!(secret_filters(&json!({})).unwrap(), SecretFilters::default()); | |
| 1011 | } | |
| 1012 | ||
| 1013 | #[test] | |
| 1014 | fn a_state_change_needs_a_reason_its_kind_takes() { | |
| 1015 | assert_eq!(state_change(&json!({ "state": "open" }), "reason", &SECRET_REASONS), Ok(None)); | |
| 1016 | assert_eq!( | |
| 1017 | state_change(&json!({ "state": "dismissed", "reason": "revoked" }), "reason", &SECRET_REASONS), | |
| 1018 | Ok(Some(DismissReason::Revoked)) | |
| 1019 | ); | |
| 1020 | assert!(state_change(&json!({ "state": "dismissed", "reason": "not_used" }), "reason", &SECRET_REASONS).unwrap_err().contains("reason is false_positive")); | |
| 1021 | assert!(state_change(&json!({ "state": "dismissed" }), "dismissed_reason", &CODE_REASONS).unwrap_err().starts_with("Give dismissed_reason")); | |
| 1022 | assert!(state_change(&json!({ "state": "fixed" }), "reason", &CODE_REASONS).is_err()); | |
| 1023 | } | |
| 1024 | ||
| 1025 | #[test] | |
| 1026 | fn base_and_head_are_split_at_the_dots() { | |
| 1027 | assert_eq!(base_head("main...feature/x"), Some(("main".into(), "feature/x".into()))); | |
| 1028 | assert_eq!(base_head("v1.0..v1.1"), Some(("v1.0".into(), "v1.1".into()))); | |
| 1029 | assert_eq!(base_head("main"), None); | |
| 1030 | assert_eq!(base_head("...head"), None); | |
| 1031 | } | |
| 1032 | ||
| 1033 | #[test] | |
| 1034 | fn code_and_vulnerability_filters_check_their_words() { | |
| 1035 | assert!(code_filters(&json!({ "severity": "severe" })).unwrap_err().contains("severity is critical")); | |
| 1036 | let filters = vulnerability_filters(&json!({ "ecosystem": "npm", "state": "dismissed" })).unwrap(); | |
| 1037 | assert_eq!(filters.state, Some(AlertState::Dismissed)); | |
| 1038 | } | |
| 1039 | ||
| 1040 | #[test] | |
| 1041 | fn a_read_only_token_sees_only_the_security_reads() { | |
| 1042 | use g1t_contracts::scopes::{Scope, scope_for}; | |
| 1043 | for op in SecurityOp::ALL { | |
| 1044 | let scope = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name())); | |
| 1045 | assert!(matches!(scope, Scope::SecurityRead | Scope::SecurityWrite), "{}", op.name()); | |
| 1046 | } | |
| 1047 | assert_eq!(scope_for("get_sbom"), Some(Scope::SecurityRead)); | |
| 1048 | assert_eq!(scope_for("upload_sarif"), Some(Scope::SecurityWrite)); | |
| 1049 | // Fixing an alert also opens an issue and spends agent time. | |
| 1050 | let needed = g1t_contracts::scopes::needed("fix_security_alert", &json!({})); | |
| 1051 | assert_eq!(needed, [Scope::SecurityWrite, Scope::IssuesWrite, Scope::AgentsRun]); | |
| 1052 | // No agent decides about security. | |
| 1053 | for name in ["bypass_push_protection", "review_bypass_request", "update_security_settings", "fix_security_alert"] { | |
| 1054 | assert!(g1t_contracts::credentials::NEVER.contains(&name), "{name}"); | |
| 1055 | } | |
| 1056 | } | |
| 1057 | ||
| 1058 | #[test] | |
| 1059 | fn workspace_wide_operations_do_not_need_a_repository() { | |
| 1060 | for op in [SecurityOp::GetOverview, SecurityOp::ListBypassRequests, SecurityOp::ListCustomPatterns] { | |
| 1061 | assert!(!op.needs_repo()); | |
| 1062 | } | |
| 1063 | assert!(SecurityOp::UploadSarif.needs_repo()); | |
| 1064 | let required = SecurityOp::UploadSarif.input()["required"].clone(); | |
| 1065 | assert_eq!(required, json!(["repo", "commit_sha", "ref", "sarif"])); | |
| 1066 | } | |
| 1067 | } |