Skip to content
1,263 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.