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 bar | 1 | //! 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 | ||
| 14 | use std::collections::{BTreeMap, BTreeSet}; | |
| 15 | ||
| 16 | use g1t_contracts::repos::{BlobArgs, BlobView, CompareArgs, Comparison, GetArgs, LineKind, Repo, RepoPath}; | |
| 17 | use g1t_contracts::security::{AlertActivity, AlertState, DismissReason}; | |
| 18 | use 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 | }; | |
| 23 | use g1t_contracts::work::{AddCommentArgs, Pull, PullDetail, SetCommitStatusArgs, ViewArgs}; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 24 | use g1t_contracts::security_suite::MAX_SUMMARY_IDS; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 25 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 26 | use g1t_scan::sarif::{self, Finding, Gate}; | |
| 27 | use serde_json::{Value, json}; | |
| 28 | use worker::Result; | |
| 29 | ||
| 30 | use crate::Security; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 31 | use crate::summaries::Changed; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 32 | use crate::store::{Activity, RepoRow}; |
| 33 | use crate::suite::link; | |
| 34 | use crate::suite_store::NewCodeAlert; | |
| 35 | ||
| 36 | const SITE: &str = "https://g1t.sh"; | |
| 37 | /// Review comments one analysis of a pull request leaves, at most. | |
| 38 | const MAX_COMMENTS: usize = 10; | |
| 39 | /// Alerts an upload announces one by one; past it, the page says the rest. | |
| 40 | ||
| 41 | fn 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. | |
| 46 | pub 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`. | |
| 59 | pub 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 | ||
| 65 | fn 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. | |
| 70 | pub 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. | |
| 88 | pub 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. | |
| 119 | pub 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. | |
| 134 | pub 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. | |
| 143 | pub 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 | ||
| 164 | impl 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 | ); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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 bar | 231 | for (ids, action) in [(&opened, "created"), (&fixed, "fixed")] { |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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 bar | 233 | 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 | } | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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 bar | 240 | } 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; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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 bar | 383 | 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", | |
| Large pull requests, commits and comparisons open quickly and no longer risk failing: the Files changed tab of a 206-file pull request sent every file's diff at once, 14,366 table rows drawn on the server (6.7 MB of its 8.7 MB of HTML) and the same lines again in the data the page hydrates from (1.6 MB), with 300 KB of highlighting sent twice. Now the page arrives with the whole file list, tree and counts, and the lines of its first files, about 2,000; every later file holds its place at its height and loads as you scroll near it, eight or so to a request (diff-files.json, cached by the two commits compared, here and in the browser), and a refresh keeps what was loaded. A file with a diff over 1,000 lines (a lockfile, generated code) waits for Load diff; files with comments on their lines come with the page wherever they are; a link to a file (#file-<path>, or #diff-<path>) opens it, loads it at once however large, and keeps it at the top while the files above it fill in. Small changes are sent exactly as before. Highlighting done on the server stops after 16,000 characters rather than 40,000; the rest is coloured in the browser as it comes near, as it already was. The repos service listed the files past its 20,000-line read as binary with no counts, so 126 of this pull request's files said "Binary or large file"; they are now marked omitted, show no counts until read ("126 files not counted" in the toolbar), and load like the rest, read on their own by compare's new paths (at most 50); truncated now means only that more than 300 files changed. get_pull_request_changes and GET /repos/{owner}/{name}/pulls/{number}/changes take paths too, and the mobile app says an omitted file is too large to show there. The pull requests guide gains Files changed, and limitations says how large changes are shown. Checked: the site's typecheck, its 914 tests (new: the first-screens budget, large and commented files, omitted and binary ones, links to a file, and fetching in batches, once, with retries) and a production build; cargo test for contracts, repos (new: omitted files and reading by path), actions, security, work and api; the mobile typecheck; the docs build. Measured on flagon-io/g1t pull 1's real response, before → after: single-fetch data 1,592,101 → 236,252 bytes; the diff's server-rendered HTML 6,694,038 → 1,698,300 bytes (renderToString 62 → 15 ms), so the page's HTML about 8.7 MB → 2.0 MB; in a harness mounting the real routes in Chrome at 1280 px, first render 600 → 270 ms, settled 2,250 → 750 ms, long tasks 1,700 → 500 ms, 84,605 → 17,982 elements; scrolling to the bottom loads all 72 waiting files in 9 requests, and refreshes with the same data remove no elements, before and after files load. Screenshots at 1280 and 390 px looked at. | 411 | &CompareArgs { repo_id, viewer: Some(User::system(&repo.namespace)), base: None, head, base_branch: pull.base.clone(), paths: None }, |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 merge | 428 | source: Some("security".to_owned()), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 429 | }, |
| 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 | ||
| 528 | fn 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)] | |
| 541 | mod 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, | |
| Large pull requests, commits and comparisons open quickly and no longer risk failing: the Files changed tab of a 206-file pull request sent every file's diff at once, 14,366 table rows drawn on the server (6.7 MB of its 8.7 MB of HTML) and the same lines again in the data the page hydrates from (1.6 MB), with 300 KB of highlighting sent twice. Now the page arrives with the whole file list, tree and counts, and the lines of its first files, about 2,000; every later file holds its place at its height and loads as you scroll near it, eight or so to a request (diff-files.json, cached by the two commits compared, here and in the browser), and a refresh keeps what was loaded. A file with a diff over 1,000 lines (a lockfile, generated code) waits for Load diff; files with comments on their lines come with the page wherever they are; a link to a file (#file-<path>, or #diff-<path>) opens it, loads it at once however large, and keeps it at the top while the files above it fill in. Small changes are sent exactly as before. Highlighting done on the server stops after 16,000 characters rather than 40,000; the rest is coloured in the browser as it comes near, as it already was. The repos service listed the files past its 20,000-line read as binary with no counts, so 126 of this pull request's files said "Binary or large file"; they are now marked omitted, show no counts until read ("126 files not counted" in the toolbar), and load like the rest, read on their own by compare's new paths (at most 50); truncated now means only that more than 300 files changed. get_pull_request_changes and GET /repos/{owner}/{name}/pulls/{number}/changes take paths too, and the mobile app says an omitted file is too large to show there. The pull requests guide gains Files changed, and limitations says how large changes are shown. Checked: the site's typecheck, its 914 tests (new: the first-screens budget, large and commented files, omitted and binary ones, links to a file, and fetching in batches, once, with retries) and a production build; cargo test for contracts, repos (new: omitted files and reading by path), actions, security, work and api; the mobile typecheck; the docs build. Measured on flagon-io/g1t pull 1's real response, before → after: single-fetch data 1,592,101 → 236,252 bytes; the diff's server-rendered HTML 6,694,038 → 1,698,300 bytes (renderToString 62 → 15 ms), so the page's HTML about 8.7 MB → 2.0 MB; in a harness mounting the real routes in Chrome at 1280 px, first render 600 → 270 ms, settled 2,250 → 750 ms, long tasks 1,700 → 500 ms, 84,605 → 17,982 elements; scrolling to the bottom loads all 72 waiting files in 9 requests, and refreshes with the same data remove no elements, before and after files load. Screenshots at 1280 and 390 px looked at. | 569 | omitted: false, |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 570 | hunks: vec![Hunk { lines: vec![line(LineKind::Context, Some(11)), line(LineKind::Add, Some(12)), line(LineKind::Delete, None)] }], |
| 571 | }], | |
| 572 | } | |
| 573 | } | |
| 574 | ||
| 575 | #[test] | |
| 576 | fn only_new_results_on_changed_lines_fail_the_check() { | |
| 577 | let runs = sarif::parse(CODEQL, None, None).unwrap(); | |
| 578 | let findings = &runs[0].findings; | |
| 579 | let changed = added_lines(&comparison()); | |
| 580 | assert_eq!(changed["src/server.js"], BTreeSet::from([12])); | |
| 581 | // Nothing on the default branch yet: the injection on line 12 is new | |
| 582 | // and on a changed line, and high, so it fails. | |
| 583 | let results = judge_pull(findings, &BTreeSet::new(), &changed, Gate::AtLeast(Severity::High)); | |
| 584 | let injection = results.iter().find(|result| result.rule_id == "js/sql-injection").unwrap(); | |
| 585 | assert!(injection.new && injection.on_changed_line && injection.failing); | |
| 586 | // The logging result is new but on a line the pull request did not touch. | |
| 587 | let logging = results.iter().find(|result| result.rule_id == "js/clear-text-logging").unwrap(); | |
| 588 | assert!(logging.new && !logging.on_changed_line && !logging.failing); | |
| 589 | assert_eq!(verdict(&results), ("failure", "1 new result at or above the threshold".to_owned())); | |
| 590 | // Already open on the default branch: not this pull request's. | |
| 591 | let known: BTreeSet<String> = findings.iter().map(|finding| finding.fingerprint.clone()).collect(); | |
| 592 | let results = judge_pull(findings, &known, &changed, Gate::AtLeast(Severity::High)); | |
| 593 | assert!(results.iter().all(|result| !result.new && !result.failing)); | |
| 594 | assert_eq!(verdict(&results).0, "success"); | |
| 595 | // A gate of none never fails. | |
| 596 | let results = judge_pull(findings, &BTreeSet::new(), &changed, Gate::None); | |
| 597 | assert_eq!(verdict(&results), ("success", "1 new result, below the threshold".to_owned())); | |
| 598 | } | |
| 599 | ||
| 600 | #[test] | |
| 601 | fn a_comment_names_the_tool_rule_and_severity() { | |
| 602 | let result = PullResult { | |
| 603 | tool: "CodeQL".into(), | |
| 604 | rule_id: "js/sql-injection".into(), | |
| 605 | level: "error".into(), | |
| 606 | severity: "high".into(), | |
| 607 | security_severity: Some("high".into()), | |
| 608 | message: "This query string depends on a user-provided value.".into(), | |
| 609 | path: Some("src/server.js".into()), | |
| 610 | line: Some(12), | |
| 611 | new: true, | |
| 612 | on_changed_line: true, | |
| 613 | failing: true, | |
| 614 | }; | |
| 615 | let text = comment_text("CodeQL", &result, "https://g1t.sh/acme/rocket/security/pulls/3"); | |
| 616 | assert!(text.starts_with("**CodeQL: `js/sql-injection`** (high)")); | |
| 617 | assert!(text.contains("depends on a user-provided value") && text.contains("/security/pulls/3")); | |
| 618 | } | |
| 619 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.