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