Skip to content

g1t/services/security/src/fixes.rs

216 lines10,769 bytesCodeBlame
1//! "Fix with g1t": an alert becomes an issue assigned to g1t, which writes
2//! the fix in a pull request that lands through the repository's required
3//! checks, as any agent's work does. Nothing new is built for it: it is the
4//! issue → agent → checks → queue pipeline, and the agent's run is charged
5//! as agent usage.
6
7use g1t_contracts::repos::RepoPath;
8use g1t_contracts::security::{SecretFinding, Vulnerability};
9use g1t_contracts::security_suite::{AlertFix, AlertType, CodeAlert, FixAlertArgs, PaidFeature};
10use g1t_contracts::work::{Issue, OpenIssueArgs, with_definition_of_done};
11use g1t_contracts::{FailureCode, Outcome};
12use serde_json::{Value, json};
13use worker::Result;
14
15use crate::Security;
16use crate::store::{Activity, VulnRow};
17
18fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
19 Outcome::fail(code, message)
20}
21
22const FOOTER: &str = "\n\n---\n_Opened from a security alert with Fix with g1t. The pull request lands through this repository's required checks._";
23
24/// The issue for a code scanning alert.
25pub fn code_issue(alert: &CodeAlert, page: &str) -> (String, String) {
26 let place = match (&alert.path, alert.start_line) {
27 (Some(path), Some(line)) => format!("`{path}` line {line}"),
28 (Some(path), None) => format!("`{path}`"),
29 _ => "the repository".to_owned(),
30 };
31 let rule = alert.rule_name.as_deref().unwrap_or(&alert.rule_id);
32 let title = format!("Fix code scanning alert #{}: {rule}", alert.number);
33 let mut body = format!(
34 "{} reports **{}** ({}) in {place}:\n\n> {}\n\n",
35 alert.tool,
36 alert.rule_id,
37 alert.security_severity.as_deref().unwrap_or(&alert.level),
38 alert.message.replace('\n', "\n> ")
39 );
40 if let Some(description) = &alert.rule_description {
41 body.push_str(&format!("{description}\n\n"));
42 }
43 if let Some(help) = &alert.help {
44 body.push_str(&format!("<details><summary>About this rule</summary>\n\n{help}\n\n</details>\n\n"));
45 }
46 body.push_str(&format!(
47 "Change the code so the problem is gone, not hidden: no suppression comments, and no change to the analysis. \
48 Keep the change to what the fix needs. The alert: {page}\n"
49 ));
50 let done = vec![
51 format!("{} no longer reports `{}` at {place} when it runs on the pull request.", alert.tool, alert.rule_id),
52 "The project's tests still pass.".to_owned(),
53 ];
54 (title, format!("{}{FOOTER}", with_definition_of_done(&body, &done)))
55}
56
57/// The issue for a leaked secret: take it out of the code. Rotating it is
58/// the person's: only they can at its issuer.
59pub fn secret_issue(secret: &SecretFinding, page: &str) -> (String, String) {
60 let title = format!("Remove {} from {}", secret.label, secret.path);
61 let body = format!(
62 "{} (`{}`) is in `{}` at line {}, committed in {}. Take it out of the code and read it from configuration \
63 instead: an environment variable, or the repository's Actions secrets for workflows. Do not write the value \
64 anywhere else, including in tests, docs or this pull request.\n\nRemoving it from the code does not make it \
65 safe: it is in the history. Whoever owns it has to rotate it at its issuer, then mark the alert revoked. \
66 The alert: {page}\n",
67 secret.label,
68 secret.preview,
69 secret.path,
70 secret.line,
71 &secret.commit[..secret.commit.len().min(7)],
72 );
73 let done = vec![format!("`{}` no longer holds the secret, and nothing else does instead.", secret.path), "The project's tests still pass.".to_owned()];
74 (title, format!("{}{FOOTER}", with_definition_of_done(&body, &done)))
75}
76
77impl Security {
78 pub(crate) async fn fix_alert(&self, a: FixAlertArgs) -> Result<Outcome<AlertFix>> {
79 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), crate::SEE_FINDINGS).await? {
80 Outcome::Ok(repo) => repo,
81 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
82 };
83 if !a.actor.verified {
84 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
85 }
86 let page = |rest: &str| format!("https://g1t.sh{}", crate::suite::link(&repo, rest));
87 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
88 let (title, body, labels, existing) = match AlertType::of_id(&a.id) {
89 Some(AlertType::CodeScanning) => {
90 if let Some(refusal) = self.gate(&repo, PaidFeature::CodeScanning).await? {
91 return Ok(refusal);
92 }
93 let Some(alert) = self.store.code_alert_by_id(&repo.repo_id, &a.id).await? else {
94 return Ok(fail(FailureCode::NotFound, "No such alert."));
95 };
96 let (title, body) = code_issue(&alert, &page(&format!("code-scanning/{}", alert.number)));
97 (title, body, vec!["security".to_owned(), "code-scanning".to_owned()], alert.issue)
98 }
99 Some(AlertType::SecretScanning) => {
100 let Some(secret) = self.store.secret(&repo.repo_id, &a.id).await? else {
101 return Ok(fail(FailureCode::NotFound, "No such alert."));
102 };
103 if secret.status == g1t_contracts::security::SecretStatus::Blocked {
104 return Ok(fail(FailureCode::Conflict, "This secret never landed: take it out of your commit and push again."));
105 }
106 let (title, body) = secret_issue(&secret, &page(&format!("secret-scanning/{}", secret.id)));
107 (title, body, vec!["security".to_owned()], self.store.fix_issue(&a.id).await?)
108 }
109 Some(AlertType::Vulnerability) => {
110 let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else {
111 return Ok(fail(FailureCode::NotFound, "No such alert."));
112 };
113 let (title, body) = self.vulnerability_issue(&repo.repo_id, &vuln).await?;
114 (title, body, vec!["dependencies".to_owned(), "security".to_owned()], self.store.fix_issue(&a.id).await?.or(vuln.issue))
115 }
116 None => return Ok(fail(FailureCode::NotFound, "No such alert.")),
117 };
118 if let Some(number) = existing
119 && self.issue(&a.actor, &path, number).await?.is_some_and(|issue| issue.state == g1t_contracts::work::State::Open)
120 {
121 return Ok(Outcome::Ok(AlertFix { issue: number, started: false, message: Some(format!("g1t is already on it in #{number}.")) }));
122 }
123 let opened: Outcome<Issue> = g1t_kit::call(
124 &self.work,
125 "open_issue",
126 &OpenIssueArgs { actor: a.actor.clone(), repo: path.clone(), title: title.chars().take(200).collect(), body, labels, checks: Vec::new(), milestone: None },
127 )
128 .await?;
129 let issue = match opened {
130 Outcome::Ok(issue) => issue,
131 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
132 };
133 match AlertType::of_id(&a.id) {
134 Some(AlertType::CodeScanning) => self.store.set_code_issue(&repo.repo_id, &a.id, issue.number).await?,
135 _ => self.store.add_fix(&repo.repo_id, &a.id, issue.number, &a.actor.username).await?,
136 }
137 self.store
138 .record(&repo.repo_id, &[Activity {
139 alert_id: &a.id,
140 action: "fix_requested",
141 actor: Some(&a.actor.username),
142 reason: None,
143 comment: None,
144 number: Some(issue.number),
145 }])
146 .await?;
147 // g1t takes it, as the person who asked: their agent usage.
148 let started: Outcome<Value> = g1t_kit::call(&self.runner, "run", &json!({ "actor": a.actor, "repo": path, "issue": issue.number })).await?;
149 Ok(Outcome::Ok(match started {
150 Outcome::Ok(_) => AlertFix { issue: issue.number, started: true, message: None },
151 Outcome::Fail(refused) => AlertFix {
152 issue: issue.number,
153 started: false,
154 message: Some(format!("The issue is open, but g1t could not start on it: {}", refused.message)),
155 },
156 }))
157 }
158
159 async fn vulnerability_issue(&self, repo_id: &str, vuln: &Vulnerability) -> Result<(String, String)> {
160 let open = self.store.open_vulnerabilities(repo_id).await?;
161 let rows: Vec<&VulnRow> = open.iter().filter(|row| row.ecosystem == vuln.ecosystem && row.package == vuln.package).collect();
162 let target = vuln.fixed_version.clone().unwrap_or_else(|| "a version without these advisories".to_owned());
163 let body = crate::deps::issue_text(&vuln.ecosystem, &vuln.package, &target, &rows, &[]);
164 Ok((format!("Upgrade {} to {target}", vuln.package), body))
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use g1t_contracts::security::AlertState;
172
173 #[test]
174 fn a_code_alert_becomes_an_issue_with_a_definition_of_done() {
175 let alert = CodeAlert {
176 id: "cod_1".into(),
177 number: 4,
178 repo_id: "rep_1".into(),
179 tool: "Semgrep OSS".into(),
180 category: "Semgrep OSS".into(),
181 rule_id: "javascript.browser.security.eval-detected.eval-detected".into(),
182 rule_name: None,
183 rule_description: Some("Detected the use of eval().".into()),
184 help: None,
185 help_uri: None,
186 tags: Vec::new(),
187 level: "warning".into(),
188 security_severity: None,
189 severity: "medium".into(),
190 message: "Detected the use of eval().".into(),
191 path: Some("src/server.js".into()),
192 start_line: Some(10),
193 end_line: Some(10),
194 start_column: None,
195 end_column: None,
196 state: AlertState::Open,
197 fingerprint: "f".into(),
198 first_commit: "c".into(),
199 last_commit: "c".into(),
200 created_at: String::new(),
201 updated_at: String::new(),
202 fixed_at: None,
203 dismissed_by: None,
204 dismissed_reason: None,
205 dismissed_comment: None,
206 dismissed_at: None,
207 issue: None,
208 };
209 let (title, body) = code_issue(&alert, "https://g1t.sh/acme/rocket/security/code-scanning/4");
210 assert_eq!(title, "Fix code scanning alert #4: javascript.browser.security.eval-detected.eval-detected");
211 assert!(body.contains("in `src/server.js` line 10"));
212 assert!(body.contains("## Definition of done"));
213 assert!(body.contains("no longer reports"));
214 assert!(body.contains("no suppression comments"));
215 }
216}