Skip to content
1,090 linesCodeBlameRaw
1//! Looking for secrets in git: in what a push adds, before it is stored
2//! (push protection), and in a repository's history, a page at a time, for
3//! the security service. Also finds the lockfiles it reads dependencies
4//! from. What counts as a secret is `g1t_scan`'s business.
5//!
6//! Push protection also keeps a person's private address out of what they
7//! push, when they asked g1t to (see [`exposed_address`]).
8//!
9//! Custom patterns (the security suite's) are looked for alongside the
10//! built-in formats, in pushes, history and files committed through g1t
11//! itself; the security service says which apply ([`Repos::patterns_for`]).
12//! It can also run a pattern over the default branch for a dry run
13//! ([`Repos::match_pattern`]), and ask a landed secret's issuer whether it
14//! still works ([`Repos::check_secret`]), without the value ever leaving
15//! this service except to that issuer.
16
17use std::cell::Cell;
18use std::collections::{HashSet, VecDeque};
19
20use futures_util::future::try_join_all;
21use g1t_contracts::User;
22use g1t_contracts::accounts::{CommitIdentityArgs, PushEmailGuard, mask_email};
23use g1t_contracts::repos::{EntryKind, Repo, RepoPath};
24use g1t_contracts::security::{
25 FindLockfilesArgs, HistoryPage, LockfileText, Lockfiles, NewSecret, PushBlockedArgs, PushVerdict,
26 ScanHistoryArgs,
27};
28use g1t_contracts::security_suite::{CheckSecretArgs, MatchPatternArgs, PatternMatch, PatternMatches, PatternSpec, PatternsForArgs, SecretValidity};
29use g1t_scan::custom::{self, Compiled};
30use g1t_scan::lockfiles::Lockfile;
31use g1t_scan::pack::{ObjectKind, Pack, TreeItem, encode_tree};
32use g1t_scan::protection::{self, Blocked};
33use worker::Result;
34
35use crate::registry::store_key;
36use crate::store::{GitRepo, GitStore};
37
38/// Where people allow a secret: the project's Security page.
39const SITE: &str = "https://g1t.sh";
40/// A push adding more commits than this is scanned for this many of them.
41const MAX_PUSH_COMMITS: usize = 300;
42/// Files compared per commit, at most.
43const MAX_FILES_PER_COMMIT: usize = 300;
44/// Bases fetched from the store for a thin pack, at most.
45const MAX_BASES: usize = 500;
46/// The largest push that is read whole and scanned. A larger one is
47/// declined, since it cannot be checked (git_http.rs `LargePushes`).
48pub const MAX_SCANNED_PUSH: usize = 24 * 1024 * 1024;
49/// What marks an error as a push too large to scan.
50const UNSCANNABLE: &str = "push-unscannable:";
51
52/// Whether an error says the push was too large to scan.
53pub fn unscannable(error: &worker::Error) -> bool {
54 error.to_string().contains(UNSCANNABLE)
55}
56const READS_AT_ONCE: usize = 16;
57/// Directories never searched for lockfiles.
58const SKIPPED_DIRECTORIES: [&str; 8] = ["node_modules", "vendor", "target", ".git", "dist", "build", "third_party", ".venv"];
59const MAX_LOCKFILES: usize = 40;
60const MAX_LOCKFILE_DEPTH: usize = 4;
61const MAX_LOCKFILE_BYTES: usize = 16 * 1024 * 1024;
62
63fn mode(kind: EntryKind) -> &'static str {
64 match kind {
65 EntryKind::Tree => "40000",
66 EntryKind::Blob => "100644",
67 EntryKind::Exec => "100755",
68 EntryKind::Symlink => "120000",
69 EntryKind::Gitlink => "160000",
70 }
71}
72
73/// Objects for a walk: the pushed pack's first, then the repository's.
74pub(crate) struct Objects<'a, R: GitRepo> {
75 pub(crate) pack: &'a Pack,
76 pub(crate) repo: &'a R,
77 pub(crate) reads: Cell<u32>,
78}
79
80impl<R: GitRepo> Objects<'_, R> {
81 pub(crate) async fn tree(&self, id: &str) -> Result<Vec<TreeItem>> {
82 if let Some(items) = self.pack.tree(id) {
83 return Ok(items);
84 }
85 self.reads.set(self.reads.get() + 1);
86 Ok(self
87 .repo
88 .read_tree(id)
89 .await?
90 .unwrap_or_default()
91 .into_iter()
92 .map(|entry| TreeItem { mode: mode(entry.kind).to_owned(), name: entry.name, id: entry.hash })
93 .collect())
94 }
95
96 async fn blob(&self, id: &str) -> Result<Option<Vec<u8>>> {
97 if let Some(bytes) = self.pack.blob(id) {
98 return Ok(Some(bytes.to_vec()));
99 }
100 self.reads.set(self.reads.get() + 1);
101 self.repo.read_blob(id).await
102 }
103
104 pub(crate) async fn commit_tree(&self, id: &str) -> Result<Option<String>> {
105 if let Some(commit) = self.pack.commit(id) {
106 return Ok(Some(commit.tree));
107 }
108 self.reads.set(self.reads.get() + 1);
109 Ok(self.repo.log(id, 1).await?.into_iter().next().map(|commit| commit.tree_hash))
110 }
111}
112
113/// A file that differs between two trees: its path, the blob it was and
114/// the blob it is.
115struct Change {
116 path: String,
117 old: Option<String>,
118 new: String,
119}
120
121/// The regular files whose content differs between two trees. Each level
122/// is read at once; identical subtrees are skipped by id.
123async fn changed_files<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<String>, new_root: String) -> Result<Vec<Change>> {
124 let mut changes = Vec::new();
125 let mut level = vec![(String::new(), old_root, new_root)];
126 while !level.is_empty() && changes.len() < MAX_FILES_PER_COMMIT {
127 let read = try_join_all(level.iter().map(|(_, old, new)| async move {
128 let old = match old {
129 Some(old) => objects.tree(old).await?,
130 None => Vec::new(),
131 };
132 Ok::<_, worker::Error>((old, objects.tree(new).await?))
133 }))
134 .await?;
135 let mut next = Vec::new();
136 for ((prefix, _, _), (old, new)) in level.iter().zip(read) {
137 for item in &new {
138 let before = old.iter().find(|entry| entry.name == item.name);
139 if before.is_some_and(|before| before.id == item.id) {
140 continue;
141 }
142 let path = format!("{prefix}{}", item.name);
143 if item.is_tree() {
144 next.push((format!("{path}/"), before.filter(|b| b.is_tree()).map(|b| b.id.clone()), item.id.clone()));
145 } else if item.is_file() && changes.len() < MAX_FILES_PER_COMMIT {
146 changes.push(Change {
147 path,
148 old: before.filter(|b| b.is_file()).map(|b| b.id.clone()),
149 new: item.id.clone(),
150 });
151 }
152 }
153 }
154 level = next;
155 }
156 Ok(changes)
157}
158
159/// The scanner's custom patterns, compiled; any that no longer compile are
160/// skipped.
161pub fn compiled(patterns: &[PatternSpec]) -> Vec<Compiled> {
162 let specs: Vec<custom::PatternSpec> = patterns
163 .iter()
164 .map(|spec| custom::PatternSpec {
165 id: spec.id.clone(),
166 name: spec.name.clone(),
167 pattern: spec.pattern.clone(),
168 before: spec.before.clone(),
169 after: spec.after.clone(),
170 })
171 .collect();
172 custom::compile_all(&specs)
173}
174
175/// What a custom pattern found, as the security service records it.
176fn custom_secret(hit: custom::CustomHit, path: &str, commit: &str) -> NewSecret {
177 NewSecret {
178 fingerprint: hit.fingerprint(),
179 kind: custom::KIND.to_owned(),
180 path: path.to_owned(),
181 line: hit.line,
182 commit: commit.to_owned(),
183 preview: hit.preview(),
184 test_value: None,
185 pattern_id: Some(hit.pattern_id),
186 pattern_name: Some(hit.pattern_name),
187 }
188}
189
190/// How a sentence names a secret found: its format, or its pattern.
191pub fn secret_label(secret: &NewSecret) -> Option<String> {
192 if secret.kind == custom::KIND {
193 return Some(custom::label(secret.pattern_name.as_deref().unwrap_or("custom")));
194 }
195 g1t_scan::secrets::SecretKind::parse(&secret.kind).map(|kind| kind.label().to_owned())
196}
197
198/// The secrets a new file holds, built-in and custom, for a commit made
199/// through g1t rather than pushed.
200pub fn scan_file(path: &str, bytes: &[u8], commit: &str, patterns: &[Compiled]) -> Vec<NewSecret> {
201 let mut found: Vec<NewSecret> = protection::scan_change(path, None, bytes)
202 .into_iter()
203 .map(|hit| NewSecret {
204 fingerprint: hit.fingerprint(),
205 kind: hit.kind.id().to_owned(),
206 path: path.to_owned(),
207 line: hit.line,
208 commit: commit.to_owned(),
209 preview: hit.preview(),
210 test_value: hit.test_value().map(str::to_owned),
211 pattern_id: None,
212 pattern_name: None,
213 })
214 .collect();
215 found.extend(protection::scan_change_custom(path, None, bytes, patterns).into_iter().map(|hit| custom_secret(hit, path, commit)));
216 found
217}
218
219/// The secrets each change adds, found `READS_AT_ONCE` files at a time.
220async fn scan_changes<R: GitRepo>(objects: &Objects<'_, R>, commit: &str, changes: Vec<Change>, patterns: &[Compiled]) -> Result<Vec<NewSecret>> {
221 let mut found = Vec::new();
222 let changes: Vec<Change> = changes
223 .into_iter()
224 .filter(|change| !g1t_scan::secrets::skipped_path(&change.path))
225 .collect();
226 for batch in changes.chunks(READS_AT_ONCE) {
227 // A change's old and new contents at once: the new is nearly always
228 // in the pack, the old in the repository.
229 let read = try_join_all(batch.iter().map(|change| async move {
230 let old = async {
231 match &change.old {
232 Some(old) => objects.blob(old).await,
233 None => Ok(None),
234 }
235 };
236 let (new, old) = futures_util::future::try_join(objects.blob(&change.new), old).await?;
237 Ok::<_, worker::Error>((new, old))
238 }))
239 .await?;
240 for (change, (new, old)) in batch.iter().zip(read) {
241 let Some(new) = new else { continue };
242 for hit in protection::scan_change(&change.path, old.as_deref(), &new) {
243 found.push(NewSecret {
244 fingerprint: hit.fingerprint(),
245 kind: hit.kind.id().to_owned(),
246 path: change.path.clone(),
247 line: hit.line,
248 commit: commit.to_owned(),
249 preview: hit.preview(),
250 test_value: hit.test_value().map(str::to_owned),
251 pattern_id: None,
252 pattern_name: None,
253 });
254 }
255 for hit in protection::scan_change_custom(&change.path, old.as_deref(), &new, patterns) {
256 found.push(custom_secret(hit, &change.path, commit));
257 }
258 }
259 }
260 Ok(found)
261}
262
263/// Fetches what a thin pack's deltas are based on from the repository,
264/// all at once. A base the pack's own trees name is read as what they say
265/// it is; any other is asked for as a blob and as a tree together, and
266/// whichever it is answers.
267pub(crate) async fn supply_bases<R: GitRepo>(pack: &mut Pack, repo: &R) -> Result<()> {
268 for _ in 0..3 {
269 let missing = pack.missing_bases();
270 if missing.is_empty() {
271 return Ok(());
272 }
273 let named = pack.named_kinds();
274 let blob = async |id: &str| repo.read_blob(id).await.ok().flatten().map(|bytes| (ObjectKind::Blob, bytes));
275 let tree = async |id: &str| {
276 repo.read_tree(id).await.ok().flatten().map(|entries| {
277 let items: Vec<TreeItem> = entries
278 .into_iter()
279 .map(|entry| TreeItem { mode: mode(entry.kind).to_owned(), name: entry.name, id: entry.hash })
280 .collect();
281 (ObjectKind::Tree, encode_tree(&items))
282 })
283 };
284 let found = futures_util::future::join_all(missing.iter().take(MAX_BASES).map(|id| async {
285 match named.get(id.as_str()) {
286 Some(ObjectKind::Tree) => tree(id).await,
287 Some(_) => blob(id).await,
288 None => {
289 let (as_blob, as_tree) = futures_util::future::join(blob(id), tree(id)).await;
290 as_blob.or(as_tree)
291 }
292 }
293 }))
294 .await;
295 let mut progress = false;
296 for (id, object) in missing.iter().zip(found) {
297 if let Some((kind, data)) = object {
298 pack.supply(id, kind, data);
299 progress = true;
300 }
301 }
302 if !progress {
303 return Ok(());
304 }
305 }
306 Ok(())
307}
308
309/// The secrets the commits in a push add, each secret once. A push too
310/// large to read is an error ([`unscannable`]): it is declined, never let
311/// through unread. A pack that cannot be read for another reason is let
312/// through, and said so in the logs; the store will judge it.
313#[cfg(test)]
314pub async fn scan_push<R: GitRepo>(repo: &R, body: &[u8], patterns: &[Compiled]) -> Result<Vec<NewSecret>> {
315 if body.len() > MAX_SCANNED_PUSH {
316 return Err(worker::Error::RustError(format!("{UNSCANNABLE} {} bytes", body.len())));
317 }
318 let mut pack = crate::push_checks::read_pack(body);
319 if let Ok(pack) = &mut pack {
320 supply_bases(pack, repo).await?;
321 }
322 scan_pack(repo, body.len(), &pack, patterns).await
323}
324
325/// [`scan_push`] for a push of `size` bytes whose pack was read already,
326/// with its bases supplied (push_checks.rs).
327pub(crate) async fn scan_pack<R: GitRepo>(repo: &R, size: usize, pack: &std::result::Result<Pack, String>, patterns: &[Compiled]) -> Result<Vec<NewSecret>> {
328 if size > MAX_SCANNED_PUSH {
329 return Err(worker::Error::RustError(format!("{UNSCANNABLE} {size} bytes")));
330 }
331 let pack = match pack {
332 Ok(pack) => pack,
333 Err(problem) if problem.contains("too large") => {
334 return Err(worker::Error::RustError(format!("{UNSCANNABLE} {problem}")));
335 }
336 Err(problem) => {
337 worker::console_error!("push not scanned for secrets: {problem}");
338 return Ok(Vec::new());
339 }
340 };
341 if pack.unresolved() > 0 {
342 worker::console_error!("{} objects of a push could not be resolved for scanning", pack.unresolved());
343 }
344 let objects = Objects { pack, repo, reads: Cell::new(0) };
345 let objects = &objects;
346 let commits: Vec<(String, g1t_scan::pack::CommitInfo)> = pack
347 .commits()
348 .iter()
349 .take(MAX_PUSH_COMMITS)
350 .filter_map(|id| Some((id.clone(), pack.commit(id)?)))
351 .collect();
352 // The trees of the parents the pack does not hold, all read up front:
353 // usually the one commit the push builds on.
354 let mut outside: Vec<&String> = commits
355 .iter()
356 .filter_map(|(_, commit)| commit.parents.first())
357 .filter(|parent| !pack.contains(parent))
358 .collect();
359 outside.sort();
360 outside.dedup();
361 let outside_trees: std::collections::HashMap<&String, Option<String>> = outside
362 .iter()
363 .copied()
364 .zip(try_join_all(outside.iter().map(|parent| objects.commit_tree(parent))).await?)
365 .collect();
366 let outside_trees = &outside_trees;
367 // What each commit changes, all read at once.
368 let changed = try_join_all(commits.iter().map(|(_, commit)| async move {
369 let old_tree = match commit.parents.first() {
370 Some(parent) => match outside_trees.get(parent) {
371 Some(tree) => tree.clone(),
372 None => objects.commit_tree(parent).await?,
373 },
374 None => None,
375 };
376 changed_files(objects, old_tree, commit.tree.clone()).await
377 }))
378 .await?;
379 let mut found = Vec::new();
380 let mut seen_blobs = HashSet::new();
381 let mut seen_secrets = HashSet::new();
382 for ((id, _), changes) in commits.iter().zip(changed) {
383 // Only content the push brings is new; a blob the repository has
384 // was looked at when it arrived.
385 let changes: Vec<Change> = changes
386 .into_iter()
387 .filter(|change| pack.contains(&change.new) && seen_blobs.insert((change.path.clone(), change.new.clone())))
388 .collect();
389 for secret in scan_changes(objects, id, changes, patterns).await? {
390 if seen_secrets.insert(secret.fingerprint.clone()) {
391 found.push(secret);
392 }
393 }
394 }
395 Ok(found)
396}
397
398/// A commit in a push that would publish one of the pusher's own
399/// addresses while they keep it private: its id and the address. Only the
400/// commits the push adds are read; anyone else's address is no concern
401/// here. A pack that cannot be read is let through.
402#[cfg(test)]
403pub fn exposed_address(body: &[u8], guard: &PushEmailGuard) -> Option<(String, String)> {
404 if body.len() > MAX_SCANNED_PUSH {
405 return None;
406 }
407 exposed_in(&Pack::parse(&body[g1t_scan::pack::pack_start(body)?..]).ok()?, guard)
408}
409
410/// [`exposed_address`] for a pack read already.
411pub(crate) fn exposed_in(pack: &Pack, guard: &PushEmailGuard) -> Option<(String, String)> {
412 pack.commits().iter().find_map(|id| {
413 let commit = pack.commit(id)?;
414 [commit.author_email, commit.committer_email]
415 .into_iter()
416 .flatten()
417 .find(|email| guard.exposes(email))
418 .map(|email| (id.clone(), email))
419 })
420}
421
422/// What git shows a person whose push would publish their private address.
423pub fn exposed_message(commit: &str, email: &str, noreply: &str) -> Vec<String> {
424 let short: String = commit.chars().take(7).collect();
425 vec![
426 format!(
427 "push declined: commit {short} would publish {} while your email is private.",
428 mask_email(&email.to_lowercase())
429 ),
430 format!("Commit with {noreply} (git config user.email {noreply}) and amend,"),
431 format!("or change this in {}/settings/emails.", SITE.trim_start_matches("https://")),
432 ]
433}
434
435impl<S: GitStore> crate::Repos<S> {
436 /// What a push by `pusher` must not publish: their own addresses, when
437 /// they keep them private and block such pushes. An agent's push is
438 /// its person's. `None` when nothing is guarded, or identity cannot say.
439 pub(crate) async fn push_email_guard(&self, pusher: Option<&User>) -> Option<PushEmailGuard> {
440 let pusher = pusher?;
441 let person = pusher.acting.as_ref().map_or(pusher.id.clone(), |acting| acting.on_behalf_of.id.clone());
442 let identity = self.identity.as_ref()?;
443 g1t_kit::call::<_, Option<PushEmailGuard>>(identity, "push_email_guard", &CommitIdentityArgs { user_id: person })
444 .await
445 .unwrap_or_else(|error| {
446 worker::console_error!("push_email_guard failed: {error}");
447 None
448 })
449 }
450
451 /// The custom patterns the security service says `repo` is scanned
452 /// with; none when it cannot say.
453 pub(crate) async fn patterns_for(&self, repo: &Repo) -> Vec<PatternSpec> {
454 let Some(security) = &self.security else { return Vec::new() };
455 g1t_kit::call(
456 security,
457 "patterns_for",
458 &PatternsForArgs { repo_id: repo.id.clone(), namespace: repo.namespace.clone(), private: Some(repo.is_private) },
459 )
460 .await
461 .unwrap_or_else(|error| {
462 worker::console_error!("patterns_for failed: {error}");
463 Vec::new()
464 })
465 }
466
467 /// Of `found` in a change to `owner`, the secrets nobody let through:
468 /// the security service records them all and says which were allowed.
469 pub(crate) async fn blocked(&self, owner: &Repo, pusher: Option<&User>, found: Vec<NewSecret>) -> Vec<Blocked> {
470 let owner_path = RepoPath { namespace: owner.namespace.clone(), name: owner.name.clone() };
471 let verdict = match &self.security {
472 Some(security) => g1t_kit::call::<_, PushVerdict>(
473 security,
474 "push_blocked",
475 &PushBlockedArgs {
476 repo_id: owner.id.clone(),
477 path: owner_path.clone(),
478 pusher: pusher.map(|user| user.username.clone()),
479 secrets: found.clone(),
480 private: Some(owner.is_private),
481 },
482 )
483 .await
484 .unwrap_or_else(|error| {
485 worker::console_error!("push_blocked failed: {error}");
486 PushVerdict::default()
487 }),
488 None => PushVerdict::default(),
489 };
490 found
491 .iter()
492 .filter(|secret| !verdict.allowed.contains(&secret.fingerprint))
493 // A likely test value is recorded, never a reason to refuse.
494 .filter(|secret| secret.test_value.is_none())
495 .filter_map(|secret| {
496 let label = secret_label(secret)?;
497 let id = verdict.ids.iter().find(|(fingerprint, _)| *fingerprint == secret.fingerprint);
498 Some(Blocked {
499 label,
500 path: secret.path.clone(),
501 line: secret.line,
502 commit: secret.commit.clone(),
503 // Where it can be bypassed with a reason, or allowed.
504 allow_url: id.map(|(_, id)| {
505 format!("{SITE}/{}/{}/security/secret-scanning/{id}", owner_path.namespace, owner_path.name)
506 }),
507 })
508 })
509 .collect()
510 }
511
512 /// Push protection for a file committed through g1t (`commit_file`):
513 /// the refusal, naming each secret and where to bypass it, or `None`.
514 pub(crate) async fn protect_file(&self, repo: &Repo, actor: &User, path: &str, content: &[u8], commit: &str) -> Option<String> {
515 let patterns = compiled(&self.patterns_for(repo).await);
516 let found = scan_file(path, content, commit, &patterns);
517 if found.is_empty() {
518 return None;
519 }
520 let blocked = self.blocked(repo, Some(actor), found).await;
521 if blocked.is_empty() {
522 return None;
523 }
524 Some(protection::explain(&blocked).join("\n"))
525 }
526
527 /// A page of the default branch's history, scanned for secrets.
528 pub(crate) async fn scan_history(&self, a: ScanHistoryArgs) -> Result<HistoryPage> {
529 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
530 return Ok(HistoryPage::default());
531 };
532 let git = self.store.open(&store_key(&repo)).await?;
533 let limit = a.limit.clamp(1, 100);
534 // A page of a pushed range starts at its newest commit, and the
535 // history of the default branch at its head.
536 let start = a.after.or(a.from).unwrap_or_else(|| repo.default_branch.clone());
537 let mut commits = git.log(&start, limit + 1).await?;
538 let mut next = (commits.len() > limit as usize).then(|| commits.pop().map(|commit| commit.hash)).flatten();
539 // A range ends where the branch was before the push.
540 if let Some(until) = a.until.as_deref()
541 && let Some(at) = commits.iter().position(|commit| commit.hash == until)
542 {
543 commits.truncate(at);
544 next = None;
545 }
546 if a.until.is_some() && next.as_deref() == a.until.as_deref() {
547 next = None;
548 }
549 let empty = Pack::default();
550 let objects = Objects { pack: &empty, repo: &git, reads: Cell::new(1) };
551 let patterns = compiled(&a.patterns);
552 let mut page = HistoryPage { next, ..HistoryPage::default() };
553 let mut seen = HashSet::new();
554 for (index, commit) in commits.iter().enumerate() {
555 let old_tree = match commit.parents.first() {
556 Some(parent) => match commits.get(index + 1).filter(|older| older.hash == *parent) {
557 Some(older) => Some(older.tree_hash.clone()),
558 None => objects.commit_tree(parent).await?,
559 },
560 None => None,
561 };
562 let changes = changed_files(&objects, old_tree, commit.tree_hash.clone()).await?;
563 for secret in scan_changes(&objects, &commit.hash, changes, &patterns).await? {
564 if seen.insert(secret.fingerprint.clone()) {
565 page.secrets.push(secret);
566 }
567 }
568 page.commits += 1;
569 }
570 page.reads = objects.reads.get();
571 Ok(page)
572 }
573
574 /// The lockfiles on the default branch, outside vendored directories.
575 pub(crate) async fn find_lockfiles(&self, a: FindLockfilesArgs) -> Result<Lockfiles> {
576 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
577 return Ok(Lockfiles::default());
578 };
579 let git = self.store.open(&store_key(&repo)).await?;
580 let at = a.git_ref.as_deref().unwrap_or(&repo.default_branch);
581 let Some(head) = git.log(at, 1).await?.into_iter().next() else {
582 return Ok(Lockfiles::default());
583 };
584 let mut found = Vec::new();
585 let mut queue = VecDeque::from([(String::new(), head.tree_hash.clone(), 0usize)]);
586 while let Some((prefix, tree, depth)) = queue.pop_front() {
587 for entry in git.read_tree(&tree).await?.unwrap_or_default() {
588 match entry.kind {
589 EntryKind::Tree if depth < MAX_LOCKFILE_DEPTH && !SKIPPED_DIRECTORIES.contains(&entry.name.as_str()) => {
590 queue.push_back((format!("{prefix}{}/", entry.name), entry.hash, depth + 1));
591 }
592 EntryKind::Blob if Lockfile::for_path(&entry.name).is_some() && found.len() < MAX_LOCKFILES => {
593 found.push((format!("{prefix}{}", entry.name), entry.hash));
594 }
595 _ => {}
596 }
597 }
598 }
599 let texts = try_join_all(found.iter().map(|(_, hash)| git.read_blob(hash))).await?;
600 let files = found
601 .into_iter()
602 .zip(texts)
603 .filter_map(|((path, _), bytes)| {
604 let bytes = bytes.filter(|bytes| bytes.len() <= MAX_LOCKFILE_BYTES)?;
605 Some(LockfileText { path, text: String::from_utf8(bytes).ok()? })
606 })
607 .collect();
608 Ok(Lockfiles { commit: Some(head.hash), files })
609 }
610
611 /// A dry run of a custom pattern over the default branch's files, up to
612 /// [`MATCH_FILES`] files and [`MATCH_BYTES`] of text, skipping what
613 /// secret scanning skips. Nothing is recorded.
614 pub(crate) async fn match_pattern(&self, a: MatchPatternArgs) -> Result<PatternMatches> {
615 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
616 return Ok(PatternMatches::default());
617 };
618 let patterns = compiled(std::slice::from_ref(&a.pattern));
619 let Some(pattern) = patterns.first() else {
620 return Ok(PatternMatches::default());
621 };
622 let git = self.store.open(&store_key(&repo)).await?;
623 let Some(head) = git.log(&repo.default_branch, 1).await?.into_iter().next() else {
624 return Ok(PatternMatches::default());
625 };
626 let mut result = PatternMatches { commit: Some(head.hash.clone()), ..PatternMatches::default() };
627 let mut files = Vec::new();
628 let mut queue = VecDeque::from([(String::new(), head.tree_hash.clone())]);
629 while let Some((prefix, tree)) = queue.pop_front() {
630 for entry in git.read_tree(&tree).await?.unwrap_or_default() {
631 let path = format!("{prefix}{}", entry.name);
632 match entry.kind {
633 EntryKind::Tree if !SKIPPED_DIRECTORIES.contains(&entry.name.as_str()) => queue.push_back((format!("{path}/"), entry.hash)),
634 EntryKind::Blob | EntryKind::Exec if !g1t_scan::secrets::skipped_path(&path) => {
635 if files.len() == MATCH_FILES {
636 result.truncated = true;
637 } else {
638 files.push((path, entry.hash));
639 }
640 }
641 _ => {}
642 }
643 }
644 }
645 let mut bytes = 0usize;
646 let limit = a.limit.clamp(1, 200) as usize;
647 for batch in files.chunks(READS_AT_ONCE) {
648 if bytes > MATCH_BYTES || result.matches.len() >= limit {
649 result.truncated = true;
650 break;
651 }
652 let read = try_join_all(batch.iter().map(|(_, hash)| git.read_blob(hash))).await?;
653 for ((path, _), blob) in batch.iter().zip(read) {
654 let Some(blob) = blob else { continue };
655 bytes += blob.len();
656 result.files_scanned += 1;
657 let Some(text) = protection::text_of(path, &blob) else { continue };
658 let lines: Vec<&str> = text.lines().collect();
659 for hit in custom::scan_lines(text, std::slice::from_ref(pattern), |_| true) {
660 if result.matches.len() >= limit {
661 result.truncated = true;
662 break;
663 }
664 let line = lines.get(hit.line as usize - 1).copied().unwrap_or_default();
665 result.matches.push(PatternMatch { path: path.clone(), line: hit.line, preview: custom::masked_line(line, &hit.value) });
666 }
667 }
668 }
669 Ok(result)
670 }
671
672 /// Asks a landed secret's issuer whether it still works: finds it again
673 /// by its fingerprint at `commit`:`path` near `line`, and makes the
674 /// issuer's own read-only check over HTTPS. The value goes nowhere else.
675 pub(crate) async fn check_secret(&self, a: CheckSecretArgs) -> Result<SecretValidity> {
676 let unknown = |detail: &str| SecretValidity { validity: "unknown".to_owned(), detail: Some(detail.to_owned()) };
677 let Some(kind) = g1t_scan::secrets::SecretKind::parse(&a.kind) else {
678 return Ok(SecretValidity { validity: "unsupported".to_owned(), detail: None });
679 };
680 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
681 return Ok(unknown("no such repository"));
682 };
683 let git = self.store.open(&store_key(&repo)).await?;
684 let Some(bytes) = git.read_file(&a.commit, &a.path).await? else {
685 return Ok(unknown("the file is not at that commit"));
686 };
687 let Some(text) = protection::text_of(&a.path, &bytes) else {
688 return Ok(unknown("the file cannot be read as text"));
689 };
690 let near = |line: u32| line + 2 >= a.line && line <= a.line + 2;
691 let Some(hit) = g1t_scan::secrets::scan_lines(text, near).into_iter().find(|hit| hit.fingerprint() == a.fingerprint) else {
692 return Ok(unknown("the secret is no longer where it was found"));
693 };
694 let Some(probe) = g1t_scan::validity::check_for(kind, &hit.value) else {
695 return Ok(SecretValidity { validity: "unsupported".to_owned(), detail: None });
696 };
697 let headers = worker::Headers::new();
698 headers.set("user-agent", "g1t secret validity check (+https://docs.g1t.sh/guides/security/secret-protection/)")?;
699 for (name, value) in &probe.headers {
700 headers.set(name, value)?;
701 }
702 let mut init = worker::RequestInit::new();
703 init.with_method(if probe.method == "POST" { worker::Method::Post } else { worker::Method::Get }).with_headers(headers);
704 if let Some(body) = &probe.body {
705 init.with_body(Some(body.clone().into()));
706 }
707 let answer = async {
708 let mut response = worker::Fetch::Request(worker::Request::new_with_init(probe.url, &init)?).send().await?;
709 let status = response.status_code();
710 let body = if probe.reader == g1t_scan::validity::Reader::SlackOk { response.text().await.unwrap_or_default() } else { String::new() };
711 Ok::<_, worker::Error>((status, body))
712 }
713 .await;
714 Ok(match answer {
715 Ok((status, body)) => {
716 let validity = g1t_scan::validity::read(probe.reader, kind, status, &body);
717 SecretValidity {
718 validity: validity.as_str().to_owned(),
719 detail: (validity == g1t_scan::validity::Validity::Unknown).then(|| format!("the issuer answered {status}")),
720 }
721 }
722 Err(error) => unknown(&format!("the issuer could not be reached: {error}")),
723 })
724 }
725}
726
727/// Files a dry run reads, at most, and text in all.
728const MATCH_FILES: usize = 2_000;
729const MATCH_BYTES: usize = 20 * 1024 * 1024;
730
731#[cfg(test)]
732mod tests {
733 use std::cell::Cell;
734 use std::collections::HashMap;
735 use std::future::Future;
736 use std::pin::pin;
737 use std::task::{Context, Poll, Waker};
738
739 use g1t_contracts::repos::{Branch, Commit, GitAccess, Signature, TreeEntry};
740 use g1t_scan::pack::{ObjectKind, TreeItem, encode_tree, object_id};
741
742 use super::*;
743 use crate::store::Scope;
744
745 /// Runs a future that never waits, as every call to the fake store is.
746 fn run<F: Future>(future: F) -> F::Output {
747 match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
748 Poll::Ready(output) => output,
749 Poll::Pending => panic!("the fake store never waits"),
750 }
751 }
752
753 /// A repository held in memory.
754 #[derive(Default)]
755 struct FakeRepo {
756 blobs: HashMap<String, Vec<u8>>,
757 trees: HashMap<String, Vec<TreeEntry>>,
758 commits: HashMap<String, Commit>,
759 /// How often each kind of read was asked for.
760 blob_reads: Cell<u32>,
761 tree_reads: Cell<u32>,
762 log_reads: Cell<u32>,
763 }
764
765 impl GitRepo for FakeRepo {
766 async fn access(&self, _scope: Scope) -> Result<GitAccess> {
767 unimplemented!()
768 }
769 async fn branches(&self) -> Result<Vec<Branch>> {
770 Ok(Vec::new())
771 }
772 async fn log(&self, git_ref: &str, _limit: u32) -> Result<Vec<Commit>> {
773 self.log_reads.set(self.log_reads.get() + 1);
774 Ok(self.commits.get(git_ref).cloned().into_iter().collect())
775 }
776 async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> {
777 Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone()))
778 }
779 async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
780 self.tree_reads.set(self.tree_reads.get() + 1);
781 Ok(self.trees.get(tree_hash).cloned())
782 }
783 async fn read_blob(&self, blob_hash: &str) -> Result<Option<Vec<u8>>> {
784 self.blob_reads.set(self.blob_reads.get() + 1);
785 Ok(self.blobs.get(blob_hash).cloned())
786 }
787 async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
788 Ok(None)
789 }
790 async fn fork(&self, _target_key: &str) -> Result<()> {
791 Ok(())
792 }
793 }
794
795 /// Zlib with one stored (uncompressed) block, which is all a pack needs.
796 fn zlib(data: &[u8]) -> Vec<u8> {
797 let mut out = vec![0x78, 0x01, 0x01];
798 let length = data.len() as u16;
799 out.extend_from_slice(&length.to_le_bytes());
800 out.extend_from_slice(&(!length).to_le_bytes());
801 out.extend_from_slice(data);
802 let (mut a, mut b) = (1u32, 0u32);
803 for byte in data {
804 a = (a + u32::from(*byte)) % 65521;
805 b = (b + a) % 65521;
806 }
807 out.extend_from_slice(&((b << 16) | a).to_be_bytes());
808 out
809 }
810
811 fn header(code: u8, size: usize) -> Vec<u8> {
812 let mut out = Vec::new();
813 let mut byte = (code << 4) | (size & 15) as u8;
814 let mut rest = size >> 4;
815 while rest > 0 {
816 out.push(byte | 0x80);
817 byte = (rest & 0x7f) as u8;
818 rest >>= 7;
819 }
820 out.push(byte);
821 out
822 }
823
824 fn raw_id(id: &str) -> Vec<u8> {
825 id.as_bytes()
826 .chunks(2)
827 .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
828 .collect()
829 }
830
831 enum Entry {
832 Whole(ObjectKind, Vec<u8>),
833 /// A ref-delta: base id and delta.
834 Delta(String, Vec<u8>),
835 }
836
837 /// A receive-pack request: one command, then the pack.
838 fn push(entries: &[Entry]) -> Vec<u8> {
839 let command = b"0000000000000000000000000000000000000000 4807077b296e6edbf410d55e72749d3e1170c291 refs/heads/main\0report-status side-band-64k\n";
840 let mut body = format!("{:04x}", command.len() + 4).into_bytes();
841 body.extend_from_slice(command);
842 body.extend_from_slice(b"0000PACK");
843 body.extend_from_slice(&2u32.to_be_bytes());
844 body.extend_from_slice(&(entries.len() as u32).to_be_bytes());
845 for entry in entries {
846 match entry {
847 Entry::Whole(kind, data) => {
848 let code = match kind {
849 ObjectKind::Commit => 1,
850 ObjectKind::Tree => 2,
851 ObjectKind::Blob => 3,
852 ObjectKind::Tag => 4,
853 };
854 body.extend(header(code, data.len()));
855 body.extend(zlib(data));
856 }
857 Entry::Delta(base, delta) => {
858 body.extend(header(7, delta.len()));
859 body.extend(raw_id(base));
860 body.extend(zlib(delta));
861 }
862 }
863 }
864 body.extend_from_slice(&[0u8; 20]);
865 body
866 }
867
868 fn key() -> String {
869 format!("AK{}", "IAZ7Q4N2XWLM3KDTRV")
870 }
871
872 fn commit(tree: &str, parent: Option<&str>) -> Vec<u8> {
873 let parent = parent.map(|parent| format!("parent {parent}\n")).unwrap_or_default();
874 format!("tree {tree}\n{parent}author A <a@example.com> 0 +0000\ncommitter A <a@example.com> 0 +0000\n\nchange\n").into_bytes()
875 }
876
877 #[test]
878 fn a_first_push_with_a_secret_is_found_by_file_and_line() {
879 let blob = format!("REGION=eu\nAWS_KEY={}\n", key()).into_bytes();
880 let blob_id = object_id(ObjectKind::Blob, &blob);
881 let tree = encode_tree(&[TreeItem { mode: "100644".into(), name: "config.env".into(), id: blob_id }]);
882 let tree_id = object_id(ObjectKind::Tree, &tree);
883 let body = push(&[
884 Entry::Whole(ObjectKind::Commit, commit(&tree_id, None)),
885 Entry::Whole(ObjectKind::Tree, tree),
886 Entry::Whole(ObjectKind::Blob, blob),
887 ]);
888 let found = run(scan_push(&FakeRepo::default(), &body, &[])).unwrap();
889 assert_eq!(found.len(), 1);
890 assert_eq!((found[0].path.as_str(), found[0].line, found[0].kind.as_str()), ("config.env", 2, "aws_access_key"));
891 assert!(found[0].preview.starts_with("AKIA") && !found[0].preview.contains(&key()));
892 }
893
894 #[test]
895 fn a_thin_push_reports_only_the_lines_it_adds() {
896 // The repository already has a file with a key in it (decided on
897 // before); the push appends a line holding a second key.
898 let old = format!("first={}\n", key()).into_bytes();
899 let old_id = object_id(ObjectKind::Blob, &old);
900 let second = format!("AK{}", "IAQ9W8E7R6T5Y4U3I2");
901 let new = [old.clone(), format!("second={second}\n").into_bytes()].concat();
902 let base_tree = vec![TreeEntry { name: "app.env".into(), hash: old_id.clone(), kind: EntryKind::Blob }];
903 let base_tree_id = object_id(ObjectKind::Tree, &encode_tree(&[TreeItem { mode: "100644".into(), name: "app.env".into(), id: old_id.clone() }]));
904 let parent_id = "c71546fcd893ef8b0f57388b65e620d759705dda".to_owned();
905 let mut repo = FakeRepo::default();
906 repo.blobs.insert(old_id.clone(), old.clone());
907 repo.trees.insert(base_tree_id.clone(), base_tree);
908 repo.commits.insert(
909 parent_id.clone(),
910 Commit {
911 hash: parent_id.clone(),
912 tree_hash: base_tree_id,
913 message: String::new(),
914 author: Signature { name: "A".into(), email: "a@example.com".into() },
915 parents: Vec::new(),
916 authored_at: String::new(),
917 },
918 );
919 // A delta: copy the old file whole, then insert the new line.
920 let added = format!("second={second}\n").into_bytes();
921 let mut delta = vec![old.len() as u8, new.len() as u8, 0x80 | 0x10, old.len() as u8, added.len() as u8];
922 delta.extend_from_slice(&added);
923 let new_id = object_id(ObjectKind::Blob, &new);
924 let tree = encode_tree(&[TreeItem { mode: "100644".into(), name: "app.env".into(), id: new_id }]);
925 let tree_id = object_id(ObjectKind::Tree, &tree);
926 let body = push(&[
927 Entry::Whole(ObjectKind::Commit, commit(&tree_id, Some(&parent_id))),
928 Entry::Whole(ObjectKind::Tree, tree),
929 Entry::Delta(old_id, delta),
930 ]);
931 let found = run(scan_push(&repo, &body, &[])).unwrap();
932 assert_eq!(found.len(), 1, "{found:?}");
933 assert_eq!((found[0].path.as_str(), found[0].line), ("app.env", 2));
934 }
935
936 #[test]
937 fn a_base_is_asked_for_as_what_the_pack_names_it_or_both_ways_at_once() {
938 // A file's old version, which no tree in the pack names: asked for
939 // as a blob and as a tree together, and found as a blob.
940 let old = b"one
941".to_vec();
942 let old_id = object_id(ObjectKind::Blob, &old);
943 let mut repo = FakeRepo::default();
944 repo.blobs.insert(old_id.clone(), old.clone());
945 let mut delta = vec![old.len() as u8, (old.len() + 4) as u8, 0x80 | 0x10, old.len() as u8, 4];
946 delta.extend_from_slice(b"two
947");
948 let body = push(&[Entry::Delta(old_id.clone(), delta)]);
949 let mut pack = crate::push_checks::read_pack(&body).unwrap();
950 run(supply_bases(&mut pack, &repo)).unwrap();
951 assert_eq!(pack.unresolved(), 0);
952 assert_eq!((repo.blob_reads.get(), repo.tree_reads.get()), (1, 1));
953
954 // A directory the pack's root tree names as a tree: read as one.
955 let file = object_id(ObjectKind::Blob, b"x");
956 let listed = [TreeItem { mode: "100644".into(), name: "a".into(), id: file.clone() }];
957 let sub = encode_tree(&listed);
958 let sub_id = object_id(ObjectKind::Tree, &sub);
959 let mut repo = FakeRepo::default();
960 repo.trees.insert(sub_id.clone(), vec![TreeEntry { name: "a".into(), hash: file, kind: EntryKind::Blob }]);
961 let root = encode_tree(&[TreeItem { mode: "40000".into(), name: "src".into(), id: sub_id.clone() }]);
962 let delta = vec![sub.len() as u8, sub.len() as u8, 0x80 | 0x10, sub.len() as u8];
963 let body = push(&[Entry::Whole(ObjectKind::Tree, root), Entry::Delta(sub_id, delta)]);
964 let mut pack = crate::push_checks::read_pack(&body).unwrap();
965 run(supply_bases(&mut pack, &repo)).unwrap();
966 assert_eq!(pack.unresolved(), 0);
967 assert_eq!((repo.blob_reads.get(), repo.tree_reads.get()), (0, 1));
968 }
969
970 #[test]
971 fn the_commit_a_push_builds_on_is_read_once_however_many_commits_build_on_it() {
972 // Two branches pushed at once, each one commit on the same parent.
973 let parent_id = "c71546fcd893ef8b0f57388b65e620d759705dda".to_owned();
974 let base_tree_id = object_id(ObjectKind::Tree, &[]);
975 let mut repo = FakeRepo::default();
976 repo.trees.insert(base_tree_id.clone(), Vec::new());
977 repo.commits.insert(
978 parent_id.clone(),
979 Commit {
980 hash: parent_id.clone(),
981 tree_hash: base_tree_id,
982 message: String::new(),
983 author: Signature { name: "A".into(), email: "a@example.com".into() },
984 parents: Vec::new(),
985 authored_at: String::new(),
986 },
987 );
988 let mut entries = Vec::new();
989 for (name, line) in [("a.env", format!("KEY={}
990", key())), ("b.txt", "nothing here
991".to_owned())] {
992 let blob = line.into_bytes();
993 let tree = encode_tree(&[TreeItem { mode: "100644".into(), name: name.into(), id: object_id(ObjectKind::Blob, &blob) }]);
994 entries.push(Entry::Whole(ObjectKind::Commit, commit(&object_id(ObjectKind::Tree, &tree), Some(&parent_id))));
995 entries.push(Entry::Whole(ObjectKind::Tree, tree));
996 entries.push(Entry::Whole(ObjectKind::Blob, blob));
997 }
998 let found = run(scan_push(&repo, &push(&entries), &[])).unwrap();
999 assert_eq!(found.len(), 1);
1000 assert_eq!(found[0].path, "a.env");
1001 assert_eq!(repo.log_reads.get(), 1);
1002 }
1003
1004 #[test]
1005 fn a_push_too_large_to_read_is_never_let_through_unread() {
1006 let body = vec![0u8; MAX_SCANNED_PUSH + 1];
1007 let error = run(scan_push(&FakeRepo::default(), &body, &[])).unwrap_err();
1008 assert!(unscannable(&error));
1009 let (reason, messages) = crate::git_http::size_refusal(&crate::git_http::SizeViolation::Unscannable {
1010 size: body.len() as u64,
1011 cap: MAX_SCANNED_PUSH,
1012 });
1013 assert_eq!(reason, "the push is too large to check for secrets");
1014 assert!(messages.iter().any(|line| line.contains("100.0 MB")));
1015 assert!(messages.iter().any(|line| line.contains("Push in parts")));
1016 }
1017
1018 #[test]
1019 fn a_push_without_secrets_or_a_pack_finds_nothing() {
1020 let blob = b"fn main() {}\n".to_vec();
1021 let blob_id = object_id(ObjectKind::Blob, &blob);
1022 let tree = encode_tree(&[TreeItem { mode: "100644".into(), name: "main.rs".into(), id: blob_id }]);
1023 let tree_id = object_id(ObjectKind::Tree, &tree);
1024 let body = push(&[
1025 Entry::Whole(ObjectKind::Commit, commit(&tree_id, None)),
1026 Entry::Whole(ObjectKind::Tree, tree),
1027 Entry::Whole(ObjectKind::Blob, blob),
1028 ]);
1029 assert!(run(scan_push(&FakeRepo::default(), &body, &[])).unwrap().is_empty());
1030 // A deletion sends commands and no pack.
1031 assert!(run(scan_push(&FakeRepo::default(), b"0000", &[])).unwrap().is_empty());
1032 }
1033
1034 #[test]
1035 fn custom_patterns_are_found_in_a_push_and_a_committed_file() {
1036 let patterns = compiled(&[PatternSpec {
1037 id: "pat_1".into(),
1038 name: "Acme key".into(),
1039 pattern: "acme_[0-9a-f]{16}".into(),
1040 before: None,
1041 after: None,
1042 }]);
1043 let blob = b"token: acme_0123456789abcdef\n".to_vec();
1044 let blob_id = object_id(ObjectKind::Blob, &blob);
1045 let tree = encode_tree(&[TreeItem { mode: "100644".into(), name: "deploy.yml".into(), id: blob_id }]);
1046 let tree_id = object_id(ObjectKind::Tree, &tree);
1047 let body = push(&[
1048 Entry::Whole(ObjectKind::Commit, commit(&tree_id, None)),
1049 Entry::Whole(ObjectKind::Tree, tree),
1050 Entry::Whole(ObjectKind::Blob, blob.clone()),
1051 ]);
1052 let found = run(scan_push(&FakeRepo::default(), &body, &patterns)).unwrap();
1053 assert_eq!(found.len(), 1);
1054 assert_eq!((found[0].kind.as_str(), found[0].pattern_id.as_deref(), found[0].line), ("custom_pattern", Some("pat_1"), 1));
1055 assert_eq!(secret_label(&found[0]).unwrap(), "a match for the custom pattern \"Acme key\"");
1056 assert!(!found[0].preview.contains("0123456789abcdef"));
1057 // Without the pattern, nothing.
1058 assert!(run(scan_push(&FakeRepo::default(), &body, &[])).unwrap().is_empty());
1059 // A file committed through g1t is scanned for both.
1060 let file = format!("{blob}AWS={}\n", key(), blob = String::from_utf8(blob).unwrap());
1061 let found = scan_file(".g1t/workflows/deploy.yml", file.as_bytes(), "c0ffee", &patterns);
1062 let kinds: Vec<&str> = found.iter().map(|secret| secret.kind.as_str()).collect();
1063 assert_eq!(kinds, ["aws_access_key", "custom_pattern"]);
1064 }
1065
1066 #[test]
1067 fn a_push_carrying_the_pushers_private_address_is_declined_with_a_masked_address() {
1068 let tree = encode_tree(&[]);
1069 let tree_id = object_id(ObjectKind::Tree, &tree);
1070 let mine = format!("tree {tree_id}
1071author S <Sam@Gmail.com> 0 +0000
1072committer S <sam@gmail.com> 0 +0000
1073
1074x
1075").into_bytes();
1076 let mine_id = object_id(ObjectKind::Commit, &mine);
1077 let guard = PushEmailGuard { emails: vec!["sam@gmail.com".into()], noreply: "1abc2def+sam@users.noreply.g1t.sh".into() };
1078 let body = push(&[Entry::Whole(ObjectKind::Commit, mine), Entry::Whole(ObjectKind::Tree, tree.clone())]);
1079 let (found, email) = exposed_address(&body, &guard).unwrap();
1080 assert_eq!(found, mine_id);
1081 let message = exposed_message(&found, &email, &guard.noreply);
1082 assert!(message[0].starts_with(&format!("push declined: commit {} would publish s***@gmail.com", &mine_id[..7])));
1083 assert!(message[1].contains("git config user.email 1abc2def+sam@users.noreply.g1t.sh"));
1084 assert!(message[2].contains("g1t.sh/settings/emails"));
1085 // Someone else's commits, and no pack at all, go through.
1086 let theirs = push(&[Entry::Whole(ObjectKind::Commit, commit(&tree_id, None)), Entry::Whole(ObjectKind::Tree, tree)]);
1087 assert_eq!(exposed_address(&theirs, &guard), None);
1088 assert_eq!(exposed_address(b"0000", &guard), None);
1089 }
1090}