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}; | |
| 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. | 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; | |
| 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. | 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 | ); | |
| 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 bar | 231 | 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 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 | } | |
| 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 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; | |
| 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 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", | |
| 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 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, | |
| 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.