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