Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! Custom patterns: secret formats a repository or a workspace defines, |
| 2 | //! found by push protection and history scans alongside the built-in ones. | |
| 3 | //! A repository's take Admin to change; a workspace's, an owner. On a | |
| 4 | //! private repository they need the Security and quality activation; a | |
| 5 | //! workspace's patterns without it cover its public repositories only. | |
| 6 | ||
| 7 | use g1t_contracts::access::Capability; | |
| 8 | use g1t_contracts::security_suite::{ | |
| 9 | CustomPatternsArgs, DeleteCustomPatternArgs, DryRun, DryRunPatternArgs, DryRunRepo, MatchPatternArgs, PaidFeature, PatternList, | |
| 10 | PatternMatches, PatternSpec, PatternsForArgs, SaveCustomPatternArgs, SavedPattern, | |
| 11 | }; | |
| 12 | use g1t_contracts::{FailureCode, Outcome, Role, User}; | |
| 13 | use g1t_scan::custom; | |
| 14 | use worker::Result; | |
| 15 | ||
| 16 | use crate::Security; | |
| 17 | use crate::store::RepoRow; | |
| 18 | use crate::suite::payment_required; | |
| 19 | ||
| 20 | /// Repositories a workspace's dry run reads, at most. | |
| 21 | const DRY_RUN_REPOS: usize = 10; | |
| 22 | /// Matches a dry run shows per repository. | |
| 23 | const DRY_RUN_MATCHES: u32 = 50; | |
| 24 | ||
| 25 | fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> { | |
| 26 | Outcome::fail(code, message) | |
| 27 | } | |
| 28 | ||
| 29 | /// The scanner's form of a pattern. | |
| 30 | pub fn scan_spec(spec: &PatternSpec) -> custom::PatternSpec { | |
| 31 | custom::PatternSpec { | |
| 32 | id: spec.id.clone(), | |
| 33 | name: spec.name.clone(), | |
| 34 | pattern: spec.pattern.clone(), | |
| 35 | before: spec.before.clone(), | |
| 36 | after: spec.after.clone(), | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | fn trimmed(text: Option<&str>) -> Option<String> { | |
| 41 | text.map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned) | |
| 42 | } | |
| 43 | ||
| 44 | /// Where a pattern call acts: one repository (Admin), or a workspace (an | |
| 45 | /// owner to change, any member to read). | |
| 46 | enum Scope { | |
| 47 | Repo(RepoRow), | |
| 48 | Workspace(String), | |
| 49 | } | |
| 50 | ||
| 51 | impl Security { | |
| 52 | async fn pattern_scope( | |
| 53 | &self, | |
| 54 | workspace: &str, | |
| 55 | repo: Option<&g1t_contracts::repos::RepoPath>, | |
| 56 | actor: &Option<User>, | |
| 57 | change: bool, | |
| 58 | ) -> Result<Outcome<Scope>> { | |
| 59 | let workspace = workspace.to_lowercase(); | |
| 60 | match repo { | |
| 61 | Some(path) => { | |
| 62 | let capability = if change { Capability::ManageIntegrations } else { crate::SEE_FINDINGS }; | |
| 63 | Ok(match self.member_repo(path, actor, capability).await? { | |
| 64 | Outcome::Ok(repo) => Outcome::Ok(Scope::Repo(repo)), | |
| 65 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 66 | }) | |
| 67 | } | |
| 68 | None => { | |
| 69 | let role = actor.as_ref().and_then(|user| user.role_in(&workspace)); | |
| 70 | Ok(match (role, change) { | |
| 71 | (None, _) => fail(FailureCode::NotFound, "Workspace not found."), | |
| 72 | (Some(Role::Owner), _) | (Some(_), false) => Outcome::Ok(Scope::Workspace(workspace)), | |
| 73 | (Some(_), true) => fail(FailureCode::Forbidden, "Only an owner can change the workspace's custom patterns."), | |
| 74 | }) | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | pub(crate) async fn custom_patterns(&self, a: CustomPatternsArgs) -> Result<Outcome<PatternList>> { | |
| 80 | let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &a.viewer, false).await? { | |
| 81 | Outcome::Ok(scope) => scope, | |
| 82 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 83 | }; | |
| 84 | let (rows, entitled) = match &scope { | |
| 85 | Scope::Repo(repo) => (self.store.patterns(&repo.namespace, Some(&repo.repo_id)).await?, self.entitled(repo).await?), | |
| 86 | Scope::Workspace(namespace) => (self.store.patterns(namespace, None).await?, self.activated(namespace).await), | |
| 87 | }; | |
| 88 | Ok(Outcome::Ok(PatternList { patterns: rows.iter().map(|row| row.contract()).collect(), entitled })) | |
| 89 | } | |
| 90 | ||
| 91 | pub(crate) async fn save_custom_pattern(&self, a: SaveCustomPatternArgs) -> Result<Outcome<SavedPattern>> { | |
| 92 | let actor = Some(a.actor.clone()); | |
| 93 | let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &actor, true).await? { | |
| 94 | Outcome::Ok(scope) => scope, | |
| 95 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 96 | }; | |
| 97 | if !a.actor.verified { | |
| 98 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); | |
| 99 | } | |
| 100 | let (namespace, repo_id) = match &scope { | |
| 101 | Scope::Repo(repo) => { | |
| 102 | if let Some(refusal) = self.gate(repo, PaidFeature::CustomPatterns).await? { | |
| 103 | return Ok(refusal); | |
| 104 | } | |
| 105 | (repo.namespace.clone(), Some(repo.repo_id.clone())) | |
| 106 | } | |
| 107 | Scope::Workspace(namespace) => (namespace.clone(), None), | |
| 108 | }; | |
| 109 | // A pattern changed must be one of this scope's. | |
| 110 | if let Some(id) = &a.id { | |
| 111 | match self.store.pattern(id).await? { | |
| 112 | Some(row) if row.namespace == namespace && row.repo_id == repo_id => {} | |
| 113 | _ => return Ok(fail(FailureCode::NotFound, "No such pattern.")), | |
| 114 | } | |
| 115 | } else if self.store.patterns(&namespace, repo_id.as_deref()).await?.len() >= custom::MAX_PATTERNS { | |
| 116 | return Ok(fail(FailureCode::Invalid, format!("A repository is scanned with at most {} custom patterns.", custom::MAX_PATTERNS))); | |
| 117 | } | |
| 118 | let spec = PatternSpec { | |
| 119 | id: a.id.clone().unwrap_or_default(), | |
| 120 | name: a.name.trim().chars().take(100).collect(), | |
| 121 | pattern: a.pattern.clone(), | |
| 122 | before: trimmed(a.before.as_deref()), | |
| 123 | after: trimmed(a.after.as_deref()), | |
| 124 | }; | |
| 125 | let compiled = match custom::compile(&scan_spec(&spec)) { | |
| 126 | Ok(compiled) => compiled, | |
| 127 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 128 | }; | |
| 129 | let tests = match custom::clean_test_strings(&a.test_strings) { | |
| 130 | Ok(tests) => tests, | |
| 131 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 132 | }; | |
| 133 | let state = if a.publish { "published" } else { "draft" }; | |
| 134 | let was_published = match &a.id { | |
| 135 | Some(id) => self.store.pattern(id).await?.is_some_and(|row| row.state == "published"), | |
| 136 | None => false, | |
| 137 | }; | |
| 138 | let id = self | |
| 139 | .store | |
| 140 | .save_pattern(a.id.as_deref(), &namespace, repo_id.as_deref(), &spec, &tests, state, &a.actor.username) | |
| 141 | .await?; | |
| 142 | // Published, or changed while published: the history is read again | |
| 143 | // for it, a page at a time, as the first scan was. | |
| 144 | if a.publish { | |
| 145 | self.store.rescan_for_pattern(&namespace, repo_id.as_deref()).await?; | |
| 146 | } | |
| 147 | let verb = match (a.id.is_some(), a.publish, was_published) { | |
| 148 | (false, true, _) => "published", | |
| 149 | (false, false, _) => "saved as a draft", | |
| 150 | (true, true, false) => "published", | |
| 151 | (true, false, true) => "unpublished", | |
| 152 | (true, _, _) => "changed", | |
| 153 | }; | |
| 154 | let repo_row = match &scope { | |
| 155 | Scope::Repo(repo) => Some(repo), | |
| 156 | Scope::Workspace(_) => None, | |
| 157 | }; | |
| 158 | self.audit( | |
| 159 | &a.actor, | |
| 160 | "custom_pattern", | |
| 161 | repo_row, | |
| 162 | &namespace, | |
| 163 | None, | |
| 164 | &format!("Custom pattern \"{}\" {verb}: {}", spec.name, spec.pattern), | |
| 165 | ) | |
| 166 | .await; | |
| 167 | let Some(row) = self.store.pattern(&id).await? else { | |
| 168 | return Ok(fail(FailureCode::NotFound, "The pattern was not saved.")); | |
| 169 | }; | |
| 170 | let tests = custom::test(&compiled, &tests) | |
| 171 | .into_iter() | |
| 172 | .map(|found| found.map(|(start, end)| (start as u32, end as u32))) | |
| 173 | .collect(); | |
| 174 | Ok(Outcome::Ok(SavedPattern { pattern: row.contract(), tests })) | |
| 175 | } | |
| 176 | ||
| 177 | pub(crate) async fn delete_custom_pattern(&self, a: DeleteCustomPatternArgs) -> Result<Outcome<bool>> { | |
| 178 | let actor = Some(a.actor.clone()); | |
| 179 | let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &actor, true).await? { | |
| 180 | Outcome::Ok(scope) => scope, | |
| 181 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 182 | }; | |
| 183 | let (namespace, repo) = match &scope { | |
| 184 | Scope::Repo(repo) => (repo.namespace.clone(), Some(repo)), | |
| 185 | Scope::Workspace(namespace) => (namespace.clone(), None), | |
| 186 | }; | |
| 187 | let Some(row) = self.store.pattern(&a.id).await? else { | |
| 188 | return Ok(fail(FailureCode::NotFound, "No such pattern.")); | |
| 189 | }; | |
| 190 | if row.namespace != namespace || row.repo_id.as_deref() != repo.map(|repo| repo.repo_id.as_str()) { | |
| 191 | return Ok(fail(FailureCode::NotFound, "No such pattern.")); | |
| 192 | } | |
| 193 | self.store.delete_pattern(&a.id).await?; | |
| 194 | self.audit(&a.actor, "custom_pattern", repo, &namespace, None, &format!("Custom pattern \"{}\" deleted", row.name)).await; | |
| 195 | Ok(Outcome::Ok(true)) | |
| 196 | } | |
| 197 | ||
| 198 | pub(crate) async fn dry_run_pattern(&self, a: DryRunPatternArgs) -> Result<Outcome<DryRun>> { | |
| 199 | let actor = Some(a.actor.clone()); | |
| 200 | let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &actor, true).await? { | |
| 201 | Outcome::Ok(scope) => scope, | |
| 202 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 203 | }; | |
| 204 | let spec = PatternSpec { | |
| 205 | id: "pat_dry_run".to_owned(), | |
| 206 | name: "Dry run".to_owned(), | |
| 207 | pattern: a.pattern.clone(), | |
| 208 | before: trimmed(a.before.as_deref()), | |
| 209 | after: trimmed(a.after.as_deref()), | |
| 210 | }; | |
| 211 | if let Err(problem) = custom::compile(&scan_spec(&spec)) { | |
| 212 | return Ok(fail(FailureCode::Invalid, problem)); | |
| 213 | } | |
| 214 | let targets: Vec<RepoRow> = match scope { | |
| 215 | Scope::Repo(repo) => { | |
| 216 | if let Some(refusal) = self.gate(&repo, PaidFeature::CustomPatterns).await? { | |
| 217 | return Ok(refusal); | |
| 218 | } | |
| 219 | vec![repo] | |
| 220 | } | |
| 221 | Scope::Workspace(namespace) => { | |
| 222 | let all = self.store.in_namespace(&namespace).await?; | |
| 223 | let activated = self.activated(&namespace).await; | |
| 224 | let mut chosen = Vec::new(); | |
| 225 | for repo in all { | |
| 226 | if !a.repos.is_empty() && !a.repos.iter().any(|name| name.eq_ignore_ascii_case(&repo.name)) { | |
| 227 | continue; | |
| 228 | } | |
| 229 | let (_, private) = self.store.repo_settings(&repo.repo_id).await?; | |
| 230 | if private && !activated { | |
| 231 | continue; | |
| 232 | } | |
| 233 | chosen.push(repo); | |
| 234 | if chosen.len() == DRY_RUN_REPOS { | |
| 235 | break; | |
| 236 | } | |
| 237 | } | |
| 238 | if chosen.is_empty() && !activated { | |
| 239 | return Ok(payment_required(PaidFeature::CustomPatterns, &namespace)); | |
| 240 | } | |
| 241 | chosen | |
| 242 | } | |
| 243 | }; | |
| 244 | let mut repos = Vec::new(); | |
| 245 | for repo in targets { | |
| 246 | let result: PatternMatches = g1t_kit::call( | |
| 247 | &self.repos, | |
| 248 | "match_pattern", | |
| 249 | &MatchPatternArgs { repo_id: repo.repo_id.clone(), pattern: spec.clone(), limit: DRY_RUN_MATCHES }, | |
| 250 | ) | |
| 251 | .await | |
| 252 | .unwrap_or_else(|error| { | |
| 253 | worker::console_error!("security: dry run on {}: {error}", repo.repo_id); | |
| 254 | PatternMatches::default() | |
| 255 | }); | |
| 256 | repos.push(DryRunRepo { name: repo.name.clone(), result }); | |
| 257 | } | |
| 258 | Ok(Outcome::Ok(DryRun { repos })) | |
| 259 | } | |
| 260 | ||
| 261 | /// The patterns push protection and scans use for a repository: its | |
| 262 | /// published ones and its workspace's, when it is entitled to them. | |
| 263 | pub(crate) async fn patterns_for(&self, a: PatternsForArgs) -> Result<Vec<PatternSpec>> { | |
| 264 | let namespace = a.namespace.to_lowercase(); | |
| 265 | let patterns = self.store.published_patterns(&namespace, &a.repo_id).await?; | |
| 266 | if patterns.is_empty() { | |
| 267 | return Ok(patterns); | |
| 268 | } | |
| 269 | let private = match a.private { | |
| 270 | Some(private) => private, | |
| 271 | None => self.store.repo_settings(&a.repo_id).await?.1, | |
| 272 | }; | |
| 273 | if private && !self.activated(&namespace).await { | |
| 274 | return Ok(Vec::new()); | |
| 275 | } | |
| 276 | Ok(patterns) | |
| 277 | } | |
| 278 | } |