Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,276 linesCodeBlameRaw
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, settings, and the workspace's overview.
6//!
7//! The addresses follow the common ones (`/repos/{owner}/{name}/secret-
8//! scanning/alerts`, `/code-scanning/sarifs`, `/dependency-graph/sbom`),
9//! in g1t's spelling: no version prefix, `snake_case` throughout. The
10//! security service decides who may see and change what, and which parts
11//! need the g1t plan, which Security and quality comes with (a 402 says so); this module
12//! reads the input and gives each answer its public shape.
13
14use g1t_contracts::advisories::{
15 Advisory, AdvisoryInput, CreateAdvisoryArgs, GetAdvisoryArgs, ListAdvisoriesArgs, ListReportsArgs, ReportVulnerabilityArgs, ReviewReportArgs,
16 SetAdvisoryStateArgs, UpdateAdvisoryArgs,
17};
18use g1t_contracts::repos::RepoPath;
19use g1t_contracts::security::{AlertChange, AlertState, DismissArgs, DismissReason, ReopenArgs, SecretFinding, SecurityOverview, Vulnerability};
20use g1t_contracts::security_suite::*;
21use g1t_contracts::{FailureCode, Outcome, Viewer};
22use serde::Serialize;
23use serde::de::DeserializeOwned;
24use serde_json::{Value, json};
25use worker::Result;
26
27use crate::operations::Services;
28
29/// One operation of the suite.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum SecurityOp {
32 ListSecretAlerts,
33 GetSecretAlert,
34 UpdateSecretAlert,
35 ListSecretLocations,
36 BypassPushProtection,
37 CheckSecretValidity,
38 ListBypassRequests,
39 ReviewBypassRequest,
40 ListCustomPatterns,
41 CreateCustomPattern,
42 UpdateCustomPattern,
43 DeleteCustomPattern,
44 DryRunCustomPattern,
45 ListCodeAlerts,
46 GetCodeAlert,
47 UpdateCodeAlert,
48 ListAnalyses,
49 UploadSarif,
50 GetSarifUpload,
51 ListVulnerabilityAlerts,
52 GetVulnerabilityAlert,
53 UpdateVulnerabilityAlert,
54 GetDependencyGraph,
55 GetSbom,
56 CompareDependencies,
57 RerunDependencyReview,
58 GetSettings,
59 UpdateSettings,
60 GetWorkspaceSettings,
61 UpdateWorkspaceSettings,
62 GetOverview,
63 ListAdvisories,
64 GetAdvisory,
65 CreateAdvisory,
66 UpdateAdvisory,
67 SetAdvisoryState,
68 ListReports,
69 ReportVulnerability,
70 ReviewReport,
71}
72
73impl SecurityOp {
74 /// Every one: `Op::ALL` lists each as `Op::Security(…)`, which a test
75 /// checks against this.
76 #[cfg(test)]
77 pub const ALL: [SecurityOp; 39] = [
78 SecurityOp::ListSecretAlerts,
79 SecurityOp::GetSecretAlert,
80 SecurityOp::UpdateSecretAlert,
81 SecurityOp::ListSecretLocations,
82 SecurityOp::BypassPushProtection,
83 SecurityOp::CheckSecretValidity,
84 SecurityOp::ListBypassRequests,
85 SecurityOp::ReviewBypassRequest,
86 SecurityOp::ListCustomPatterns,
87 SecurityOp::CreateCustomPattern,
88 SecurityOp::UpdateCustomPattern,
89 SecurityOp::DeleteCustomPattern,
90 SecurityOp::DryRunCustomPattern,
91 SecurityOp::ListCodeAlerts,
92 SecurityOp::GetCodeAlert,
93 SecurityOp::UpdateCodeAlert,
94 SecurityOp::ListAnalyses,
95 SecurityOp::UploadSarif,
96 SecurityOp::GetSarifUpload,
97 SecurityOp::ListVulnerabilityAlerts,
98 SecurityOp::GetVulnerabilityAlert,
99 SecurityOp::UpdateVulnerabilityAlert,
100 SecurityOp::GetDependencyGraph,
101 SecurityOp::GetSbom,
102 SecurityOp::CompareDependencies,
103 SecurityOp::RerunDependencyReview,
104 SecurityOp::GetSettings,
105 SecurityOp::UpdateSettings,
106 SecurityOp::GetWorkspaceSettings,
107 SecurityOp::UpdateWorkspaceSettings,
108 SecurityOp::GetOverview,
109 SecurityOp::ListAdvisories,
110 SecurityOp::GetAdvisory,
111 SecurityOp::CreateAdvisory,
112 SecurityOp::UpdateAdvisory,
113 SecurityOp::SetAdvisoryState,
114 SecurityOp::ListReports,
115 SecurityOp::ReportVulnerability,
116 SecurityOp::ReviewReport,
117 ];
118
119 pub fn name(self) -> &'static str {
120 match self {
121 SecurityOp::ListSecretAlerts => "list_secret_scanning_alerts",
122 SecurityOp::GetSecretAlert => "get_secret_scanning_alert",
123 SecurityOp::UpdateSecretAlert => "update_secret_scanning_alert",
124 SecurityOp::ListSecretLocations => "list_secret_scanning_locations",
125 SecurityOp::BypassPushProtection => "bypass_push_protection",
126 SecurityOp::CheckSecretValidity => "check_secret_validity",
127 SecurityOp::ListBypassRequests => "list_bypass_requests",
128 SecurityOp::ReviewBypassRequest => "review_bypass_request",
129 SecurityOp::ListCustomPatterns => "list_custom_patterns",
130 SecurityOp::CreateCustomPattern => "create_custom_pattern",
131 SecurityOp::UpdateCustomPattern => "update_custom_pattern",
132 SecurityOp::DeleteCustomPattern => "delete_custom_pattern",
133 SecurityOp::DryRunCustomPattern => "dry_run_custom_pattern",
134 SecurityOp::ListCodeAlerts => "list_code_scanning_alerts",
135 SecurityOp::GetCodeAlert => "get_code_scanning_alert",
136 SecurityOp::UpdateCodeAlert => "update_code_scanning_alert",
137 SecurityOp::ListAnalyses => "list_code_scanning_analyses",
138 SecurityOp::UploadSarif => "upload_sarif",
139 SecurityOp::GetSarifUpload => "get_sarif_upload",
140 SecurityOp::ListVulnerabilityAlerts => "list_vulnerability_alerts",
141 SecurityOp::GetVulnerabilityAlert => "get_vulnerability_alert",
142 SecurityOp::UpdateVulnerabilityAlert => "update_vulnerability_alert",
143 SecurityOp::GetDependencyGraph => "get_dependency_graph",
144 SecurityOp::GetSbom => "get_sbom",
145 SecurityOp::CompareDependencies => "compare_dependencies",
146 SecurityOp::RerunDependencyReview => "rerun_dependency_review",
147 SecurityOp::GetSettings => "get_security_settings",
148 SecurityOp::UpdateSettings => "update_security_settings",
149 SecurityOp::GetWorkspaceSettings => "get_workspace_security_settings",
150 SecurityOp::UpdateWorkspaceSettings => "update_workspace_security_settings",
151 SecurityOp::GetOverview => "get_security_overview",
152 SecurityOp::ListAdvisories => "list_security_advisories",
153 SecurityOp::GetAdvisory => "get_security_advisory",
154 SecurityOp::CreateAdvisory => "create_security_advisory",
155 SecurityOp::UpdateAdvisory => "update_security_advisory",
156 SecurityOp::SetAdvisoryState => "set_security_advisory_state",
157 SecurityOp::ListReports => "list_vulnerability_reports",
158 SecurityOp::ReportVulnerability => "report_vulnerability",
159 SecurityOp::ReviewReport => "review_vulnerability_report",
160 }
161 }
162
163 /// For the API reference: "List secret scanning alerts".
164 pub fn title(self) -> &'static str {
165 match self {
166 SecurityOp::ListSecretAlerts => "List secret scanning alerts",
167 SecurityOp::GetSecretAlert => "Get a secret scanning alert",
168 SecurityOp::UpdateSecretAlert => "Dismiss or reopen a secret scanning alert",
169 SecurityOp::ListSecretLocations => "List where a secret was found",
170 SecurityOp::BypassPushProtection => "Bypass push protection",
171 SecurityOp::CheckSecretValidity => "Check whether a secret still works",
172 SecurityOp::ListBypassRequests => "List push protection bypass requests",
173 SecurityOp::ReviewBypassRequest => "Review a bypass request",
174 SecurityOp::ListCustomPatterns => "List custom patterns",
175 SecurityOp::CreateCustomPattern => "Create a custom pattern",
176 SecurityOp::UpdateCustomPattern => "Update a custom pattern",
177 SecurityOp::DeleteCustomPattern => "Delete a custom pattern",
178 SecurityOp::DryRunCustomPattern => "Dry-run a custom pattern",
179 SecurityOp::ListCodeAlerts => "List code scanning alerts",
180 SecurityOp::GetCodeAlert => "Get a code scanning alert",
181 SecurityOp::UpdateCodeAlert => "Dismiss or reopen a code scanning alert",
182 SecurityOp::ListAnalyses => "List code scanning analyses",
183 SecurityOp::UploadSarif => "Upload a SARIF file",
184 SecurityOp::GetSarifUpload => "Get a SARIF upload",
185 SecurityOp::ListVulnerabilityAlerts => "List vulnerability alerts",
186 SecurityOp::GetVulnerabilityAlert => "Get a vulnerability alert",
187 SecurityOp::UpdateVulnerabilityAlert => "Dismiss or reopen a vulnerability alert",
188 SecurityOp::GetDependencyGraph => "Get the dependency graph",
189 SecurityOp::GetSbom => "Export an SBOM",
190 SecurityOp::CompareDependencies => "Compare dependencies",
191 SecurityOp::RerunDependencyReview => "Re-run dependency review",
192 SecurityOp::GetSettings => "Get a repository's security settings",
193 SecurityOp::UpdateSettings => "Update a repository's security settings",
194 SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings",
195 SecurityOp::UpdateWorkspaceSettings => "Update a workspace's security settings",
196 SecurityOp::GetOverview => "Get the security overview",
197 SecurityOp::ListAdvisories => "List security advisories",
198 SecurityOp::GetAdvisory => "Get a security advisory",
199 SecurityOp::CreateAdvisory => "Create a security advisory",
200 SecurityOp::UpdateAdvisory => "Update a security advisory",
201 SecurityOp::SetAdvisoryState => "Publish or withdraw a security advisory",
202 SecurityOp::ListReports => "List vulnerability reports",
203 SecurityOp::ReportVulnerability => "Report a vulnerability privately",
204 SecurityOp::ReviewReport => "Accept or decline a vulnerability report",
205 }
206 }
207
208 pub fn description(self) -> &'static str {
209 match self {
210 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.",
211 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.",
212 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 Write role, or a security manager of the workspace.",
213 SecurityOp::ListSecretLocations => "List every place a secret was found: file, line, commit and whether a push or the history scan found it.",
214 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.",
215 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 g1t plan on a private repository). Formats with no safe check answer unsupported.",
216 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.",
217 SecurityOp::ReviewBypassRequest => "Approve or deny a bypass request (owners, security managers 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.",
218 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).",
219 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 g1t plan.",
220 SecurityOp::UpdateCustomPattern => "Change a custom pattern, publish it, or turn it back into a draft (publish false). Returns where it matched each test string.",
221 SecurityOp::DeleteCustomPattern => "Delete a custom pattern. Alerts it found stay.",
222 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.",
223 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.",
224 SecurityOp::GetCodeAlert => "Get one code scanning alert by number, with its rule, location, activity and the analyses that reported it.",
225 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.",
226 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.",
227 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 g1t plan on a private repository.",
228 SecurityOp::GetSarifUpload => "Get a SARIF upload by id (sar_…): whether it was read, the analyses it made, and what was wrong.",
229 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.",
230 SecurityOp::GetVulnerabilityAlert => "Get one vulnerability alert by id (vul_…).",
231 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).",
232 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.",
233 SecurityOp::GetSbom => "Export the dependency graph as an SPDX 2.3 JSON document, in sbom. Every package is named by its package URL.",
234 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.",
235 SecurityOp::RerunDependencyReview => "Run the Dependency review check again on an open pull request's head, with the advisories known now: its status is set again and its one comment on the pull request is changed in place (or added, when it has dependency changes to show and none is there yet). Returns the review. g1t reviews a pull request by itself when it opens and when its head moves, carrying the result over when the head's lockfile changes are the ones reviewed before. Takes the Write role. Needs the g1t plan on a private repository.",
236 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.",
237 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.",
238 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.",
239 SecurityOp::UpdateWorkspaceSettings => "Turn delegated bypass and validity checks on or off for a workspace. Owners only.",
240 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.",
241 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).",
242 SecurityOp::GetAdvisory => "Get one security advisory by its adv_… id or its G1TSA-… id, with the same visibility as the list.",
243 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.",
244 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.",
245 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.",
246 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.",
247 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.",
248 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.",
249 }
250 }
251
252 /// Whether the operation is about one repository named by `repo`
253 /// (rather than a workspace, or either).
254 pub fn needs_repo(self) -> bool {
255 !matches!(
256 self,
257 SecurityOp::ListSecretAlerts
258 | SecurityOp::ListCodeAlerts
259 | SecurityOp::ListVulnerabilityAlerts
260 | SecurityOp::ListBypassRequests
261 | SecurityOp::ReviewBypassRequest
262 | SecurityOp::ListCustomPatterns
263 | SecurityOp::CreateCustomPattern
264 | SecurityOp::UpdateCustomPattern
265 | SecurityOp::DeleteCustomPattern
266 | SecurityOp::DryRunCustomPattern
267 | SecurityOp::GetWorkspaceSettings
268 | SecurityOp::UpdateWorkspaceSettings
269 | SecurityOp::GetOverview
270 )
271 }
272
273 pub fn input(self) -> Value {
274 let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
275 let either = |mut properties: Value| {
276 properties["repo"] = json!({ "type": "string", "description": "Repository as \"owner/name\". Or give workspace." });
277 properties["workspace"] = json!({ "type": "string", "description": "Instead of repo: the workspace's slug, for all of it (or its own, for patterns)." });
278 properties
279 };
280 let secret_id = || json!({ "type": "string", "description": "The alert's id: sec_…" });
281 let number = || json!({ "type": "integer", "description": "The code scanning alert's number." });
282 let state = || json!({ "type": "string", "enum": ["open", "dismissed", "fixed"], "description": "Only alerts in this state." });
283 let severity = || json!({ "type": "string", "enum": ["critical", "high", "medium", "low", "unknown"], "description": "Only alerts of this severity." });
284 let set_state = || json!({ "type": "string", "enum": ["open", "dismissed"], "description": "dismissed, with a reason, or open to reopen." });
285 let comment = || json!({ "type": "string", "description": "Why, in a sentence; kept with the alert. At most 500 characters." });
286 let pattern_fields = |mut properties: Value| {
287 properties["pattern_name"] = json!({ "type": "string", "description": "What people call it: \"Acme API key\"." });
288 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." });
289 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." });
290 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." });
291 properties
292 };
293 let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." });
294 let advisory_fields = |mut properties: Value| {
295 properties["title"] = json!({ "type": "string", "description": "One line: what the vulnerability is. At most 200 characters." });
296 properties["description"] = json!({ "type": "string", "description": "Markdown: what is affected, how it is exploited, what to do. At most 20,000 characters." });
297 properties["severity"] = json!({ "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad it is." });
298 properties["cvss_vector"] = json!({ "type": "string", "description": "A CVSS vector string, when scored: CVSS:3.1/AV:N/…" });
299 properties["cvss_score"] = json!({ "type": "number", "description": "The CVSS base score, 0 to 10." });
300 properties["cve_id"] = json!({ "type": "string", "description": "CVE-2026-12345, when one was issued elsewhere." });
301 properties["ecosystem"] = json!({ "type": "string", "description": "How the affected package is distributed: npm, crates.io, Go, PyPI, or how the project ships." });
302 properties["package"] = json!({ "type": "string", "description": "The package affected." });
303 properties["affected_versions"] = json!({ "type": "string", "description": "The versions affected, as a range: >= 1.2.0, < 1.4.1" });
304 properties["patched_versions"] = json!({ "type": "string", "description": "The versions that fix it: >= 1.4.1" });
305 properties["credits"] = json!({ "type": "array", "items": { "type": "string" }, "description": "Who found or fixed it: usernames or names, up to 20." });
306 properties
307 };
308 let advisory_id = || json!({ "type": "string", "description": "The advisory's id: adv_…, or its G1TSA-… id." });
309 let (properties, required): (Value, &[&str]) = match self {
310 SecurityOp::ListSecretAlerts => (
311 either(json!({
312 "state": state(),
313 "secret_type": { "type": "string", "description": "Only this kind of secret: aws_access_key, github_token, custom_pattern, …" },
314 "validity": { "type": "string", "enum": ["active", "inactive", "unknown", "unsupported"], "description": "Only alerts whose issuer said this when last asked." },
315 "bypassed": { "type": "boolean", "description": "Only alerts someone bypassed push protection for (true), or not (false)." },
316 })),
317 &[],
318 ),
319 SecurityOp::GetSecretAlert | SecurityOp::ListSecretLocations | SecurityOp::CheckSecretValidity => {
320 (json!({ "repo": repo(), "id": secret_id() }), &["repo", "id"])
321 }
322 SecurityOp::UpdateSecretAlert => (
323 json!({
324 "repo": repo(),
325 "id": secret_id(),
326 "state": set_state(),
327 "reason": { "type": "string", "enum": ["false_positive", "used_in_tests", "revoked", "wont_fix"], "description": "Why it is dismissed. revoked marks it fixed." },
328 "comment": comment(),
329 }),
330 &["repo", "id", "state"],
331 ),
332 SecurityOp::BypassPushProtection => (
333 json!({
334 "repo": repo(),
335 "id": secret_id(),
336 "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)." },
337 "comment": comment(),
338 }),
339 &["repo", "id", "reason"],
340 ),
341 SecurityOp::ListBypassRequests => (
342 json!({
343 "workspace": workspace(),
344 "repo": { "type": "string", "description": "Only this repository's, as \"owner/name\"." },
345 "state": { "type": "string", "enum": ["pending", "approved", "denied", "cancelled"], "description": "Only requests in this state." },
346 }),
347 &["workspace"],
348 ),
349 SecurityOp::ReviewBypassRequest => (
350 json!({
351 "workspace": workspace(),
352 "id": { "type": "string", "description": "The request's id: byp_…" },
353 "decision": { "type": "string", "enum": ["approve", "deny", "cancel"], "description": "approve or deny (reviewers), or cancel (your own)." },
354 "comment": comment(),
355 }),
356 &["workspace", "id", "decision"],
357 ),
358 SecurityOp::ListCustomPatterns => (either(json!({})), &[]),
359 SecurityOp::CreateCustomPattern => (
360 either(pattern_fields(json!({
361 "test_strings": { "type": "array", "items": { "type": "string" }, "description": "Up to 20 strings to show the pattern working on." },
362 "publish": { "type": "boolean", "description": "Use it in push protection and scans now (true), or keep a draft (false, the default)." },
363 }))),
364 &["pattern_name", "pattern"],
365 ),
366 SecurityOp::UpdateCustomPattern => (
367 either(pattern_fields(json!({
368 "id": { "type": "string", "description": "The pattern's id: pat_…" },
369 "test_strings": { "type": "array", "items": { "type": "string" }, "description": "Up to 20 strings to show the pattern working on." },
370 "publish": { "type": "boolean", "description": "Published (true) or a draft (false)." },
371 }))),
372 &["id", "pattern_name", "pattern"],
373 ),
374 SecurityOp::DeleteCustomPattern => (either(json!({ "id": { "type": "string", "description": "The pattern's id: pat_…" } })), &["id"]),
375 SecurityOp::DryRunCustomPattern => (
376 either(pattern_fields(json!({
377 "repos": { "type": "array", "items": { "type": "string" }, "description": "With workspace: repository names to run it on; the first ten when empty." },
378 }))),
379 &["pattern"],
380 ),
381 SecurityOp::ListCodeAlerts => (
382 either(json!({
383 "state": state(),
384 "severity": severity(),
385 "tool": { "type": "string", "description": "Only this tool's: \"ESLint\"." },
386 "rule_id": { "type": "string", "description": "Only this rule's." },
387 })),
388 &[],
389 ),
390 SecurityOp::GetCodeAlert => (json!({ "repo": repo(), "number": number() }), &["repo", "number"]),
391 SecurityOp::UpdateCodeAlert => (
392 json!({
393 "repo": repo(),
394 "number": number(),
395 "state": set_state(),
396 "dismissed_reason": { "type": "string", "enum": ["false_positive", "wont_fix", "used_in_tests"], "description": "Why it is dismissed." },
397 "dismissed_comment": comment(),
398 }),
399 &["repo", "number", "state"],
400 ),
401 SecurityOp::ListAnalyses => (json!({ "repo": repo() }), &["repo"]),
402 SecurityOp::UploadSarif => (
403 json!({
404 "repo": repo(),
405 "commit_sha": { "type": "string", "description": "The full hash of the commit analysed." },
406 "ref": { "type": "string", "description": "refs/heads/<branch>, or refs/pull/<number>/head (or /merge) for a pull request." },
407 "sarif": { "type": "string", "description": "The SARIF 2.1.0 file, gzipped, then base64-encoded. At most 10 MB encoded and 40 MB unzipped." },
408 "tool_name": { "type": "string", "description": "The tool's name, when the file has one run and you want another name for it." },
409 "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." },
410 "checkout_uri": { "type": "string", "description": "Where the files were checked out (file:///home/runner/work/repo), so absolute paths become repository paths." },
411 }),
412 &["repo", "commit_sha", "ref", "sarif"],
413 ),
414 SecurityOp::GetSarifUpload => (json!({ "repo": repo(), "id": { "type": "string", "description": "The upload's id: sar_…" } }), &["repo", "id"]),
415 SecurityOp::ListVulnerabilityAlerts => (
416 either(json!({
417 "state": state(),
418 "severity": severity(),
419 "ecosystem": { "type": "string", "description": "Only this ecosystem's: npm, crates.io, Go or PyPI." },
420 "package": { "type": "string", "description": "Only this package's." },
421 })),
422 &[],
423 ),
424 SecurityOp::GetVulnerabilityAlert => (json!({ "repo": repo(), "id": { "type": "string", "description": "The alert's id: vul_…" } }), &["repo", "id"]),
425 SecurityOp::UpdateVulnerabilityAlert => (
426 json!({
427 "repo": repo(),
428 "id": { "type": "string", "description": "The alert's id: vul_…" },
429 "state": set_state(),
430 "reason": { "type": "string", "enum": ["fix_started", "no_bandwidth", "tolerable_risk", "inaccurate", "not_used"], "description": "Why it is dismissed." },
431 "comment": comment(),
432 }),
433 &["repo", "id", "state"],
434 ),
435 SecurityOp::GetDependencyGraph | SecurityOp::GetSbom | SecurityOp::GetSettings => (json!({ "repo": repo() }), &["repo"]),
436 SecurityOp::RerunDependencyReview => (
437 json!({ "repo": repo(), "number": { "type": "integer", "description": "The pull request's number." } }),
438 &["repo", "number"],
439 ),
440 SecurityOp::CompareDependencies => (
441 json!({
442 "repo": repo(),
443 "basehead": { "type": "string", "description": "base...head: two commits, branches or tags, e.g. main...my-branch." },
444 }),
445 &["repo", "basehead"],
446 ),
447 SecurityOp::UpdateSettings => (
448 json!({
449 "repo": repo(),
450 "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)." },
451 "dependency_review": { "type": "boolean", "description": "Whether pull requests get the Dependency review check." },
452 "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." },
453 "review_deny_licenses": { "type": "array", "items": { "type": "string" }, "description": "SPDX license ids an added package may not have." },
454 "review_comment": { "type": "boolean", "description": "Whether the review comments its summary on the pull request." },
455 "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." },
456 "private_vulnerability_reporting": { "type": "boolean", "description": "Whether anyone who can see the repository may report a vulnerability privately to its maintainers." },
457 }),
458 &["repo"],
459 ),
460 SecurityOp::GetWorkspaceSettings => (json!({ "workspace": workspace() }), &["workspace"]),
461 SecurityOp::UpdateWorkspaceSettings => (
462 json!({
463 "workspace": workspace(),
464 "delegated_bypass": { "type": "boolean", "description": "Bypasses need an owner's or the repository's admins' approval." },
465 "validity_checks": { "type": "boolean", "description": "Ask issuers whether secrets still work, where that can be done safely." },
466 }),
467 &["workspace"],
468 ),
469 SecurityOp::GetOverview => (
470 json!({ "workspace": workspace(), "days": { "type": "integer", "description": "Days of trend, 7 to 90. Default 30." } }),
471 &["workspace"],
472 ),
473 SecurityOp::ListAdvisories => (
474 json!({ "repo": repo(), "state": { "type": "string", "enum": ["draft", "published", "withdrawn"], "description": "Only advisories in this state." } }),
475 &["repo"],
476 ),
477 SecurityOp::GetAdvisory => (json!({ "repo": repo(), "id": advisory_id() }), &["repo", "id"]),
478 SecurityOp::CreateAdvisory => (advisory_fields(json!({ "repo": repo() })), &["repo", "title", "severity"]),
479 SecurityOp::UpdateAdvisory => (advisory_fields(json!({ "repo": repo(), "id": advisory_id() })), &["repo", "id"]),
480 SecurityOp::SetAdvisoryState => (
481 json!({
482 "repo": repo(),
483 "id": advisory_id(),
484 "state": { "type": "string", "enum": ["published", "withdrawn"], "description": "published to publish (or publish again), withdrawn to withdraw." },
485 }),
486 &["repo", "id", "state"],
487 ),
488 SecurityOp::ListReports => (
489 json!({ "repo": repo(), "state": { "type": "string", "enum": ["new", "accepted", "declined"], "description": "Only reports in this state." } }),
490 &["repo"],
491 ),
492 SecurityOp::ReportVulnerability => (
493 json!({
494 "repo": repo(),
495 "title": { "type": "string", "description": "One line: what you found. At most 200 characters." },
496 "description": { "type": "string", "description": "What is affected and how to reproduce it. At most 20,000 characters." },
497 "severity": { "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad you think it is, if you like." },
498 "email": { "type": "string", "description": "Without a token: where the maintainers can answer you." },
499 }),
500 &["repo", "title", "description"],
501 ),
502 SecurityOp::ReviewReport => (
503 json!({
504 "repo": repo(),
505 "id": { "type": "string", "description": "The report's id: vrp_…" },
506 "decision": { "type": "string", "enum": ["accept", "decline"], "description": "accept makes a draft advisory from it; decline closes it." },
507 "note": { "type": "string", "description": "A note for the record, in a sentence. At most 1,000 characters." },
508 }),
509 &["repo", "id", "decision"],
510 ),
511 };
512 let mut schema = json!({ "type": "object", "properties": properties });
513 if !required.is_empty() {
514 schema["required"] = json!(required);
515 }
516 schema
517 }
518}
519
520fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
521 Ok(Outcome::fail(code, message))
522}
523
524fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
525 Ok(Outcome::Ok(serde_json::to_value(value)?))
526}
527
528fn text(input: &Value, key: &str) -> Option<String> {
529 input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned)
530}
531
532fn flag(input: &Value, key: &str) -> Option<bool> {
533 match &input[key] {
534 Value::Bool(value) => Some(*value),
535 Value::String(text) => match text.trim() {
536 "true" | "1" => Some(true),
537 "false" | "0" => Some(false),
538 _ => None,
539 },
540 _ => None,
541 }
542}
543
544fn whole(input: &Value, key: &str) -> Option<u32> {
545 match &input[key] {
546 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
547 Value::String(digits) => digits.trim().parse().ok(),
548 _ => None,
549 }
550}
551
552fn strings(input: &Value, key: &str) -> Vec<String> {
553 input[key].as_array().map(|items| items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect()).unwrap_or_default()
554}
555
556/// One of `allowed`, or why not.
557fn one_of(input: &Value, key: &str, allowed: &[&str]) -> std::result::Result<Option<String>, String> {
558 match text(input, key) {
559 None => Ok(None),
560 Some(given) => {
561 let lower = given.to_lowercase();
562 if allowed.contains(&lower.as_str()) {
563 Ok(Some(lower))
564 } else {
565 Err(format!("{key} is {}, not {given}.", allowed.join(", ")))
566 }
567 }
568 }
569}
570
571/// Filters for secret scanning alerts.
572#[derive(Debug, Default, PartialEq)]
573pub(crate) struct SecretFilters {
574 pub state: Option<AlertState>,
575 pub secret_type: Option<String>,
576 pub validity: Option<String>,
577 pub bypassed: Option<bool>,
578}
579
580pub(crate) fn secret_filters(input: &Value) -> std::result::Result<SecretFilters, String> {
581 Ok(SecretFilters {
582 state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)),
583 secret_type: text(input, "secret_type"),
584 validity: one_of(input, "validity", &["active", "inactive", "unknown", "unsupported"])?,
585 bypassed: match &input["bypassed"] {
586 Value::Null => None,
587 _ => Some(flag(input, "bypassed").ok_or("bypassed is true or false.")?),
588 },
589 })
590}
591
592impl SecretFilters {
593 pub(crate) fn keeps(&self, secret: &SecretFinding) -> bool {
594 self.state.is_none_or(|state| secret.state == state)
595 && self.secret_type.as_deref().is_none_or(|kind| secret.kind == kind)
596 && self.validity.as_deref().is_none_or(|validity| secret.validity.as_deref().unwrap_or("unknown") == validity)
597 && self.bypassed.is_none_or(|bypassed| secret.bypass.is_some() == bypassed)
598 }
599}
600
601/// Filters for code scanning alerts.
602#[derive(Debug, Default, PartialEq)]
603pub(crate) struct CodeFilters {
604 pub state: Option<AlertState>,
605 pub severity: Option<String>,
606 pub tool: Option<String>,
607 pub rule_id: Option<String>,
608}
609
610pub(crate) fn code_filters(input: &Value) -> std::result::Result<CodeFilters, String> {
611 Ok(CodeFilters {
612 state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)),
613 severity: one_of(input, "severity", &["critical", "high", "medium", "low", "unknown"])?,
614 tool: text(input, "tool"),
615 rule_id: text(input, "rule_id"),
616 })
617}
618
619impl CodeFilters {
620 pub(crate) fn keeps(&self, alert: &CodeAlert) -> bool {
621 self.state.is_none_or(|state| alert.state == state)
622 && self.severity.as_deref().is_none_or(|severity| alert.severity == severity)
623 && self.tool.as_deref().is_none_or(|tool| alert.tool.eq_ignore_ascii_case(tool))
624 && self.rule_id.as_deref().is_none_or(|rule| alert.rule_id == rule)
625 }
626}
627
628/// Filters for vulnerability alerts.
629#[derive(Debug, Default, PartialEq)]
630pub(crate) struct VulnerabilityFilters {
631 pub state: Option<AlertState>,
632 pub severity: Option<String>,
633 pub ecosystem: Option<String>,
634 pub package: Option<String>,
635}
636
637pub(crate) fn vulnerability_filters(input: &Value) -> std::result::Result<VulnerabilityFilters, String> {
638 Ok(VulnerabilityFilters {
639 state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)),
640 severity: one_of(input, "severity", &["critical", "high", "medium", "low", "unknown"])?,
641 ecosystem: text(input, "ecosystem"),
642 package: text(input, "package"),
643 })
644}
645
646impl VulnerabilityFilters {
647 pub(crate) fn keeps(&self, vuln: &Vulnerability) -> bool {
648 self.state.is_none_or(|state| vuln.state == state)
649 && self.severity.as_deref().is_none_or(|severity| vuln.severity == severity)
650 && self.ecosystem.as_deref().is_none_or(|ecosystem| vuln.ecosystem.eq_ignore_ascii_case(ecosystem))
651 && self.package.as_deref().is_none_or(|package| vuln.package == package)
652 }
653}
654
655/// `base...head` (or `base..head`), as compare_dependencies reads it.
656pub(crate) fn base_head(text: &str) -> Option<(String, String)> {
657 let (base, head) = text.split_once("...").or_else(|| text.split_once(".."))?;
658 let (base, head) = (base.trim(), head.trim());
659 (!base.is_empty() && !head.is_empty()).then(|| (base.to_owned(), head.to_owned()))
660}
661
662/// What dismissing or reopening an alert of `kind` asks: the reason, if
663/// dismissing, checked against the reasons that kind takes.
664pub(crate) fn state_change(input: &Value, reason_key: &str, reasons: &[DismissReason]) -> std::result::Result<Option<DismissReason>, String> {
665 match text(input, "state").as_deref() {
666 Some("open") => Ok(None),
667 Some("dismissed") => {
668 let names: Vec<&str> = reasons.iter().map(|reason| reason.as_str()).collect();
669 let given = text(input, reason_key).ok_or_else(|| format!("Give {reason_key}: one of {}.", names.join(", ")))?;
670 DismissReason::parse(&given)
671 .filter(|reason| reasons.contains(reason))
672 .map(Some)
673 .ok_or_else(|| format!("{reason_key} is {}, not {given}.", names.join(", ")))
674 }
675 _ => Err("state is open or dismissed.".to_owned()),
676 }
677}
678
679const SECRET_REASONS: [DismissReason; 4] = [DismissReason::FalsePositive, DismissReason::UsedInTests, DismissReason::Revoked, DismissReason::WontFix];
680const CODE_REASONS: [DismissReason; 3] = [DismissReason::FalsePositive, DismissReason::WontFix, DismissReason::UsedInTests];
681const DEPENDENCY_REASONS: [DismissReason; 5] = [
682 DismissReason::FixStarted,
683 DismissReason::NoBandwidth,
684 DismissReason::TolerableRisk,
685 DismissReason::Inaccurate,
686 DismissReason::NotUsed,
687];
688
689async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> {
690 g1t_kit::call(&services.security, method, args).await
691}
692
693/// Passes a service's outcome through as it is.
694async fn pass<A: Serialize>(services: &Services, method: &str, args: &A) -> Result<Outcome<Value>> {
695 call(services, method, args).await
696}
697
698fn path_of(input: &Value) -> Option<RepoPath> {
699 crate::operations::repo_path(input)
700}
701
702/// An advisory's fields as given, over `current` for what is not.
703pub(crate) fn advisory_input(input: &Value, current: &AdvisoryInput) -> AdvisoryInput {
704 let given = |key: &str, now: &Option<String>| if input.get(key).is_some() { text(input, key) } else { now.clone() };
705 AdvisoryInput {
706 title: text(input, "title").unwrap_or_else(|| current.title.clone()),
707 description: input["description"].as_str().map(str::to_owned).unwrap_or_else(|| current.description.clone()),
708 severity: text(input, "severity").map(|severity| severity.to_lowercase()).unwrap_or_else(|| current.severity.clone()),
709 cvss_vector: given("cvss_vector", &current.cvss_vector),
710 cvss_score: if input.get("cvss_score").is_some() { input["cvss_score"].as_f64() } else { current.cvss_score },
711 cve_id: given("cve_id", &current.cve_id),
712 ecosystem: given("ecosystem", &current.ecosystem),
713 package: given("package", &current.package),
714 affected_versions: given("affected_versions", &current.affected_versions),
715 patched_versions: given("patched_versions", &current.patched_versions),
716 credits: if input["credits"].is_array() { strings(input, "credits") } else { current.credits.clone() },
717 }
718}
719
720/// An advisory as it stands, as input to change it from.
721fn advisory_as_input(advisory: &Advisory) -> AdvisoryInput {
722 AdvisoryInput {
723 title: advisory.title.clone(),
724 description: advisory.description.clone(),
725 severity: advisory.severity.clone(),
726 cvss_vector: advisory.cvss_vector.clone(),
727 cvss_score: advisory.cvss_score,
728 cve_id: advisory.cve_id.clone(),
729 ecosystem: advisory.ecosystem.clone(),
730 package: advisory.package.clone(),
731 affected_versions: advisory.affected_versions.clone(),
732 patched_versions: advisory.patched_versions.clone(),
733 credits: advisory.credits.clone(),
734 }
735}
736
737pub async fn run(op: SecurityOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
738 let actor = || viewer.clone().unwrap_or_default();
739 let repo = path_of(input);
740 let workspace = text(input, "workspace").map(|slug| slug.to_lowercase());
741 let need_repo = || "Give the repository as \"owner/name\".".to_owned();
742 // Operations on a repository or a workspace: which.
743 let scope_repo = repo.clone();
744 let scope_workspace = || workspace.clone().or_else(|| repo.as_ref().map(|repo| repo.namespace.to_lowercase()));
745 match op {
746 SecurityOp::ListSecretAlerts => {
747 let filters = match secret_filters(input) {
748 Ok(filters) => filters,
749 Err(message) => return failed(FailureCode::Invalid, &message),
750 };
751 match (scope_repo, workspace) {
752 (Some(repo), _) => {
753 let overview: Outcome<SecurityOverview> =
754 call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?;
755 match overview {
756 Outcome::Ok(overview) => {
757 let alerts: Vec<SecretFinding> = overview.secrets.into_iter().filter(|secret| filters.keeps(secret)).collect();
758 ok(&alerts)
759 }
760 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
761 }
762 }
763 (None, Some(workspace)) => {
764 let found: Outcome<Vec<WorkspaceAlert>> = call(
765 services,
766 "workspace_alerts",
767 &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::SecretScanning },
768 )
769 .await?;
770 match found {
771 Outcome::Ok(found) => {
772 let alerts: Vec<WorkspaceAlert> =
773 found.into_iter().filter(|alert| alert.secret.as_ref().is_some_and(|secret| filters.keeps(secret))).collect();
774 ok(&alerts)
775 }
776 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
777 }
778 }
779 (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."),
780 }
781 }
782 SecurityOp::GetSecretAlert | SecurityOp::ListSecretLocations => {
783 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
784 let id = text(input, "id").unwrap_or_default();
785 let detail: Outcome<SecretAlertDetail> = call(services, "secret_alert", &SecretAlertArgs { viewer: viewer.clone(), repo, id }).await?;
786 match (detail, op) {
787 (Outcome::Ok(detail), SecurityOp::ListSecretLocations) => ok(&detail.locations),
788 (Outcome::Ok(detail), _) => ok(&detail),
789 (Outcome::Fail(failure), _) => Ok(Outcome::Fail(failure)),
790 }
791 }
792 SecurityOp::UpdateSecretAlert | SecurityOp::UpdateVulnerabilityAlert => {
793 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
794 let id = text(input, "id").unwrap_or_default();
795 let (reasons, wants): (&[DismissReason], &str) = match op {
796 SecurityOp::UpdateSecretAlert => (&SECRET_REASONS, "sec_"),
797 _ => (&DEPENDENCY_REASONS, "vul_"),
798 };
799 if !id.starts_with(wants) {
800 return failed(FailureCode::NotFound, "No such alert.");
801 }
802 let reason = match state_change(input, "reason", reasons) {
803 Ok(reason) => reason,
804 Err(message) => return failed(FailureCode::Invalid, &message),
805 };
806 let changed: Outcome<AlertChange> = match reason {
807 Some(reason) => {
808 let comment = text(input, "comment").unwrap_or_default();
809 call(services, "dismiss", &DismissArgs { actor: actor(), repo, id, reason, comment }).await?
810 }
811 None => call(services, "reopen", &ReopenArgs { actor: actor(), repo, id }).await?,
812 };
813 match changed {
814 Outcome::Ok(AlertChange { secret: Some(secret), .. }) => ok(&secret),
815 Outcome::Ok(AlertChange { vulnerability: Some(vuln), .. }) => ok(&vuln),
816 Outcome::Ok(_) => failed(FailureCode::NotFound, "No such alert."),
817 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
818 }
819 }
820 SecurityOp::BypassPushProtection => {
821 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
822 let Some(reason) = text(input, "reason").as_deref().and_then(BypassReason::parse) else {
823 return failed(FailureCode::Invalid, "reason is false_positive, used_in_tests or will_fix_later.");
824 };
825 let args = BypassArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default(), reason, comment: text(input, "comment").unwrap_or_default() };
826 pass(services, "bypass", &args).await
827 }
828 SecurityOp::CheckSecretValidity => {
829 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
830 pass(services, "check_validity", &CheckValidityArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default() }).await
831 }
832 SecurityOp::ListBypassRequests => {
833 let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
834 let state = match one_of(input, "state", &["pending", "approved", "denied", "cancelled"]) {
835 Ok(state) => state,
836 Err(message) => return failed(FailureCode::Invalid, &message),
837 };
838 pass(services, "bypass_requests", &BypassRequestsArgs { viewer: viewer.clone(), workspace, repo, state }).await
839 }
840 SecurityOp::ReviewBypassRequest => {
841 let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
842 let decision = match one_of(input, "decision", &["approve", "deny", "cancel"]) {
843 Ok(Some(decision)) => decision,
844 Ok(None) => return failed(FailureCode::Invalid, "decision is approve, deny or cancel."),
845 Err(message) => return failed(FailureCode::Invalid, &message),
846 };
847 let args = ReviewBypassArgs {
848 actor: actor(),
849 workspace,
850 id: text(input, "id").unwrap_or_default(),
851 decision,
852 comment: text(input, "comment").unwrap_or_default(),
853 };
854 pass(services, "review_bypass", &args).await
855 }
856 SecurityOp::ListCustomPatterns => {
857 let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
858 pass(services, "custom_patterns", &CustomPatternsArgs { viewer: viewer.clone(), workspace, repo: scope_repo }).await
859 }
860 SecurityOp::CreateCustomPattern | SecurityOp::UpdateCustomPattern => {
861 let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
862 let args = SaveCustomPatternArgs {
863 actor: actor(),
864 workspace,
865 repo: scope_repo,
866 id: if op == SecurityOp::UpdateCustomPattern { text(input, "id") } else { None },
867 name: text(input, "pattern_name").unwrap_or_default(),
868 pattern: input["pattern"].as_str().unwrap_or_default().to_owned(),
869 before: text(input, "before"),
870 after: text(input, "after"),
871 test_strings: strings(input, "test_strings"),
872 publish: flag(input, "publish").unwrap_or(false),
873 };
874 pass(services, "save_custom_pattern", &args).await
875 }
876 SecurityOp::DeleteCustomPattern => {
877 let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
878 let args = DeleteCustomPatternArgs { actor: actor(), workspace, repo: scope_repo, id: text(input, "id").unwrap_or_default() };
879 match call::<_, bool>(services, "delete_custom_pattern", &args).await? {
880 Outcome::Ok(_) => ok(&json!({ "deleted": true })),
881 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
882 }
883 }
884 SecurityOp::DryRunCustomPattern => {
885 let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
886 let args = DryRunPatternArgs {
887 actor: actor(),
888 workspace,
889 repo: scope_repo,
890 repos: strings(input, "repos"),
891 pattern: input["pattern"].as_str().unwrap_or_default().to_owned(),
892 before: text(input, "before"),
893 after: text(input, "after"),
894 };
895 pass(services, "dry_run_pattern", &args).await
896 }
897 SecurityOp::ListCodeAlerts => {
898 let filters = match code_filters(input) {
899 Ok(filters) => filters,
900 Err(message) => return failed(FailureCode::Invalid, &message),
901 };
902 match (scope_repo, workspace) {
903 (Some(repo), _) => match call::<_, CodeScanning>(services, "code_scanning", &CodeScanningArgs { viewer: viewer.clone(), repo }).await? {
904 Outcome::Ok(scanning) => {
905 let alerts: Vec<CodeAlert> = scanning.alerts.into_iter().filter(|alert| filters.keeps(alert)).collect();
906 ok(&alerts)
907 }
908 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
909 },
910 (None, Some(workspace)) => {
911 let found: Outcome<Vec<WorkspaceAlert>> = call(
912 services,
913 "workspace_alerts",
914 &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::CodeScanning },
915 )
916 .await?;
917 match found {
918 Outcome::Ok(found) => {
919 let alerts: Vec<WorkspaceAlert> =
920 found.into_iter().filter(|alert| alert.code.as_ref().is_some_and(|code| filters.keeps(code))).collect();
921 ok(&alerts)
922 }
923 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
924 }
925 }
926 (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."),
927 }
928 }
929 SecurityOp::GetCodeAlert => {
930 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
931 pass(services, "code_alert", &CodeAlertArgs { viewer: viewer.clone(), repo, number: whole(input, "number").unwrap_or(0) }).await
932 }
933 SecurityOp::UpdateCodeAlert => {
934 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
935 let reason = match state_change(input, "dismissed_reason", &CODE_REASONS) {
936 Ok(reason) => reason,
937 Err(message) => return failed(FailureCode::Invalid, &message),
938 };
939 let args = SetCodeAlertStateArgs {
940 actor: actor(),
941 repo,
942 number: whole(input, "number").unwrap_or(0),
943 state: if reason.is_some() { AlertState::Dismissed } else { AlertState::Open },
944 reason,
945 comment: text(input, "dismissed_comment").unwrap_or_default(),
946 };
947 pass(services, "set_code_alert_state", &args).await
948 }
949 SecurityOp::ListAnalyses => {
950 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
951 match call::<_, CodeScanning>(services, "code_scanning", &CodeScanningArgs { viewer: viewer.clone(), repo }).await? {
952 Outcome::Ok(scanning) => ok(&scanning.analyses),
953 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
954 }
955 }
956 SecurityOp::UploadSarif => {
957 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
958 let (Some(commit_sha), Some(git_ref), Some(sarif)) = (text(input, "commit_sha"), text(input, "ref"), text(input, "sarif")) else {
959 return failed(FailureCode::Invalid, "Give commit_sha, ref and sarif (the file gzipped and base64-encoded).");
960 };
961 if sarif.len() > g1t_scan_limits::MAX_UPLOAD_BYTES {
962 return failed(FailureCode::Invalid, "The upload is larger than 10 MB.");
963 }
964 let args = UploadSarifArgs {
965 actor: actor(),
966 repo,
967 commit_sha,
968 git_ref,
969 sarif,
970 tool_name: text(input, "tool_name"),
971 category: text(input, "category"),
972 checkout_uri: text(input, "checkout_uri"),
973 };
974 pass(services, "upload_sarif", &args).await
975 }
976 SecurityOp::GetSarifUpload => {
977 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
978 pass(services, "sarif_status", &SarifStatusArgs { viewer: viewer.clone(), repo, id: text(input, "id").unwrap_or_default() }).await
979 }
980 SecurityOp::ListVulnerabilityAlerts => {
981 let filters = match vulnerability_filters(input) {
982 Ok(filters) => filters,
983 Err(message) => return failed(FailureCode::Invalid, &message),
984 };
985 match (scope_repo, workspace) {
986 (Some(repo), _) => {
987 let overview: Outcome<SecurityOverview> =
988 call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?;
989 match overview {
990 Outcome::Ok(overview) => {
991 let alerts: Vec<Vulnerability> = overview.vulnerabilities.into_iter().filter(|vuln| filters.keeps(vuln)).collect();
992 ok(&alerts)
993 }
994 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
995 }
996 }
997 (None, Some(workspace)) => {
998 let found: Outcome<Vec<WorkspaceAlert>> = call(
999 services,
1000 "workspace_alerts",
1001 &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::Vulnerability },
1002 )
1003 .await?;
1004 match found {
1005 Outcome::Ok(found) => {
1006 let alerts: Vec<WorkspaceAlert> =
1007 found.into_iter().filter(|alert| alert.vulnerability.as_ref().is_some_and(|vuln| filters.keeps(vuln))).collect();
1008 ok(&alerts)
1009 }
1010 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
1011 }
1012 }
1013 (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."),
1014 }
1015 }
1016 SecurityOp::GetVulnerabilityAlert => {
1017 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1018 let id = text(input, "id").unwrap_or_default();
1019 let overview: Outcome<SecurityOverview> =
1020 call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?;
1021 match overview {
1022 Outcome::Ok(overview) => match overview.vulnerabilities.into_iter().find(|vuln| vuln.id == id) {
1023 Some(vuln) => ok(&vuln),
1024 None => failed(FailureCode::NotFound, "No such alert."),
1025 },
1026 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
1027 }
1028 }
1029 SecurityOp::GetDependencyGraph => {
1030 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1031 pass(services, "dependency_graph", &DependencyGraphArgs { viewer: viewer.clone(), repo }).await
1032 }
1033 SecurityOp::GetSbom => {
1034 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1035 // The document goes out as SPDX spells it: `sbom` is passed
1036 // through untouched (g1t_kit::wire::USER_KEYED).
1037 match call::<_, Value>(services, "sbom", &SbomArgs { viewer: viewer.clone(), repo }).await? {
1038 Outcome::Ok(document) => ok(&json!({ "sbom": document })),
1039 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
1040 }
1041 }
1042 SecurityOp::CompareDependencies => {
1043 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1044 let Some((base, head)) = text(input, "basehead").as_deref().and_then(base_head) else {
1045 return failed(FailureCode::Invalid, "basehead is base...head, e.g. main...my-branch.");
1046 };
1047 pass(services, "dependency_review", &DependencyReviewArgs { viewer: viewer.clone(), repo, base, head }).await
1048 }
1049 SecurityOp::RerunDependencyReview => {
1050 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1051 let Some(number) = whole(input, "number").filter(|number| *number > 0) else {
1052 return failed(FailureCode::Invalid, "number is the pull request's number.");
1053 };
1054 pass(services, "rerun_dependency_review", &PullScanningArgs { viewer: viewer.clone(), repo, number }).await
1055 }
1056 SecurityOp::GetSettings => {
1057 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1058 pass(services, "security_settings", &SecuritySettingsArgs { viewer: viewer.clone(), repo }).await
1059 }
1060 SecurityOp::UpdateSettings => {
1061 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1062 // What is not given stays as it is.
1063 let current: Outcome<SecuritySettingsView> =
1064 call(services, "security_settings", &SecuritySettingsArgs { viewer: viewer.clone(), repo: repo.clone() }).await?;
1065 let mut settings = match current {
1066 Outcome::Ok(view) => view.settings,
1067 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1068 };
1069 if let Some(gate) = text(input, "code_scanning_gate") {
1070 settings.code_scanning_gate = gate.to_lowercase();
1071 }
1072 if let Some(on) = flag(input, "dependency_review") {
1073 settings.dependency_review = on;
1074 }
1075 if let Some(fail_on) = text(input, "review_fail_on") {
1076 settings.review_fail_on = fail_on.to_lowercase();
1077 }
1078 if input["review_deny_licenses"].is_array() {
1079 settings.review_deny_licenses = strings(input, "review_deny_licenses");
1080 }
1081 if let Some(on) = flag(input, "review_comment") {
1082 settings.review_comment = on;
1083 }
1084 if let Some(on) = flag(input, "history_alerts") {
1085 settings.history_alerts = on;
1086 }
1087 if let Some(on) = flag(input, "private_vulnerability_reporting") {
1088 settings.private_vulnerability_reporting = on;
1089 }
1090 pass(services, "set_security_settings", &SetSecuritySettingsArgs { actor: actor(), repo, settings }).await
1091 }
1092 SecurityOp::ListAdvisories => {
1093 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1094 let state = match one_of(input, "state", &["draft", "published", "withdrawn"]) {
1095 Ok(state) => state,
1096 Err(message) => return failed(FailureCode::Invalid, &message),
1097 };
1098 pass(services, "list_advisories", &ListAdvisoriesArgs { viewer: viewer.clone(), repo, state }).await
1099 }
1100 SecurityOp::GetAdvisory => {
1101 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1102 pass(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo, id: text(input, "id").unwrap_or_default() }).await
1103 }
1104 SecurityOp::CreateAdvisory => {
1105 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1106 let advisory = advisory_input(input, &AdvisoryInput::default());
1107 pass(services, "create_advisory", &CreateAdvisoryArgs { actor: actor(), repo, advisory }).await
1108 }
1109 SecurityOp::UpdateAdvisory => {
1110 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1111 let id = text(input, "id").unwrap_or_default();
1112 // What is not given stays as it is.
1113 let current: Outcome<Advisory> = call(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo: repo.clone(), id: id.clone() }).await?;
1114 let current = match current {
1115 Outcome::Ok(advisory) => advisory,
1116 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1117 };
1118 let advisory = advisory_input(input, &advisory_as_input(&current));
1119 pass(services, "update_advisory", &UpdateAdvisoryArgs { actor: actor(), repo, id, advisory }).await
1120 }
1121 SecurityOp::SetAdvisoryState => {
1122 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1123 let state = match one_of(input, "state", &["published", "withdrawn"]) {
1124 Ok(Some(state)) => state,
1125 Ok(None) => return failed(FailureCode::Invalid, "state is published or withdrawn."),
1126 Err(message) => return failed(FailureCode::Invalid, &message),
1127 };
1128 pass(services, "set_advisory_state", &SetAdvisoryStateArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default(), state }).await
1129 }
1130 SecurityOp::ListReports => {
1131 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1132 let state = match one_of(input, "state", &["new", "accepted", "declined"]) {
1133 Ok(state) => state,
1134 Err(message) => return failed(FailureCode::Invalid, &message),
1135 };
1136 pass(services, "list_vulnerability_reports", &ListReportsArgs { viewer: viewer.clone(), repo, state }).await
1137 }
1138 SecurityOp::ReportVulnerability => {
1139 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1140 let args = ReportVulnerabilityArgs {
1141 viewer: viewer.clone(),
1142 repo,
1143 title: text(input, "title").unwrap_or_default(),
1144 description: text(input, "description").unwrap_or_default(),
1145 severity: text(input, "severity").map(|severity| severity.to_lowercase()),
1146 email: text(input, "email"),
1147 };
1148 pass(services, "report_vulnerability", &args).await
1149 }
1150 SecurityOp::ReviewReport => {
1151 let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1152 let args = ReviewReportArgs {
1153 actor: actor(),
1154 repo,
1155 id: text(input, "id").unwrap_or_default(),
1156 decision: text(input, "decision").unwrap_or_default().to_lowercase(),
1157 note: text(input, "note").unwrap_or_default(),
1158 };
1159 pass(services, "review_vulnerability_report", &args).await
1160 }
1161 SecurityOp::GetWorkspaceSettings => {
1162 let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
1163 pass(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace }).await
1164 }
1165 SecurityOp::UpdateWorkspaceSettings => {
1166 let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
1167 let current: Outcome<WorkspaceSecurityView> =
1168 call(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace: workspace.clone() }).await?;
1169 let mut settings = match current {
1170 Outcome::Ok(view) => view.settings,
1171 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1172 };
1173 if let Some(on) = flag(input, "delegated_bypass") {
1174 settings.delegated_bypass = on;
1175 }
1176 if let Some(on) = flag(input, "validity_checks") {
1177 settings.validity_checks = on;
1178 }
1179 pass(services, "set_workspace_security_settings", &SetWorkspaceSecuritySettingsArgs { actor: actor(), workspace, settings }).await
1180 }
1181 SecurityOp::GetOverview => {
1182 let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
1183 pass(services, "security_overview", &WorkspaceOverviewArgs { viewer: viewer.clone(), workspace, days: whole(input, "days") }).await
1184 }
1185 }
1186}
1187
1188/// Limits the API checks before passing an upload on.
1189mod g1t_scan_limits {
1190 /// As the security service's: 10 MB gzipped and base64-encoded.
1191 pub const MAX_UPLOAD_BYTES: usize = 10 * 1024 * 1024;
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 #[test]
1199 fn every_one_is_an_operation() {
1200 for op in SecurityOp::ALL {
1201 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Security(op)), "{}", op.name());
1202 }
1203 }
1204
1205 #[test]
1206 fn names_are_unique_and_found_again() {
1207 let mut names: Vec<&str> = SecurityOp::ALL.iter().map(|op| op.name()).collect();
1208 names.sort();
1209 names.dedup();
1210 assert_eq!(names.len(), SecurityOp::ALL.len());
1211 }
1212
1213 #[test]
1214 fn secret_filters_are_read_as_words() {
1215 let filters = secret_filters(&json!({ "state": "OPEN", "validity": "active", "bypassed": "true", "secret_type": "github_token" })).unwrap();
1216 assert_eq!(filters.state, Some(AlertState::Open));
1217 assert_eq!(filters.validity.as_deref(), Some("active"));
1218 assert_eq!(filters.bypassed, Some(true));
1219 assert!(secret_filters(&json!({ "validity": "maybe" })).unwrap_err().contains("validity is active, inactive"));
1220 assert!(secret_filters(&json!({ "bypassed": "perhaps" })).is_err());
1221 assert_eq!(secret_filters(&json!({})).unwrap(), SecretFilters::default());
1222 }
1223
1224 #[test]
1225 fn a_state_change_needs_a_reason_its_kind_takes() {
1226 assert_eq!(state_change(&json!({ "state": "open" }), "reason", &SECRET_REASONS), Ok(None));
1227 assert_eq!(
1228 state_change(&json!({ "state": "dismissed", "reason": "revoked" }), "reason", &SECRET_REASONS),
1229 Ok(Some(DismissReason::Revoked))
1230 );
1231 assert!(state_change(&json!({ "state": "dismissed", "reason": "not_used" }), "reason", &SECRET_REASONS).unwrap_err().contains("reason is false_positive"));
1232 assert!(state_change(&json!({ "state": "dismissed" }), "dismissed_reason", &CODE_REASONS).unwrap_err().starts_with("Give dismissed_reason"));
1233 assert!(state_change(&json!({ "state": "fixed" }), "reason", &CODE_REASONS).is_err());
1234 }
1235
1236 #[test]
1237 fn base_and_head_are_split_at_the_dots() {
1238 assert_eq!(base_head("main...feature/x"), Some(("main".into(), "feature/x".into())));
1239 assert_eq!(base_head("v1.0..v1.1"), Some(("v1.0".into(), "v1.1".into())));
1240 assert_eq!(base_head("main"), None);
1241 assert_eq!(base_head("...head"), None);
1242 }
1243
1244 #[test]
1245 fn code_and_vulnerability_filters_check_their_words() {
1246 assert!(code_filters(&json!({ "severity": "severe" })).unwrap_err().contains("severity is critical"));
1247 let filters = vulnerability_filters(&json!({ "ecosystem": "npm", "state": "dismissed" })).unwrap();
1248 assert_eq!(filters.state, Some(AlertState::Dismissed));
1249 }
1250
1251 #[test]
1252 fn a_read_only_token_sees_only_the_security_reads() {
1253 use g1t_contracts::scopes::{Scope, scope_for};
1254 for op in SecurityOp::ALL {
1255 let scope = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name()));
1256 assert!(matches!(scope, Scope::SecurityRead | Scope::SecurityWrite), "{}", op.name());
1257 }
1258 assert_eq!(scope_for("get_sbom"), Some(Scope::SecurityRead));
1259 assert_eq!(scope_for("upload_sarif"), Some(Scope::SecurityWrite));
1260 assert_eq!(scope_for("rerun_dependency_review"), Some(Scope::SecurityWrite));
1261 // No run decides about security.
1262 for name in ["bypass_push_protection", "review_bypass_request", "update_security_settings"] {
1263 assert!(g1t_contracts::credentials::NEVER.contains(&name), "{name}");
1264 }
1265 }
1266
1267 #[test]
1268 fn workspace_wide_operations_do_not_need_a_repository() {
1269 for op in [SecurityOp::GetOverview, SecurityOp::ListBypassRequests, SecurityOp::ListCustomPatterns] {
1270 assert!(!op.needs_repo());
1271 }
1272 assert!(SecurityOp::UploadSarif.needs_repo());
1273 let required = SecurityOp::UploadSarif.input()["required"].clone();
1274 assert_eq!(required, json!(["repo", "commit_sha", "ref", "sarif"]));
1275 }
1276}