-
-
Notifications
You must be signed in to change notification settings - Fork 1
F-022: perf(services): RegexSet first-pass for secret scanning #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| use std::borrow::Cow; | ||||||||||||||||||||||||||||||||||||||||||||||
| use std::sync::LazyLock; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| use regex::Regex; | ||||||||||||||||||||||||||||||||||||||||||||||
| use regex::{Regex, RegexSet}; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| use crate::domain::StagedChanges; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -23,11 +23,62 @@ pub struct SecretPattern { | |||||||||||||||||||||||||||||||||||||||||||||
| pub description: Cow<'static, str>, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// A bundle of secret-detection patterns together with a [`RegexSet`] over | ||||||||||||||||||||||||||||||||||||||||||||||
| /// the same patterns for a fast first-pass filter. | ||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||
| /// Per-line scanning calls [`RegexSet::matches`] first; only on hits does it | ||||||||||||||||||||||||||||||||||||||||||||||
| /// fall back to individual [`Regex::is_match`] on the matching pattern. This | ||||||||||||||||||||||||||||||||||||||||||||||
| /// turns the common no-match case from `O(N × 24 × L)` into | ||||||||||||||||||||||||||||||||||||||||||||||
| /// `O(N × L)` (one aggregated automaton pass) plus an `O(1)` membership check. | ||||||||||||||||||||||||||||||||||||||||||||||
| pub struct PatternSet { | ||||||||||||||||||||||||||||||||||||||||||||||
| patterns: Vec<SecretPattern>, | ||||||||||||||||||||||||||||||||||||||||||||||
| set: RegexSet, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| impl PatternSet { | ||||||||||||||||||||||||||||||||||||||||||||||
| /// Number of patterns in the set. | ||||||||||||||||||||||||||||||||||||||||||||||
| #[allow(dead_code)] // exercised by integration tests; library consumers may also want it | ||||||||||||||||||||||||||||||||||||||||||||||
| pub fn len(&self) -> usize { | ||||||||||||||||||||||||||||||||||||||||||||||
| self.patterns.len() | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// True when there are no patterns — every scan is a no-op. | ||||||||||||||||||||||||||||||||||||||||||||||
| #[allow(dead_code)] // paired with len() for clippy::len_without_is_empty | ||||||||||||||||||||||||||||||||||||||||||||||
| pub fn is_empty(&self) -> bool { | ||||||||||||||||||||||||||||||||||||||||||||||
| self.patterns.is_empty() | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Iterate the patterns in stable order (matches [`RegexSet`] index order). | ||||||||||||||||||||||||||||||||||||||||||||||
| #[allow(dead_code)] // exercised by integration tests; library consumers may also want it | ||||||||||||||||||||||||||||||||||||||||||||||
| pub fn iter(&self) -> std::slice::Iter<'_, SecretPattern> { | ||||||||||||||||||||||||||||||||||||||||||||||
| self.patterns.iter() | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Build a [`PatternSet`] from a `Vec<SecretPattern>`. The [`RegexSet`] | ||||||||||||||||||||||||||||||||||||||||||||||
| /// is compiled from the patterns' source strings; indices align 1:1 with | ||||||||||||||||||||||||||||||||||||||||||||||
| /// `patterns`, so `set.matches(line)` yields valid indices into | ||||||||||||||||||||||||||||||||||||||||||||||
| /// `patterns`. | ||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||
| /// Since every individual [`Regex`] in `patterns` is already known to | ||||||||||||||||||||||||||||||||||||||||||||||
| /// compile, the combined [`RegexSet`] compile is expected to succeed; if | ||||||||||||||||||||||||||||||||||||||||||||||
| /// it somehow does not (size limits, etc.), we fall back to an empty | ||||||||||||||||||||||||||||||||||||||||||||||
| /// [`RegexSet`] and the scanner still works correctly — it just loses | ||||||||||||||||||||||||||||||||||||||||||||||
| /// the first-pass speedup and runs the per-pattern check on every line. | ||||||||||||||||||||||||||||||||||||||||||||||
| fn from_patterns(patterns: Vec<SecretPattern>) -> Self { | ||||||||||||||||||||||||||||||||||||||||||||||
| // `Regex::as_str` returns the exact source the regex was built from, | ||||||||||||||||||||||||||||||||||||||||||||||
| // so feeding the same strings into `RegexSet` yields identical | ||||||||||||||||||||||||||||||||||||||||||||||
| // semantics to the per-pattern `Regex::is_match` fallback. | ||||||||||||||||||||||||||||||||||||||||||||||
| let sources: Vec<&str> = patterns.iter().map(|p| p.regex.as_str()).collect(); | ||||||||||||||||||||||||||||||||||||||||||||||
| let set = RegexSet::new(&sources).unwrap_or_else(|_| RegexSet::empty()); | ||||||||||||||||||||||||||||||||||||||||||||||
| Self { patterns, set } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+62
to
+74
|
||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Build the full set of secret patterns, applying custom additions and disabled names. | ||||||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||||||
| /// Custom patterns are compiled from user-provided regex strings. Invalid regexes | ||||||||||||||||||||||||||||||||||||||||||||||
| /// are silently skipped (logged at warn level in the caller). | ||||||||||||||||||||||||||||||||||||||||||||||
| pub fn build_patterns(custom: &[String], disabled: &[String]) -> Vec<SecretPattern> { | ||||||||||||||||||||||||||||||||||||||||||||||
| pub fn build_patterns(custom: &[String], disabled: &[String]) -> PatternSet { | ||||||||||||||||||||||||||||||||||||||||||||||
| let mut patterns = builtin_patterns(); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Remove disabled patterns by name (case-insensitive match) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -47,7 +98,7 @@ pub fn build_patterns(custom: &[String], disabled: &[String]) -> Vec<SecretPatte | |||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| patterns | ||||||||||||||||||||||||||||||||||||||||||||||
| PatternSet::from_patterns(patterns) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| fn builtin_patterns() -> Vec<SecretPattern> { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -197,7 +248,18 @@ fn builtin_patterns() -> Vec<SecretPattern> { | |||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Default patterns (no custom, no disabled) for use by the legacy API. | ||||||||||||||||||||||||||||||||||||||||||||||
| static DEFAULT_PATTERNS: LazyLock<Vec<SecretPattern>> = LazyLock::new(builtin_patterns); | ||||||||||||||||||||||||||||||||||||||||||||||
| static DEFAULT_PATTERNS: LazyLock<PatternSet> = | ||||||||||||||||||||||||||||||||||||||||||||||
| LazyLock::new(|| PatternSet::from_patterns(builtin_patterns())); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// First element of the set that matches `line`, if any. Uses | ||||||||||||||||||||||||||||||||||||||||||||||
| /// [`RegexSet::matches`] as a single-automaton first-pass filter — the | ||||||||||||||||||||||||||||||||||||||||||||||
| /// common case (no match) returns without touching any individual | ||||||||||||||||||||||||||||||||||||||||||||||
| /// [`Regex`]. `SetMatches::iter()` yields indices in ascending order, so | ||||||||||||||||||||||||||||||||||||||||||||||
| /// taking `.next()` preserves the previous "first pattern wins" semantics. | ||||||||||||||||||||||||||||||||||||||||||||||
| fn first_match<'a>(line: &str, patterns: &'a PatternSet) -> Option<&'a SecretPattern> { | ||||||||||||||||||||||||||||||||||||||||||||||
| let idx = patterns.set.matches(line).iter().next()?; | ||||||||||||||||||||||||||||||||||||||||||||||
| Some(&patterns.patterns[idx]) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+255
to
+261
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// [`RegexSet::matches`] as a single-automaton first-pass filter — the | |
| /// common case (no match) returns without touching any individual | |
| /// [`Regex`]. `SetMatches::iter()` yields indices in ascending order, so | |
| /// taking `.next()` preserves the previous "first pattern wins" semantics. | |
| fn first_match<'a>(line: &str, patterns: &'a PatternSet) -> Option<&'a SecretPattern> { | |
| let idx = patterns.set.matches(line).iter().next()?; | |
| Some(&patterns.patterns[idx]) | |
| /// [`RegexSet::matches`] as a single-automaton first-pass filter, then | |
| /// falls back to checking individual [`Regex`] values if the set yields | |
| /// no match. This preserves correctness if the set is unavailable while | |
| /// keeping the fast path for the common case. `SetMatches::iter()` | |
| /// yields indices in ascending order, so taking `.next()` preserves the | |
| /// previous "first pattern wins" semantics. | |
| fn first_match<'a>(line: &str, patterns: &'a PatternSet) -> Option<&'a SecretPattern> { | |
| if let Some(idx) = patterns.set.matches(line).iter().next() { | |
| return Some(&patterns.patterns[idx]); | |
| } | |
| patterns | |
| .patterns | |
| .iter() | |
| .find(|pattern| pattern.regex.is_match(line)) |
Copilot
AI
Apr 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In scan_for_secrets_with_patterns, first_match is called with the full diff line (including the leading '+'), while scan_full_diff_with_patterns strips the '+' before matching. This inconsistency can cause anchored custom patterns (e.g. ^TOKEN_...) to never match in the per-file API. Consider matching against &line[1..] here as well to align semantics across both scanners.
| if let Some(pat) = first_match(line, patterns) { | |
| let content = &line[1..]; | |
| if let Some(pat) = first_match(content, patterns) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The doc for
PatternSet/from_patternsdescribes a per-patternRegex::is_matchfallback afterRegexSet::matches, butfirst_matchcurrently returns the firstRegexSethit without any per-pattern verification, and there is no fallback path when the set can’t be built. Please align the documentation with the actual algorithm, or implement the described fallback behavior (especially for theRegexSetcompile-failure case).