git_policy_cli/policy/policy_forbidden_strings.rs
1//! What:
2//! The optional `security/forbidden-strings` policy:
3//! scan every candidate's exact
4//! bytes and pathname through the linked scanner and report each match,
5//! redacted.
6//! Why:
7//! The installed wrapper wrote candidate bytes to temporary files,
8//! started the
9//! scanner executable and parsed its standard error.
10//! Here the same scanner is a
11//! library:
12//! rules are loaded once per invocation,
13//! only when a candidate can be
14//! scanned,
15//! and each finding carries the scanner's masked display path and an
16//! opaque rule name,
17//! never the matched text.
18//! A scan that could not complete ends
19//! the policy with the code of its cause,
20//! never as a clean result.
21//!
22//! In TS you'd write (pseudocode):
23//! ```ts
24//! // const findings = await scanCandidates({ builtinRules, repositoryRoot, candidates });
25//! ```
26
27/// Import the candidates whose content a parent commit's tree already holds.
28use crate::candidate_known_content::KnownContent;
29/// Import the immutable version type.
30use crate::candidate_version::CandidateVersion;
31/// Import the validated options of this policy.
32use crate::config_schema::{ForbiddenStringsOptions, PolicyConfig};
33/// Import the debug and warning diagnostics on standard error.
34use crate::diagnostic_log::{debug, warn};
35/// Import the engine-failure codes.
36use crate::diagnostics::EngineFailureCode;
37/// Import the event form of a pathname.
38use crate::event_path::EventPath;
39/// Import the lifecycle's candidates.
40use crate::policy_content::{ContentState, LifecycleContent};
41/// Import the finding and outcome types of a check.
42use crate::policy_engine::{PolicyFinding, PolicyOutcome};
43/// Import the facts interface for the repository's top level.
44use crate::repository_facts::RepositoryFacts;
45/// Import the repository location answer.
46use crate::repository_location::RepositoryLocation;
47/// Import the scanner adapter and its failure.
48use crate::scanner_adapter::{CandidateScanner, RulesSource, ScannerError, ScannerFailure};
49/// Import the code of each scanner and scan failure.
50use crate::scanner_failure_code::{
51 finding_failure_code, scan_run_failure_code, scanner_failure_code,
52};
53/// Import the rules-file precedence and which candidates are scanned.
54use crate::scanner_selection::{RULES_VARIABLE, is_scannable, rules_candidate_paths, rules_source};
55/// Import the worktree's top level from Git's answer.
56use crate::worktree_identity::worktree_root;
57/// The scanner library's per-candidate result and per-match finding.
58use forbidden_strings::{CandidateScan, RulesListError, ScanFinding};
59/// What:
60/// `OsString` is owned operating-system text of raw OS bytes (sibling `String`
61/// must be UTF-8).
62/// Why:
63/// The variable's value is a path and need not be UTF-8.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// // string, but byte-preserving.
68/// ```
69use std::ffi::OsString;
70/// `PathBuf` is an owned filesystem path.
71use std::path::PathBuf;
72/// `Rc<T>` is a shared,
73/// read-only handle.
74use std::rc::Rc;
75
76/// The policy-local code of a match.
77pub const FORBIDDEN_STRING_CODE: &str = "forbidden-string";
78
79/// The engine-failure message of a scan a matcher could not complete.
80pub const ENGINE_ERROR_MESSAGE: &str = "The forbidden-strings scanner reported a matcher failure while scanning a candidate, so the scan is incomplete.";
81
82/// The engine-failure message of a pathname the line-based scanner cannot inspect.
83pub const LINE_BREAK_MESSAGE: &str = "The forbidden-strings scanner cannot inspect a candidate pathname that contains a line break, so the scan is incomplete.";
84
85/// What:
86/// Where the rules come from:
87/// the policy's options and the value of
88/// `FORBIDDEN_STRINGS_RULES` in the invocation's environment.
89/// Why:
90/// The lifecycle reads both once;
91/// the check resolves them against the repository
92/// root only when it scans.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// type ScannerSettings = { options: ForbiddenStringsOptions; rulesVariable?: string };
97/// ```
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct ScannerSettings {
100 /// The policy's validated options;
101 /// defaults until the configuration is loaded.
102 pub options: ForbiddenStringsOptions,
103 /// The value of `FORBIDDEN_STRINGS_RULES`,
104 /// when the variable is set.
105 pub rules_variable: Option<OsString>,
106}
107
108/// What:
109/// Settings with the default options and no variable.
110/// Why:
111/// A lifecycle that loads no configuration and has no candidates never scans.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// const defaultScannerSettings = (): ScannerSettings => ({ options: defaults.forbiddenStrings });
116/// ```
117pub fn default_scanner_settings() -> ScannerSettings {
118 return ScannerSettings {
119 options: PolicyConfig::defaults().forbidden_strings,
120 rules_variable: None,
121 };
122}
123
124/// What:
125/// The scanner of one invocation,
126/// loaded on first use,
127/// or the remembered failure.
128/// Why:
129/// Rules are loaded once per invocation and only when a candidate needs scanning.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// class ScannerState { #loaded?: CandidateScanner | ScannerError }
134/// ```
135pub struct ScannerState {
136 /// The loaded scanner,
137 /// or the failure of the one attempt to load it.
138 loaded: Option<Result<CandidateScanner, ScannerError>>,
139}
140
141/// What:
142/// A state that has loaded nothing.
143/// Why:
144/// Loading waits until a candidate can be scanned.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// const unloadedScanner = (): ScannerState => new ScannerState();
149/// ```
150pub fn unloaded_scanner() -> ScannerState {
151 // `None` is the "absent" variant: nothing has been loaded.
152 return ScannerState { loaded: None };
153}
154
155/// What:
156/// The outcome of a check that could not finish.
157/// `&str` is borrowed message text.
158/// Why:
159/// Every failure of this policy names its cause through its code.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// const failed = (code, message): PolicyOutcome => ({ kind: 'failed', code, message });
164/// ```
165fn failed(code: EngineFailureCode, message: &str) -> PolicyOutcome {
166 return PolicyOutcome::Failed {
167 code,
168 message: String::from(message),
169 // `None`: a scan failure never names a pathname, which may hold a secret.
170 path: None,
171 };
172}
173
174/// What:
175/// The failure a scan result carries,
176/// or nothing when its findings are all matches.
177/// `&CandidateScan` borrows one candidate's result.
178/// Why:
179/// The scanner reports a failed matcher and an uninspectable pathname inside an
180/// otherwise successful result;
181/// either makes the whole scan incomplete,
182/// so it is
183/// found before any match is reported.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// function incompleteScan(scan: CandidateScan): PolicyOutcome | undefined;
188/// ```
189fn incomplete_scan(scan: &CandidateScan) -> Option<PolicyOutcome> {
190 for finding in &scan.findings {
191 // `if let Some(code) = ...` runs only for a finding that is a failure.
192 if let Some(code) = finding_failure_code(finding) {
193 let message: &str = if *finding == ScanFinding::PathnameLineBreak {
194 LINE_BREAK_MESSAGE
195 } else {
196 ENGINE_ERROR_MESSAGE
197 };
198 return Some(failed(code, message));
199 }
200 }
201 return None;
202}
203
204/// What:
205/// The finding for one match in one scan,
206/// or nothing for a finding that is not a match.
207/// Why:
208/// The words,
209/// the code and the masked path are the installed wrapper's,
210/// so a match
211/// is reported the same way by both wrappers and never shows the matched text.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// const toFinding = (hit, displayPath): PolicyFinding => ({ code: 'forbidden-string', message, path: displayPath });
216/// ```
217fn match_finding(finding: &ScanFinding, display_path: &str) -> Option<PolicyFinding> {
218 let message: String = match finding {
219 ScanFinding::Content { line, rule } => {
220 format!("Forbidden string matched at line {line} (rule {rule}).")
221 }
222 ScanFinding::Name { component, rule } => {
223 format!("Forbidden string matched in pathname segment {component} (rule {rule}).")
224 }
225 ScanFinding::EngineError | ScanFinding::PathnameLineBreak => return None,
226 };
227 return Some(PolicyFinding {
228 code: FORBIDDEN_STRING_CODE,
229 message,
230 // The scanner's masked display is text only: its bytes would undo the masking.
231 path: Some(EventPath::display(display_path)),
232 location: None,
233 fix_available: false,
234 });
235}
236
237/// What:
238/// `impl ScannerState { ... }` attaches the one way to get the loaded scanner.
239/// Why:
240/// Loading and its remembered failure stay behind one owner.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// class ScannerState { load(rules, builtinRules): CandidateScanner }
245/// ```
246impl ScannerState {
247 /// What:
248 /// The scanner,
249 /// loaded from `rules` on first use.
250 /// `Result<&CandidateScanner, ..>`
251 /// lends the loaded scanner,
252 /// or returns the outcome the policy reports.
253 /// Why:
254 /// A second call in the same invocation reuses the loaded rules,
255 /// and a failure
256 /// is not retried.
257 ///
258 /// In TS you'd write (pseudocode):
259 /// ```ts
260 /// load(rules: RulesSource, builtinRules: boolean): CandidateScanner; // throws PolicyOutcome
261 /// ```
262 fn load(
263 &mut self,
264 rules: &RulesSource,
265 builtin_rules: bool,
266 ) -> Result<&CandidateScanner, PolicyOutcome> {
267 if self.loaded.is_none() {
268 self.loaded = Some(CandidateScanner::load(rules, builtin_rules));
269 // An absent optional file is routine on a fresh clone, so it is logged for diagnosis, not warned.
270 if let Some(Ok(scanner)) = &self.loaded {
271 for skipped in scanner.skipped_optional_rules() {
272 debug(
273 "checkForbiddenStrings",
274 format!("optional rules file is absent; skipped: {}", skipped.display()).as_str(),
275 );
276 }
277 }
278 }
279 // `match` on the remembered attempt, lending the scanner when it loaded.
280 match &self.loaded {
281 Some(Ok(scanner)) => return Ok(scanner),
282 Some(Err(error)) => {
283 return Err(failed(
284 scanner_failure_code(error.failure),
285 error.message.as_str(),
286 ));
287 }
288 None => {
289 return Err(failed(
290 EngineFailureCode::PolicyIncomplete,
291 "cli-git did not load the forbidden-strings rules; this is a defect in cli-git.",
292 ));
293 }
294 }
295 }
296}
297
298/// What:
299/// The repository's top level,
300/// from Git's answer about where the command runs.
301/// Why:
302/// The rules file is named relative to it,
303/// as the scanner child's working
304/// directory was under the installed wrapper.
305///
306/// In TS you'd write (pseudocode):
307/// ```ts
308/// async function repositoryRoot(facts): Promise<string>;
309/// ```
310fn repository_root<F: RepositoryFacts>(facts: &mut F) -> Result<PathBuf, PolicyOutcome> {
311 let location: RepositoryLocation = match facts.location() {
312 Ok(found) => found,
313 Err(message) => {
314 return Err(failed(
315 EngineFailureCode::ContentUnavailable,
316 message.as_str(),
317 ));
318 }
319 };
320 match worktree_root(&location.identity) {
321 Some(root) => return Ok(root.to_path_buf()),
322 None => {
323 return Err(failed(
324 EngineFailureCode::ContentUnavailable,
325 "cli-git could not find the repository's top level to resolve the forbidden-strings rules file.",
326 ));
327 }
328 }
329}
330
331/// What:
332/// The candidates whose content a parent commit's tree already holds,
333/// or none when that cannot be
334/// determined.
335/// `&mut ContentState` lends the prepared candidates;
336/// `&[Vec<u8>]` are the rules files'
337/// own pathnames.
338/// Why:
339/// Skipping content is only ever a saving.
340/// When the parents cannot be listed,
341/// every candidate is
342/// scanned in full as it was before the rule existed,
343/// and the warning says why,
344/// so a Git failure
345/// can make the policy slower or stricter and never more permissive.
346///
347/// In TS you'd write (pseudocode):
348/// ```ts
349/// async function knownContentOrNone(content, lifecycle, rulesPath): Promise<KnownContent>;
350/// ```
351fn known_content_or_none(
352 content: &mut ContentState,
353 lifecycle: &LifecycleContent,
354 rules_paths: &[Vec<u8>],
355) -> KnownContent {
356 // Only a lifecycle with candidates has a request to take the parents from.
357 let LifecycleContent::Requested(request) = lifecycle else {
358 return KnownContent::default();
359 };
360 match content.known_content(request, rules_paths) {
361 Ok(found) => return found,
362 Err(error) => {
363 warn(
364 "checkForbiddenStrings",
365 format!(
366 "could not list the parent commits' files, so every candidate is scanned in full: {}",
367 error.message
368 )
369 .as_str(),
370 );
371 return KnownContent::default();
372 }
373 }
374}
375
376/// What:
377/// Record each skipped candidate as a decision:
378/// its masked pathname,
379/// its blob and the parent commit that holds it.
380/// `&[CandidateScan]` borrows the scan results,
381/// whose identity is the candidate's position.
382/// Why:
383/// A skip changes what the policy reads,
384/// so the inputs that justify it are kept for whoever checks a verdict later.
385/// The pathname is the scanner's masked display,
386/// never the raw pathname,
387/// which may hold a secret.
388///
389/// In TS you'd write (pseudocode):
390/// ```ts
391/// function logKnownContent(scans: CandidateScan[], known: KnownContent): void;
392/// ```
393fn log_known_content(scans: &[CandidateScan], known: &KnownContent) {
394 for scan in scans {
395 if let Some(entry) = known.entry(scan.identity) {
396 debug(
397 "checkForbiddenStrings",
398 entry.describe(scan.display_path.as_str()).as_str(),
399 );
400 }
401 }
402}
403
404/// What:
405/// Scan every candidate the scanner is given and report each match.
406/// `&ScannerSettings` borrows where the rules come from;
407/// `&mut ScannerState` lends
408/// the invocation's scanner.
409/// Why:
410/// Deleted candidates,
411/// the rule sources and the rules file itself are not scanned,
412/// as the installed wrapper skips them.
413/// When nothing is left,
414/// no rules are loaded,
415/// so a missing rules file stops nothing that would not have been scanned.
416///
417/// In TS you'd write (pseudocode):
418/// ```ts
419/// async function checkForbiddenStrings(content, lifecycle, facts, settings, scanner): Promise<PolicyOutcome>;
420/// ```
421pub fn check_forbidden_strings<F: RepositoryFacts>(
422 content: &mut ContentState,
423 lifecycle: &LifecycleContent,
424 facts: &mut F,
425 settings: &ScannerSettings,
426 scanner: &mut ScannerState,
427) -> PolicyOutcome {
428 let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
429 Ok(Some(found)) => found,
430 // `Vec::new()` is the empty list: a lifecycle without candidates has nothing to report.
431 Ok(None) => return PolicyOutcome::Findings(Vec::new()),
432 Err(outcome) => return outcome,
433 };
434 let root: PathBuf = match repository_root(facts) {
435 Ok(found) => found,
436 Err(outcome) => return outcome,
437 };
438 // A malformed variable fails the policy only when something is scanned, like a missing rules file.
439 let selected: Result<RulesSource, RulesListError> = rules_source(
440 // `.as_deref()` lends the optional owned list as an optional borrowed slice.
441 settings.options.rules_files.as_deref(),
442 settings.rules_variable.as_deref(),
443 root.as_path(),
444 );
445 let rules_paths: Vec<Vec<u8>> = match &selected {
446 Ok(rules) => rules_candidate_paths(rules, root.as_path()),
447 // `Vec::new()` is the empty list: no file is known to be a rules file.
448 Err(_) => Vec::new(),
449 };
450 let mut any_scannable: bool = false;
451 for candidate in version.candidates() {
452 if is_scannable(candidate, rules_paths.as_slice()) {
453 any_scannable = true;
454 break;
455 }
456 }
457 if !any_scannable {
458 return PolicyOutcome::Findings(Vec::new());
459 }
460 let rules: RulesSource = match selected {
461 Ok(found) => found,
462 Err(error) => {
463 return failed(
464 scanner_failure_code(ScannerFailure::RulesNotLoaded),
465 format!(
466 "cli-git could not load the forbidden-strings rules, so no file was scanned: \
467 the value of {RULES_VARIABLE} is not a valid rules list: {error}."
468 )
469 .as_str(),
470 );
471 }
472 };
473 let loaded: &CandidateScanner = match scanner.load(&rules, settings.options.builtin_rules) {
474 Ok(found) => found,
475 Err(outcome) => return outcome,
476 };
477 let known: KnownContent = known_content_or_none(content, lifecycle, rules_paths.as_slice());
478 let scans: Vec<CandidateScan> =
479 match content.scan_skipping(loaded, rules_paths.as_slice(), &known) {
480 Ok(found) => found,
481 // `.to_string()` renders the wrapped failure's message, which names no pathname.
482 Err(error) => {
483 return failed(scan_run_failure_code(&error), error.to_string().as_str());
484 }
485 };
486 log_known_content(&scans, &known);
487 // Every result is checked for a failure before any match is reported.
488 for scan in &scans {
489 if let Some(outcome) = incomplete_scan(scan) {
490 return outcome;
491 }
492 }
493 let mut findings: Vec<PolicyFinding> = Vec::new();
494 for scan in &scans {
495 for finding in &scan.findings {
496 if let Some(reported) = match_finding(finding, scan.display_path.as_str()) {
497 findings.push(reported);
498 }
499 }
500 }
501 return PolicyOutcome::Findings(findings);
502}
503
504/// Rules,
505/// scan and redaction controls stay out of the release executable.
506#[cfg(test)]
507#[path = "policy_forbidden_strings_tests.rs"]
508mod tests;