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.

212 linesCodeBlameRaw
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
4use g1t_contracts::repos::{Repo, RepoPath};
5use g1t_contracts::security::{OverviewArgs, SeverityCounts};
6use g1t_contracts::{Outcome, User};
7use worker::Fetcher;
8
9use 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.
13fn 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
28fn 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
47fn 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
75fn 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
86pub(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)]
122mod 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}