Skip to content
618 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//! Code scanning: SARIF uploads read into analyses and alerts, and the
2//! `Code scanning` check on pull requests.
3//!
4//! An upload for the default branch opens an alert for each result not
5//! seen before (by fingerprint, per tool and category), refreshes those
6//! seen again, and fixes the open ones it no longer reports. An upload for
7//! a pull request (`refs/pull/<n>/head` or `/merge`) leaves the alerts
8//! alone: its results are compared with the default branch's, and those
9//! new to the pull request on lines it changes become the check's
10//! annotations, as review comments on those lines, failing the check at
11//! the repository's chosen threshold. Require the check in branch
12//! protection and it gates the merge like any other.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use g1t_contracts::repos::{BlobArgs, BlobView, CompareArgs, Comparison, GetArgs, LineKind, Repo, RepoPath};
17use g1t_contracts::security::{AlertActivity, AlertState, DismissReason};
18use g1t_contracts::security_suite::{
19 AlertType, CODE_SCANNING_CHECK, CodeAlert, CodeAlertArgs, CodeAlertDetail, CodeScanning, CodeScanningArgs, PaidFeature,
20 PullResult, PullScanning, PullScanningArgs, STARTER_WORKFLOW_PATH, SarifStatusArgs, SarifUpload, SecurityEvent,
21 SetCodeAlertStateArgs, UploadSarifArgs,
22};
23use g1t_contracts::work::{AddCommentArgs, Pull, PullDetail, SetCommitStatusArgs, ViewArgs};
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.24use g1t_contracts::security_suite::MAX_SUMMARY_IDS;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar25use g1t_contracts::{FailureCode, Outcome, User};
26use g1t_scan::sarif::{self, Finding, Gate};
27use serde_json::{Value, json};
28use worker::Result;
29
30use crate::Security;
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.31use crate::summaries::Changed;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar32use crate::store::{Activity, RepoRow};
33use crate::suite::link;
34use crate::suite_store::NewCodeAlert;
35
36const SITE: &str = "https://g1t.sh";
37/// Review comments one analysis of a pull request leaves, at most.
38const MAX_COMMENTS: usize = 10;
39/// Alerts an upload announces one by one; past it, the page says the rest.
40
41fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
42 Outcome::fail(code, message)
43}
44
45/// A result as kept with its analysis.
46pub fn result_json(finding: &Finding) -> Value {
47 json!({
48 "ruleId": finding.rule_id,
49 "level": finding.level.as_str(),
50 "securitySeverity": finding.security_severity.map(|severity| severity.as_str()),
51 "severity": finding.severity().as_str(),
52 "message": finding.message,
53 "path": finding.location.as_ref().map(|location| location.path.clone()),
54 "line": finding.location.as_ref().map(|location| location.start_line),
55 })
56}
57
58/// The pull request a ref names: `refs/pull/12/head` or `/merge`.
59pub fn pull_of(git_ref: &str) -> Option<u32> {
60 let rest = git_ref.strip_prefix("refs/pull/")?;
61 let (number, which) = rest.split_once('/')?;
62 matches!(which, "head" | "merge").then(|| number.parse().ok()).flatten()
63}
64
65fn is_sha(text: &str) -> bool {
66 matches!(text.len(), 40 | 64) && text.chars().all(|c| c.is_ascii_hexdigit())
67}
68
69/// The lines each file gains in a comparison.
70pub fn added_lines(comparison: &Comparison) -> BTreeMap<String, BTreeSet<u32>> {
71 let mut out: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
72 for file in &comparison.files {
73 for hunk in &file.hunks {
74 for line in &hunk.lines {
75 if line.kind == LineKind::Add
76 && let Some(number) = line.new
77 {
78 out.entry(file.path.clone()).or_default().insert(number);
79 }
80 }
81 }
82 }
83 out
84}
85
86/// A pull request's results, judged: new to it or not, on a line it
87/// changes or not, failing the check or not.
88pub fn judge_pull(found: &[Finding], on_default: &BTreeSet<String>, changed: &BTreeMap<String, BTreeSet<u32>>, gate: Gate) -> Vec<PullResult> {
89 let mut results: Vec<PullResult> = found
90 .iter()
91 .map(|finding| {
92 let location = finding.location.as_ref();
93 let new = !on_default.contains(&finding.fingerprint);
94 let on_changed_line = location.is_some_and(|location| {
95 changed
96 .get(&location.path)
97 .is_some_and(|lines| (location.start_line..=location.end_line).any(|line| lines.contains(&line)))
98 });
99 PullResult {
100 tool: String::new(),
101 rule_id: finding.rule_id.clone(),
102 level: finding.level.as_str().to_owned(),
103 severity: finding.severity().as_str().to_owned(),
104 security_severity: finding.security_severity.map(|severity| severity.as_str().to_owned()),
105 message: finding.message.clone(),
106 path: location.map(|location| location.path.clone()),
107 line: location.map(|location| location.start_line),
108 new,
109 on_changed_line,
110 failing: new && on_changed_line && gate.fails(finding),
111 }
112 })
113 .collect();
114 results.sort_by_key(|result| (!result.failing, !result.new, !result.on_changed_line));
115 results
116}
117
118/// The check's state and line for a pull request's results.
119pub fn verdict(results: &[PullResult]) -> (&'static str, String) {
120 let failing = results.iter().filter(|result| result.failing).count();
121 let new = results.iter().filter(|result| result.new && result.on_changed_line).count();
122 if failing > 0 {
123 let plural = if failing == 1 { "result" } else { "results" };
124 return ("failure", format!("{failing} new {plural} at or above the threshold"));
125 }
126 match new {
127 0 => ("success", "No new results".to_owned()),
128 1 => ("success", "1 new result, below the threshold".to_owned()),
129 n => ("success", format!("{n} new results, below the threshold")),
130 }
131}
132
133/// A review comment for a result on a line the pull request changes.
134pub fn comment_text(tool: &str, finding: &PullResult, alert_page: &str) -> String {
135 let severity = finding.security_severity.as_deref().unwrap_or(&finding.level);
136 format!(
137 "**{tool}: `{}`** ({severity})\n\n{}\n\n[See the results for this pull request]({alert_page}). Fix it here, or dismiss it on the alert once it lands if it is not a real problem.",
138 finding.rule_id, finding.message
139 )
140}
141
142/// The event that tells of a code scanning alert.
143pub fn code_event(repo: &RepoRow, alert: &CodeAlert) -> SecurityEvent {
144 SecurityEvent {
145 repo_id: repo.repo_id.clone(),
146 alert_id: alert.id.clone(),
147 alert_type: AlertType::CodeScanning.as_str().to_owned(),
148 alert_number: Some(alert.number),
149 severity: alert.severity.clone(),
150 title: format!(
151 "{}: {}{}",
152 alert.tool,
153 alert.rule_name.as_deref().unwrap_or(&alert.rule_id),
154 alert.path.as_deref().map(|path| format!(" in {path}")).unwrap_or_default()
155 ),
156 link: link(repo, &format!("code-scanning/{}", alert.number)),
157 path: alert.path.clone(),
158 line: alert.start_line,
159 state: alert.state.as_str().to_owned(),
160 ..SecurityEvent::default()
161 }
162}
163
164impl Security {
165 pub(crate) async fn repo_record(&self, repo: &RepoRow) -> Result<Option<Repo>> {
166 let found: Outcome<Repo> = g1t_kit::call(
167 &self.repos,
168 "get",
169 &GetArgs { path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, viewer: Some(User::system(&repo.namespace)) },
170 )
171 .await?;
172 Ok(found.into_result().ok())
173 }
174
175 pub(crate) async fn upload_sarif(&self, a: UploadSarifArgs) -> Result<Outcome<SarifUpload>> {
176 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), crate::SEE_FINDINGS).await? {
177 Outcome::Ok(repo) => repo,
178 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
179 };
180 if let Some(refusal) = self.gate(&repo, PaidFeature::CodeScanning).await? {
181 return Ok(refusal);
182 }
183 if !is_sha(&a.commit_sha) {
184 return Ok(fail(FailureCode::Invalid, "commit_sha is a full commit hash."));
185 }
186 if !a.git_ref.starts_with("refs/") {
187 return Ok(fail(FailureCode::Invalid, "ref is a full ref: refs/heads/<branch> or refs/pull/<number>/head."));
188 }
189 let Some(record) = self.repo_record(&repo).await? else {
190 return Ok(fail(FailureCode::NotFound, "Repository not found."));
191 };
192 let default_ref = format!("refs/heads/{}", record.default_branch);
193 let pull = pull_of(&a.git_ref);
194 let id = self.store.add_upload(&repo.repo_id, &a.commit_sha, &a.git_ref, &a.actor.username).await?;
195 let parsed = sarif::decode_upload(&a.sarif).and_then(|text| sarif::parse(&text, a.category.as_deref(), a.checkout_uri.as_deref()));
196 let mut runs = match parsed {
197 Ok(runs) => runs,
198 Err(problem) => {
199 self.store.finish_upload(&id, true, std::slice::from_ref(&problem), &[]).await?;
200 return Ok(Outcome::Ok(self.upload_view(&repo, &id).await?.unwrap_or_else(|| failed_upload(&id, &a, problem))));
201 }
202 };
203 if let (Some(name), [run]) = (a.tool_name.as_deref().map(str::trim).filter(|name| !name.is_empty()), runs.as_mut_slice()) {
204 if run.category == run.tool {
205 run.category = name.to_owned();
206 }
207 run.tool = name.to_owned();
208 }
209 let mut analyses = Vec::new();
210 let mut errors = Vec::new();
211 for run in &runs {
212 if run.dropped > 0 {
213 errors.push(format!("{} results past the first {} of {} were not kept.", run.dropped, sarif::MAX_RESULTS, run.tool));
214 }
215 if a.git_ref == default_ref {
216 let live = self.store.live_fingerprints(&repo.repo_id, &run.tool, &run.category).await?;
217 let (_, fixed) = sarif::reconcile(&live, &run.findings);
218 let items: Vec<NewCodeAlert> = run
219 .findings
220 .iter()
221 .map(|finding| NewCodeAlert { tool: &run.tool, category: &run.category, finding, commit: &a.commit_sha })
222 .collect();
223 let opened = self.store.upsert_code_alerts(&repo.repo_id, &items).await?;
224 let fixed = self.store.fix_code_alerts(&repo.repo_id, &run.tool, &run.category, &fixed).await?;
225 analyses.push(
226 self.store
227 .add_analysis(&repo.repo_id, &id, run, &a.commit_sha, &a.git_ref, None, opened.len() as u32, fixed.len() as u32)
228 .await?,
229 );
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.230 // An event each for webhooks, and one summary of the analysis for the inbox.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar231 for (ids, action) in [(&opened, "created"), (&fixed, "fixed")] {
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.232 for alert_id in ids.iter().take(MAX_SUMMARY_IDS) {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar233 if let Some(alert) = self.store.code_alert_by_id(&repo.repo_id, alert_id).await? {
234 self.alert_event(AlertType::CodeScanning, action, &repo, code_event(&repo, &alert), Some(a.actor.id.clone())).await;
235 }
236 }
237 }
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.238 let changed = Changed { scan: "analysis", opened: opened.clone(), fixed: fixed.clone(), ..Changed::default() };
239 self.summarize(&repo, AlertType::CodeScanning, changed, Some(a.actor.id.clone())).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar240 } else {
241 analyses.push(self.store.add_analysis(&repo.repo_id, &id, run, &a.commit_sha, &a.git_ref, pull, 0, 0).await?);
242 }
243 }
244 self.store.finish_upload(&id, false, &errors, &analyses).await?;
245 if let Some(number) = pull
246 && let Err(error) = self.code_pull_check(&repo, number, &a.commit_sha, &runs).await
247 {
248 worker::console_error!("security: code scanning check on #{number} of {}: {error}", repo.repo_id);
249 }
250 Ok(Outcome::Ok(self.upload_view(&repo, &id).await?.unwrap_or_else(|| failed_upload(&id, &a, "not recorded".to_owned()))))
251 }
252
253 async fn upload_view(&self, repo: &RepoRow, id: &str) -> Result<Option<SarifUpload>> {
254 Ok(self.store.upload(&repo.repo_id, id).await?.map(|row| SarifUpload {
255 id: row.id,
256 processing_status: row.status,
257 analyses: serde_json::from_str(&row.analyses).unwrap_or_default(),
258 errors: serde_json::from_str(&row.errors).unwrap_or_default(),
259 commit_sha: row.commit_sha,
260 git_ref: row.git_ref,
261 created_at: row.created_at,
262 }))
263 }
264
265 pub(crate) async fn sarif_status(&self, a: SarifStatusArgs) -> Result<Outcome<SarifUpload>> {
266 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
267 Outcome::Ok(repo) => repo,
268 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
269 };
270 Ok(match self.upload_view(&repo, &a.id).await? {
271 Some(upload) => Outcome::Ok(upload),
272 None => fail(FailureCode::NotFound, "No such upload."),
273 })
274 }
275
276 pub(crate) async fn code_scanning(&self, a: CodeScanningArgs) -> Result<Outcome<CodeScanning>> {
277 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
278 Outcome::Ok(repo) => repo,
279 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
280 };
281 let (_, private) = self.store.repo_settings(&repo.repo_id).await?;
282 let default_branch = self.repo_record(&repo).await?.map(|record| record.default_branch).unwrap_or_else(|| "main".to_owned());
283 let starter: Outcome<BlobView> = g1t_kit::call(
284 &self.repos,
285 "blob",
286 &BlobArgs {
287 path: a.repo.clone(),
288 viewer: Some(User::system(&repo.namespace)),
289 git_ref: default_branch,
290 file_path: STARTER_WORKFLOW_PATH.to_owned(),
291 },
292 )
293 .await
294 .unwrap_or_else(|_| fail(FailureCode::NotFound, "unread"));
295 Ok(Outcome::Ok(CodeScanning {
296 alerts: self.store.code_alerts(&repo.repo_id).await?,
297 analyses: self.store.analyses(&repo.repo_id, 50).await?,
298 entitled: self.entitled(&repo).await?,
299 private,
300 configured: matches!(starter, Outcome::Ok(_)),
301 }))
302 }
303
304 pub(crate) async fn code_alert(&self, a: CodeAlertArgs) -> Result<Outcome<CodeAlertDetail>> {
305 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
306 Outcome::Ok(repo) => repo,
307 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
308 };
309 let Some(alert) = self.store.code_alert(&repo.repo_id, a.number).await? else {
310 return Ok(fail(FailureCode::NotFound, "No such alert."));
311 };
312 let activity: Vec<AlertActivity> =
313 self.store.activity(&repo.repo_id, 500).await?.into_iter().filter(|item| item.alert_id == alert.id).collect();
314 Ok(Outcome::Ok(CodeAlertDetail {
315 analyses: self.store.analyses_reporting(&repo.repo_id, &alert.fingerprint).await?,
316 activity,
317 alert,
318 }))
319 }
320
321 pub(crate) async fn set_code_alert_state(&self, a: SetCodeAlertStateArgs) -> Result<Outcome<CodeAlert>> {
322 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), crate::SEE_FINDINGS).await? {
323 Outcome::Ok(repo) => repo,
324 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
325 };
326 if !a.actor.verified {
327 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
328 }
329 let Some(alert) = self.store.code_alert(&repo.repo_id, a.number).await? else {
330 return Ok(fail(FailureCode::NotFound, "No such alert."));
331 };
332 let comment: String = a.comment.trim().chars().take(500).collect();
333 let comment = (!comment.is_empty()).then_some(comment);
334 let action = match a.state {
335 AlertState::Dismissed => {
336 let Some(reason) = a.reason.filter(|reason| {
337 matches!(reason, DismissReason::FalsePositive | DismissReason::WontFix | DismissReason::UsedInTests)
338 }) else {
339 return Ok(fail(FailureCode::Invalid, "A code scanning alert is dismissed as false_positive, wont_fix or used_in_tests."));
340 };
341 if alert.state != AlertState::Open {
342 return Ok(fail(FailureCode::Conflict, "This alert is not open."));
343 }
344 self.store.set_code_alert(&repo.repo_id, &alert.id, Some((reason, comment.as_deref(), &a.actor.username))).await?;
345 self.store
346 .record(&repo.repo_id, &[Activity {
347 alert_id: &alert.id,
348 action: "dismissed",
349 actor: Some(&a.actor.username),
350 reason: Some(reason),
351 comment: comment.as_deref(),
352 number: None,
353 }])
354 .await?;
355 "dismissed"
356 }
357 AlertState::Open => {
358 if alert.state != AlertState::Dismissed {
359 return Ok(fail(FailureCode::Conflict, "Only a dismissed alert can be reopened; a fixed one reopens when it is found again."));
360 }
361 self.store.set_code_alert(&repo.repo_id, &alert.id, None).await?;
362 self.store
363 .record(&repo.repo_id, &[Activity {
364 alert_id: &alert.id,
365 action: "reopened",
366 actor: Some(&a.actor.username),
367 reason: None,
368 comment: None,
369 number: None,
370 }])
371 .await?;
372 "reopened"
373 }
374 AlertState::Fixed => return Ok(fail(FailureCode::Invalid, "An alert is fixed by an analysis that no longer reports it.")),
375 };
376 let Some(alert) = self.store.code_alert(&repo.repo_id, a.number).await? else {
377 return Ok(fail(FailureCode::NotFound, "No such alert."));
378 };
379 let event = SecurityEvent { reason: a.reason.map(|reason| reason.as_str().to_owned()), ..code_event(&repo, &alert) };
380 self.alert_event(AlertType::CodeScanning, action, &repo, event, Some(a.actor.id.clone())).await;
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.381 let changed = Changed { scan: "change", dismissed: u32::from(action == "dismissed"), ..Changed::default() };
382 self.summarize(&repo, AlertType::CodeScanning, changed, Some(a.actor.id.clone())).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar383 Ok(Outcome::Ok(alert))
384 }
385
386 pub(crate) async fn pull_by_number(&self, repo: &RepoRow, number: u32) -> Result<Option<Pull>> {
387 let found: Outcome<PullDetail> = g1t_kit::call(
388 &self.work,
389 "get_pull",
390 &ViewArgs {
391 repo: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
392 number,
393 viewer: Some(User::system(&repo.namespace)),
394 after_seq: 0,
395 },
396 )
397 .await?;
398 Ok(found.into_result().ok().map(|detail| detail.pull))
399 }
400
401 /// What a pull request changes, as the repository compares it: its
402 /// branch (or fork) against where it left the branch it merges into.
403 pub(crate) async fn pull_comparison(&self, repo: &RepoRow, pull: &Pull) -> Result<Option<Comparison>> {
404 let (repo_id, head) = match &pull.fork_repo_id {
405 Some(fork) => (fork.clone(), None),
406 None => (repo.repo_id.clone(), pull.branch.clone()),
407 };
408 let compared: Outcome<Comparison> = g1t_kit::call(
409 &self.repos,
410 "compare",
411 &CompareArgs { repo_id, viewer: Some(User::system(&repo.namespace)), base: None, head, base_branch: pull.base.clone() },
412 )
413 .await?;
414 Ok(compared.into_result().ok())
415 }
416
417 pub(crate) async fn set_status(&self, repo: &RepoRow, sha: &str, context: &str, state: &str, description: &str, number: u32) {
418 let set: Result<Outcome<bool>> = g1t_kit::call(
419 &self.work,
420 "set_commit_status",
421 &SetCommitStatusArgs {
422 repo_id: repo.repo_id.clone(),
423 sha: sha.to_owned(),
424 context: context.to_owned(),
425 state: state.to_owned(),
426 description: Some(description.chars().take(140).collect()),
427 target_url: Some(format!("{SITE}/{}/{}/security/pulls/{number}", repo.namespace, repo.name)),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge428 source: Some("security".to_owned()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar429 },
430 )
431 .await;
432 if let Err(error) = set {
433 worker::console_error!("security: {context} status on {sha}: {error}");
434 }
435 }
436
437 /// Judges a pull request's code scanning results and reports the check.
438 async fn code_pull_check(&self, repo: &RepoRow, number: u32, commit: &str, runs: &[sarif::Run]) -> Result<()> {
439 let Some(pull) = self.pull_by_number(repo, number).await? else { return Ok(()) };
440 let (settings, _) = self.store.repo_settings(&repo.repo_id).await?;
441 let gate = Gate::parse(&settings.code_scanning_gate).unwrap_or(Gate::AtLeast(g1t_scan::osv::Severity::High));
442 let changed = match self.pull_comparison(repo, &pull).await? {
443 Some(comparison) => added_lines(&comparison),
444 None => BTreeMap::new(),
445 };
446 let on_default = self.store.open_code_fingerprints(&repo.repo_id).await?;
447 // This upload's runs, joined with what earlier uploads for the same
448 // commit found with other tools.
449 let previous = self.store.pull_check(&repo.repo_id, number, "code").await?;
450 let mut results: Vec<PullResult> = Vec::new();
451 let tools: BTreeSet<&str> = runs.iter().map(|run| run.tool.as_str()).collect();
452 if let Some(previous) = &previous
453 && previous.commit_sha == commit
454 {
455 let kept: Vec<PullResult> = serde_json::from_str::<Value>(&previous.detail)
456 .ok()
457 .and_then(|detail| serde_json::from_value(detail["results"].clone()).ok())
458 .unwrap_or_default();
459 results.extend(kept.into_iter().filter(|result| !tools.contains(result.tool.as_str())));
460 }
461 for run in runs {
462 for mut result in judge_pull(&run.findings, &on_default, &changed, gate) {
463 result.tool = run.tool.clone();
464 results.push(result);
465 }
466 }
467 let (state, description) = verdict(&results);
468 // Review comments on changed lines for results new to the pull
469 // request, each once.
470 let mut commented: Vec<String> = previous
471 .as_ref()
472 .and_then(|row| serde_json::from_str(&row.commented).ok())
473 .unwrap_or_default();
474 let page = format!("{SITE}/{}/{}/security/pulls/{number}", repo.namespace, repo.name);
475 let system = User::system(&repo.namespace);
476 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
477 let mut left = 0;
478 for result in results.iter().filter(|result| result.new && result.on_changed_line) {
479 let key = format!("{}|{}|{}|{}", result.tool, result.rule_id, result.path.as_deref().unwrap_or(""), result.line.unwrap_or(0));
480 if commented.contains(&key) || left == MAX_COMMENTS {
481 continue;
482 }
483 let _: Result<Outcome<Value>> = g1t_kit::call(
484 &self.work,
485 "add_comment",
486 &AddCommentArgs {
487 actor: system.clone(),
488 repo: path.clone(),
489 number,
490 body: comment_text(&result.tool, result, &page),
491 path: result.path.clone(),
492 line: result.line,
493 verdict: None,
494 },
495 )
496 .await;
497 commented.push(key);
498 left += 1;
499 }
500 self.store
501 .set_pull_check(&repo.repo_id, number, "code", commit, state, &description, &json!({ "results": results }), &commented)
502 .await?;
503 self.set_status(repo, commit, CODE_SCANNING_CHECK, state, &description, number).await;
504 Ok(())
505 }
506
507 pub(crate) async fn pull_code_scanning(&self, a: PullScanningArgs) -> Result<Outcome<PullScanning>> {
508 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
509 Outcome::Ok(repo) => repo,
510 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
511 };
512 let code = self.store.pull_check(&repo.repo_id, a.number, "code").await?;
513 let review = self.store.pull_check(&repo.repo_id, a.number, "review").await?;
514 Ok(Outcome::Ok(PullScanning {
515 commit: code.as_ref().or(review.as_ref()).map(|row| row.commit_sha.clone()),
516 results: code
517 .as_ref()
518 .and_then(|row| serde_json::from_str::<Value>(&row.detail).ok())
519 .and_then(|detail| serde_json::from_value(detail["results"].clone()).ok())
520 .unwrap_or_default(),
521 review: review.as_ref().and_then(|row| serde_json::from_str(&row.detail).ok()),
522 code_status: code.as_ref().map(|row| row.state.clone()),
523 code_description: code.as_ref().map(|row| row.description.clone()),
524 }))
525 }
526}
527
528fn failed_upload(id: &str, a: &UploadSarifArgs, problem: String) -> SarifUpload {
529 SarifUpload {
530 id: id.to_owned(),
531 processing_status: "failed".to_owned(),
532 analyses: Vec::new(),
533 errors: vec![problem],
534 commit_sha: a.commit_sha.clone(),
535 git_ref: a.git_ref.clone(),
536 created_at: crate::store::now(),
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use g1t_contracts::repos::{DiffLine, FileDiff, FileStatus, Hunk};
544 use g1t_scan::osv::Severity;
545
546 const CODEQL: &str = include_str!("../../../crates/scan/fixtures/codeql.sarif");
547
548 #[test]
549 fn pull_refs_name_their_pull_request() {
550 assert_eq!(pull_of("refs/pull/12/head"), Some(12));
551 assert_eq!(pull_of("refs/pull/12/merge"), Some(12));
552 assert_eq!(pull_of("refs/heads/main"), None);
553 assert_eq!(pull_of("refs/pull/x/head"), None);
554 assert!(is_sha("4807077b296e6edbf410d55e72749d3e1170c291") && !is_sha("main"));
555 }
556
557 fn comparison() -> Comparison {
558 let line = |kind, new| DiffLine { kind, old: None, new, text: String::new() };
559 Comparison {
560 base: Some("a".into()),
561 head: "b".into(),
562 truncated: false,
563 files: vec![FileDiff {
564 path: "src/server.js".into(),
565 status: FileStatus::Modified,
566 additions: 1,
567 deletions: 0,
568 binary: false,
569 hunks: vec![Hunk { lines: vec![line(LineKind::Context, Some(11)), line(LineKind::Add, Some(12)), line(LineKind::Delete, None)] }],
570 }],
571 }
572 }
573
574 #[test]
575 fn only_new_results_on_changed_lines_fail_the_check() {
576 let runs = sarif::parse(CODEQL, None, None).unwrap();
577 let findings = &runs[0].findings;
578 let changed = added_lines(&comparison());
579 assert_eq!(changed["src/server.js"], BTreeSet::from([12]));
580 // Nothing on the default branch yet: the injection on line 12 is new
581 // and on a changed line, and high, so it fails.
582 let results = judge_pull(findings, &BTreeSet::new(), &changed, Gate::AtLeast(Severity::High));
583 let injection = results.iter().find(|result| result.rule_id == "js/sql-injection").unwrap();
584 assert!(injection.new && injection.on_changed_line && injection.failing);
585 // The logging result is new but on a line the pull request did not touch.
586 let logging = results.iter().find(|result| result.rule_id == "js/clear-text-logging").unwrap();
587 assert!(logging.new && !logging.on_changed_line && !logging.failing);
588 assert_eq!(verdict(&results), ("failure", "1 new result at or above the threshold".to_owned()));
589 // Already open on the default branch: not this pull request's.
590 let known: BTreeSet<String> = findings.iter().map(|finding| finding.fingerprint.clone()).collect();
591 let results = judge_pull(findings, &known, &changed, Gate::AtLeast(Severity::High));
592 assert!(results.iter().all(|result| !result.new && !result.failing));
593 assert_eq!(verdict(&results).0, "success");
594 // A gate of none never fails.
595 let results = judge_pull(findings, &BTreeSet::new(), &changed, Gate::None);
596 assert_eq!(verdict(&results), ("success", "1 new result, below the threshold".to_owned()));
597 }
598
599 #[test]
600 fn a_comment_names_the_tool_rule_and_severity() {
601 let result = PullResult {
602 tool: "CodeQL".into(),
603 rule_id: "js/sql-injection".into(),
604 level: "error".into(),
605 severity: "high".into(),
606 security_severity: Some("high".into()),
607 message: "This query string depends on a user-provided value.".into(),
608 path: Some("src/server.js".into()),
609 line: Some(12),
610 new: true,
611 on_changed_line: true,
612 failing: true,
613 };
614 let text = comment_text("CodeQL", &result, "https://g1t.sh/acme/rocket/security/pulls/3");
615 assert!(text.starts_with("**CodeQL: `js/sql-injection`** (high)"));
616 assert!(text.contains("depends on a user-provided value") && text.contains("/security/pulls/3"));
617 }
618}

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