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