Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Show vulnerability summaries after successful git pushes

syntaqxcommitted Parent3497fccBrowse files
5 files+231−20/5 viewed
+2−0
606606
607607 /// `overview`: members of the workspace only. Returns
608608 /// `Outcome<SecurityOverview>`.
609+/// Also used by `push_summary`, returning `Outcome<SeverityCounts>` for
610+/// open vulnerabilities from the latest scan, without starting a scan.
609611 #[derive(Debug, Serialize, Deserialize)]
610612 pub struct OverviewArgs {
611613 pub repo: RepoPath,
+5−1
3737 mod pack_cache;
3838 mod pack_limits;
3939 mod push_checks;
40+mod push_feedback;
4041 mod push_commits;
4142 mod refs;
4243 mod refs_cache;
22572258 // only then goes back. Whoever fetches after it sees the push.
22582259 let headers = response.headers().clone();
22592260 headers.delete("content-length")?;
2260− let report = response.bytes().await?;
2261+ let mut report = response.bytes().await?;
22612262 self.refs_moved(&repo.id).await;
22622263 timing.mark("refs");
2264+ if status == 200 {
2265+ push_feedback::add(&mut report, self.security.as_ref(), &repo, &viewer).await;
2266+ }
22632267 response = Response::from_bytes(report)?.with_headers(headers).with_status(status);
22642268 } else if let (Some(kept_key), 200) = (&kept_key, status) {
22652269 // A miss: this answer is kept for the next to ask.
+212−0
1+//! Dependency alerts from the latest scan, on Git's progress channel.
2+//! Never turn a successful push into a failure when security is unavailable.
3+
4+use g1t_contracts::repos::{Repo, RepoPath};
5+use g1t_contracts::security::{OverviewArgs, SeverityCounts};
6+use g1t_contracts::{Outcome, User};
7+use worker::Fetcher;
8+
9+use crate::git_http::pkt_line;
10+
11+/// Strict packets with a terminal flush. Plain report-status and malformed
12+/// responses must remain untouched: only negotiated side-band carries text.
13+fn packets(mut bytes: &[u8]) -> Option<Vec<&[u8]>> {
14+ let mut result = Vec::new();
15+ loop {
16+ let size = usize::from_str_radix(std::str::from_utf8(bytes.get(..4)?).ok()?, 16).ok()?;
17+ if size == 0 {
18+ return (bytes.len() == 4).then_some(result);
19+ }
20+ if size <= 4 {
21+ return None;
22+ }
23+ result.push(bytes.get(4..size)?);
24+ bytes = bytes.get(size..)?;
25+ }
26+}
27+
28+fn successful(report: &[u8]) -> bool {
29+ let Some(bands) = packets(report) else {
30+ return false;
31+ };
32+ let mut status = Vec::new();
33+ for band in bands {
34+ match band[0] {
35+ 1 => status.extend_from_slice(&band[1..]),
36+ 2 => {}
37+ _ => return false,
38+ }
39+ }
40+ let Some(lines) = packets(&status) else {
41+ return false;
42+ };
43+ lines.first() == Some(&b"unpack ok\n".as_slice())
44+ && lines.iter().any(|line| line.starts_with(b"ok "))
45+}
46+
47+fn message(counts: &SeverityCounts, namespace: &str, name: &str) -> Option<String> {
48+ let severities = [
49+ (counts.critical, "critical"),
50+ (counts.high, "high"),
51+ (counts.medium, "moderate"),
52+ (counts.low, "low"),
53+ (counts.unknown, "unrated"),
54+ ];
55+ let total: u64 = severities.iter().map(|(n, _)| u64::from(*n)).sum();
56+ if total == 0 {
57+ return None;
58+ }
59+ let details = severities
60+ .iter()
61+ .filter(|(n, _)| *n > 0)
62+ .map(|(n, label)| format!("{n} {label}"))
63+ .collect::<Vec<_>>()
64+ .join(", ");
65+ let noun = if total == 1 {
66+ "vulnerability"
67+ } else {
68+ "vulnerabilities"
69+ };
70+ Some(format!(
71+ "\ng1t found {total} {noun} on {namespace}/{name}'s default branch ({details}).\nLatest completed scan; default-branch pushes are re-scanned in the background.\nTo find out more, visit:\n https://g1t.sh/{namespace}/{name}/security/vulnerabilities\n\n"
72+ ))
73+}
74+
75+fn append(report: &mut Vec<u8>, text: &str) {
76+ report.truncate(report.len() - 4);
77+ // Also fits the original side-band capability's 1000-byte limit.
78+ for chunk in text.as_bytes().chunks(990) {
79+ let mut payload = vec![2];
80+ payload.extend_from_slice(chunk);
81+ report.extend(pkt_line(&payload));
82+ }
83+ report.extend_from_slice(b"0000");
84+}
85+
86+pub(crate) async fn add(
87+ report: &mut Vec<u8>,
88+ security: Option<&Fetcher>,
89+ repo: &Repo,
90+ viewer: &Option<User>,
91+) {
92+ if !successful(report) {
93+ return;
94+ }
95+ let Some(security) = security else { return };
96+ let args = OverviewArgs {
97+ repo: RepoPath {
98+ namespace: repo.namespace.clone(),
99+ name: repo.name.clone(),
100+ },
101+ viewer: viewer.clone(),
102+ };
103+ match g1t_kit::call_within::<_, Outcome<SeverityCounts>>(
104+ security,
105+ "push_summary",
106+ &args,
107+ std::time::Duration::from_secs(2),
108+ )
109+ .await
110+ {
111+ Ok(Some(Outcome::Ok(counts))) => {
112+ if let Some(text) = message(&counts, &repo.namespace, &repo.name) {
113+ append(report, &text);
114+ }
115+ }
116+ Ok(Some(Outcome::Fail(_)) | None) => {}
117+ Err(error) => worker::console_warn!("push security summary unavailable: {error}"),
118+ }
119+}
120+
121+#[cfg(test)]
122+mod tests {
123+ use super::*;
124+
125+ fn report(lines: &[&str]) -> Vec<u8> {
126+ let mut status: Vec<u8> = lines
127+ .iter()
128+ .flat_map(|line| pkt_line(line.as_bytes()))
129+ .collect();
130+ status.extend_from_slice(b"0000");
131+ // Split inside a status packet to exercise reassembly.
132+ let mut answer = Vec::new();
133+ for chunk in status.chunks(7) {
134+ let mut band = vec![1];
135+ band.extend_from_slice(chunk);
136+ answer.extend(pkt_line(&band));
137+ }
138+ answer.extend_from_slice(b"0000");
139+ answer
140+ }
141+
142+ #[test]
143+ fn progress_preserves_status_and_terminal_flush() {
144+ let mut answer = report(&[
145+ "unpack ok\n",
146+ "ok refs/heads/main\n",
147+ "ng refs/pull/1/head hidden\n",
148+ ]);
149+ assert!(successful(&answer));
150+ let original = answer[..answer.len() - 4].to_vec();
151+ append(&mut answer, &"hello\n".repeat(400));
152+ assert!(answer.starts_with(&original));
153+ assert!(successful(&answer));
154+ let bands = packets(&answer).unwrap();
155+ let progress: Vec<u8> = bands
156+ .iter()
157+ .filter(|band| band[0] == 2)
158+ .flat_map(|band| band[1..].iter().copied())
159+ .collect();
160+ assert_eq!(progress, "hello\n".repeat(400).as_bytes());
161+ assert!(bands.iter().all(|band| band.len() + 4 <= 1000));
162+ }
163+
164+ #[test]
165+ fn refusals_plain_and_malformed_reports_are_unchanged() {
166+ assert!(!successful(&report(&[
167+ "unpack ok\n",
168+ "ng refs/heads/main denied\n"
169+ ])));
170+ assert!(!successful(&report(&[
171+ "unpack failed\n",
172+ "ok refs/heads/main\n"
173+ ])));
174+ assert!(!successful(b"000eunpack ok\n0000"));
175+ assert!(!successful(b"0000"));
176+ let answer = report(&["unpack ok\n", "ok refs/heads/main\n"]);
177+ for end in 0..answer.len() {
178+ assert!(!successful(&answer[..end]));
179+ }
180+ }
181+
182+ #[test]
183+ fn counts_are_explicit_about_scan_freshness() {
184+ assert_eq!(message(&SeverityCounts::default(), "acme", "repo"), None);
185+ let text = message(
186+ &SeverityCounts {
187+ high: 2,
188+ medium: 1,
189+ ..Default::default()
190+ },
191+ "acme",
192+ "repo",
193+ )
194+ .unwrap();
195+ assert!(text.contains("3 vulnerabilities"));
196+ assert!(text.contains("2 high, 1 moderate"));
197+ assert!(text.contains("Latest completed scan"));
198+ assert!(text.contains("https://g1t.sh/acme/repo/security/vulnerabilities"));
199+ assert!(
200+ message(
201+ &SeverityCounts {
202+ unknown: 1,
203+ ..Default::default()
204+ },
205+ "acme",
206+ "repo"
207+ )
208+ .unwrap()
209+ .contains("1 vulnerability")
210+ );
211+ }
212+}
+11−0
229229 }
230230 }
231231
232+ /// The latest completed dependency scan, without starting work on the
233+ /// push response path. Uses the same access check as the Security page.
234+ async fn push_summary(&self, a: OverviewArgs) -> Result<Outcome<SeverityCounts>> {
235+ let repo = match self.member_repo(&a.repo, &a.viewer, SEE_FINDINGS).await? {
236+ Outcome::Ok(repo) => repo,
237+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
238+ };
239+ Ok(Outcome::Ok(self.open_counts(&repo, g1t_contracts::security_suite::AlertType::Vulnerability).await?))
240+ }
241+
232242 async fn overview(&self, a: OverviewArgs) -> Result<Outcome<SecurityOverview>> {
233243 let mut repo = match self.member_repo(&a.repo, &a.viewer, SEE_FINDINGS).await? {
234244 Outcome::Ok(repo) => repo,
708718 let body: serde_json::Value = request.json().await?;
709719 match method.as_str() {
710720 "overview" => reply(&security.overview(args(body)?).await?),
721+ "push_summary" => reply(&security.push_summary(args(body)?).await?),
711722 "dismiss" => reply(&security.dismiss(args(body)?).await?),
712723 "reopen" => reply(&security.reopen(args(body)?).await?),
713724 "rescan" => reply(&security.rescan(args(body)?).await?),
+1−1
121121 /// What is open of one type, by severity. A secret in the history that
122122 /// looks real, or stopped at a push and not yet decided, is critical; a
123123 /// likely test value is low.
124− async fn open_counts(&self, repo: &RepoRow, alert_type: AlertType) -> Result<SeverityCounts> {
124+ pub(crate) async fn open_counts(&self, repo: &RepoRow, alert_type: AlertType) -> Result<SeverityCounts> {
125125 Ok(match alert_type {
126126 AlertType::SecretScanning => {
127127 let secrets = self.store.secret_counts(&repo.repo_id).await?;

Comments

Sign in to comment.