Skip to content

g1t/apps/api/src/security.rs

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