Skip to content

g1t/services/security/src/patterns.rs

278 lines12,209 bytesCodeBlame

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 bar1//! 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
7use g1t_contracts::access::Capability;
8use g1t_contracts::security_suite::{
9 CustomPatternsArgs, DeleteCustomPatternArgs, DryRun, DryRunPatternArgs, DryRunRepo, MatchPatternArgs, PaidFeature, PatternList,
10 PatternMatches, PatternSpec, PatternsForArgs, SaveCustomPatternArgs, SavedPattern,
11};
12use g1t_contracts::{FailureCode, Outcome, Role, User};
13use g1t_scan::custom;
14use worker::Result;
15
16use crate::Security;
17use crate::store::RepoRow;
18use crate::suite::payment_required;
19
20/// Repositories a workspace's dry run reads, at most.
21const DRY_RUN_REPOS: usize = 10;
22/// Matches a dry run shows per repository.
23const DRY_RUN_MATCHES: u32 = 50;
24
25fn 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.
30pub 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
40fn 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).
46enum Scope {
47 Repo(RepoRow),
48 Workspace(String),
49}
50
51impl 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}