Skip to content

g1t/services/security/src/patterns.rs

280 lines12,388 bytesCodeBlame
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
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, 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::ManageSecurity } 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 let manages = actor.as_ref().is_some_and(|user| user.manages_security(&workspace));
71 Ok(match (role, change) {
72 (None, _) => fail(FailureCode::NotFound, "Workspace not found."),
73 (Some(_), false) => Outcome::Ok(Scope::Workspace(workspace)),
74 (Some(_), true) if manages => Outcome::Ok(Scope::Workspace(workspace)),
75 (Some(_), true) => fail(FailureCode::Forbidden, "Only an owner or a security manager can change the workspace's custom patterns."),
76 })
77 }
78 }
79 }
80
81 pub(crate) async fn custom_patterns(&self, a: CustomPatternsArgs) -> Result<Outcome<PatternList>> {
82 let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &a.viewer, false).await? {
83 Outcome::Ok(scope) => scope,
84 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
85 };
86 let (rows, entitled) = match &scope {
87 Scope::Repo(repo) => (self.store.patterns(&repo.namespace, Some(&repo.repo_id)).await?, self.entitled(repo).await?),
88 Scope::Workspace(namespace) => (self.store.patterns(namespace, None).await?, self.activated(namespace).await),
89 };
90 Ok(Outcome::Ok(PatternList { patterns: rows.iter().map(|row| row.contract()).collect(), entitled }))
91 }
92
93 pub(crate) async fn save_custom_pattern(&self, a: SaveCustomPatternArgs) -> Result<Outcome<SavedPattern>> {
94 let actor = Some(a.actor.clone());
95 let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &actor, true).await? {
96 Outcome::Ok(scope) => scope,
97 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
98 };
99 if !a.actor.verified {
100 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
101 }
102 let (namespace, repo_id) = match &scope {
103 Scope::Repo(repo) => {
104 if let Some(refusal) = self.gate(repo, PaidFeature::CustomPatterns).await? {
105 return Ok(refusal);
106 }
107 (repo.namespace.clone(), Some(repo.repo_id.clone()))
108 }
109 Scope::Workspace(namespace) => (namespace.clone(), None),
110 };
111 // A pattern changed must be one of this scope's.
112 if let Some(id) = &a.id {
113 match self.store.pattern(id).await? {
114 Some(row) if row.namespace == namespace && row.repo_id == repo_id => {}
115 _ => return Ok(fail(FailureCode::NotFound, "No such pattern.")),
116 }
117 } else if self.store.patterns(&namespace, repo_id.as_deref()).await?.len() >= custom::MAX_PATTERNS {
118 return Ok(fail(FailureCode::Invalid, format!("A repository is scanned with at most {} custom patterns.", custom::MAX_PATTERNS)));
119 }
120 let spec = PatternSpec {
121 id: a.id.clone().unwrap_or_default(),
122 name: a.name.trim().chars().take(100).collect(),
123 pattern: a.pattern.clone(),
124 before: trimmed(a.before.as_deref()),
125 after: trimmed(a.after.as_deref()),
126 };
127 let compiled = match custom::compile(&scan_spec(&spec)) {
128 Ok(compiled) => compiled,
129 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
130 };
131 let tests = match custom::clean_test_strings(&a.test_strings) {
132 Ok(tests) => tests,
133 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
134 };
135 let state = if a.publish { "published" } else { "draft" };
136 let was_published = match &a.id {
137 Some(id) => self.store.pattern(id).await?.is_some_and(|row| row.state == "published"),
138 None => false,
139 };
140 let id = self
141 .store
142 .save_pattern(a.id.as_deref(), &namespace, repo_id.as_deref(), &spec, &tests, state, &a.actor.username)
143 .await?;
144 // Published, or changed while published: the history is read again
145 // for it, a page at a time, as the first scan was.
146 if a.publish {
147 self.store.rescan_for_pattern(&namespace, repo_id.as_deref()).await?;
148 }
149 let verb = match (a.id.is_some(), a.publish, was_published) {
150 (false, true, _) => "published",
151 (false, false, _) => "saved as a draft",
152 (true, true, false) => "published",
153 (true, false, true) => "unpublished",
154 (true, _, _) => "changed",
155 };
156 let repo_row = match &scope {
157 Scope::Repo(repo) => Some(repo),
158 Scope::Workspace(_) => None,
159 };
160 self.audit(
161 &a.actor,
162 "custom_pattern",
163 repo_row,
164 &namespace,
165 None,
166 &format!("Custom pattern \"{}\" {verb}: {}", spec.name, spec.pattern),
167 )
168 .await;
169 let Some(row) = self.store.pattern(&id).await? else {
170 return Ok(fail(FailureCode::NotFound, "The pattern was not saved."));
171 };
172 let tests = custom::test(&compiled, &tests)
173 .into_iter()
174 .map(|found| found.map(|(start, end)| (start as u32, end as u32)))
175 .collect();
176 Ok(Outcome::Ok(SavedPattern { pattern: row.contract(), tests }))
177 }
178
179 pub(crate) async fn delete_custom_pattern(&self, a: DeleteCustomPatternArgs) -> Result<Outcome<bool>> {
180 let actor = Some(a.actor.clone());
181 let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &actor, true).await? {
182 Outcome::Ok(scope) => scope,
183 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
184 };
185 let (namespace, repo) = match &scope {
186 Scope::Repo(repo) => (repo.namespace.clone(), Some(repo)),
187 Scope::Workspace(namespace) => (namespace.clone(), None),
188 };
189 let Some(row) = self.store.pattern(&a.id).await? else {
190 return Ok(fail(FailureCode::NotFound, "No such pattern."));
191 };
192 if row.namespace != namespace || row.repo_id.as_deref() != repo.map(|repo| repo.repo_id.as_str()) {
193 return Ok(fail(FailureCode::NotFound, "No such pattern."));
194 }
195 self.store.delete_pattern(&a.id).await?;
196 self.audit(&a.actor, "custom_pattern", repo, &namespace, None, &format!("Custom pattern \"{}\" deleted", row.name)).await;
197 Ok(Outcome::Ok(true))
198 }
199
200 pub(crate) async fn dry_run_pattern(&self, a: DryRunPatternArgs) -> Result<Outcome<DryRun>> {
201 let actor = Some(a.actor.clone());
202 let scope = match self.pattern_scope(&a.workspace, a.repo.as_ref(), &actor, true).await? {
203 Outcome::Ok(scope) => scope,
204 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
205 };
206 let spec = PatternSpec {
207 id: "pat_dry_run".to_owned(),
208 name: "Dry run".to_owned(),
209 pattern: a.pattern.clone(),
210 before: trimmed(a.before.as_deref()),
211 after: trimmed(a.after.as_deref()),
212 };
213 if let Err(problem) = custom::compile(&scan_spec(&spec)) {
214 return Ok(fail(FailureCode::Invalid, problem));
215 }
216 let targets: Vec<RepoRow> = match scope {
217 Scope::Repo(repo) => {
218 if let Some(refusal) = self.gate(&repo, PaidFeature::CustomPatterns).await? {
219 return Ok(refusal);
220 }
221 vec![repo]
222 }
223 Scope::Workspace(namespace) => {
224 let all = self.store.in_namespace(&namespace).await?;
225 let activated = self.activated(&namespace).await;
226 let mut chosen = Vec::new();
227 for repo in all {
228 if !a.repos.is_empty() && !a.repos.iter().any(|name| name.eq_ignore_ascii_case(&repo.name)) {
229 continue;
230 }
231 let (_, private) = self.store.repo_settings(&repo.repo_id).await?;
232 if private && !activated {
233 continue;
234 }
235 chosen.push(repo);
236 if chosen.len() == DRY_RUN_REPOS {
237 break;
238 }
239 }
240 if chosen.is_empty() && !activated {
241 return Ok(payment_required(PaidFeature::CustomPatterns, &namespace));
242 }
243 chosen
244 }
245 };
246 let mut repos = Vec::new();
247 for repo in targets {
248 let result: PatternMatches = g1t_kit::call(
249 &self.repos,
250 "match_pattern",
251 &MatchPatternArgs { repo_id: repo.repo_id.clone(), pattern: spec.clone(), limit: DRY_RUN_MATCHES },
252 )
253 .await
254 .unwrap_or_else(|error| {
255 worker::console_error!("security: dry run on {}: {error}", repo.repo_id);
256 PatternMatches::default()
257 });
258 repos.push(DryRunRepo { name: repo.name.clone(), result });
259 }
260 Ok(Outcome::Ok(DryRun { repos }))
261 }
262
263 /// The patterns push protection and scans use for a repository: its
264 /// published ones and its workspace's, when it is entitled to them.
265 pub(crate) async fn patterns_for(&self, a: PatternsForArgs) -> Result<Vec<PatternSpec>> {
266 let namespace = a.namespace.to_lowercase();
267 let patterns = self.store.published_patterns(&namespace, &a.repo_id).await?;
268 if patterns.is_empty() {
269 return Ok(patterns);
270 }
271 let private = match a.private {
272 Some(private) => private,
273 None => self.store.repo_settings(&a.repo_id).await?.1,
274 };
275 if private && !self.activated(&namespace).await {
276 return Ok(Vec::new());
277 }
278 Ok(patterns)
279 }
280}