git_policy_cli/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 super::candidate_known_content::KnownContent;
29/// Import the immutable version type.
30use super::candidate_version::CandidateVersion;
31/// Import the validated options of this policy.
32use super::config_schema::{ForbiddenStringsOptions, PolicyConfig};
33/// Import the debug and warning diagnostics on standard error.
34use super::diagnostic_log::{debug, warn};
35/// Import the engine-failure codes.
36use super::diagnostics::EngineFailureCode;
37/// Import the event form of a pathname.
38use super::event_path::EventPath;
39/// Import the lifecycle's candidates.
40use super::policy_content::{ContentState, LifecycleContent};
41/// Import the finding and outcome types of a check.
42use super::policy_engine::{PolicyFinding, PolicyOutcome};
43/// Import the facts interface for the repository's top level.
44use super::repository_facts::RepositoryFacts;
45/// Import the repository location answer.
46use super::repository_location::RepositoryLocation;
47/// Import the scanner adapter and its failure.
48use super::scanner_adapter::{CandidateScanner, RulesSource, ScannerError};
49/// Import the code of each scanner and scan failure.
50use super::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 super::scanner_selection::{is_scannable, rules_candidate_path, rules_source};
55/// Import the worktree's top level from Git's answer.
56use super::worktree_identity::worktree_root;
57/// The scanner library's per-candidate result and per-match finding.
58use forbidden_strings::{CandidateScan, 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 }
270 // `match` on the remembered attempt, lending the scanner when it loaded.
271 match &self.loaded {
272 Some(Ok(scanner)) => return Ok(scanner),
273 Some(Err(error)) => {
274 return Err(failed(
275 scanner_failure_code(error.failure),
276 error.message.as_str(),
277 ));
278 }
279 None => {
280 return Err(failed(
281 EngineFailureCode::PolicyIncomplete,
282 "cli-git did not load the forbidden-strings rules; this is a defect in cli-git.",
283 ));
284 }
285 }
286 }
287}
288
289/// What:
290/// The repository's top level,
291/// from Git's answer about where the command runs.
292/// Why:
293/// The rules file is named relative to it,
294/// as the scanner child's working
295/// directory was under the installed wrapper.
296///
297/// In TS you'd write (pseudocode):
298/// ```ts
299/// async function repositoryRoot(facts): Promise<string>;
300/// ```
301fn repository_root<F: RepositoryFacts>(facts: &mut F) -> Result<PathBuf, PolicyOutcome> {
302 let location: RepositoryLocation = match facts.location() {
303 Ok(found) => found,
304 Err(message) => {
305 return Err(failed(
306 EngineFailureCode::ContentUnavailable,
307 message.as_str(),
308 ));
309 }
310 };
311 match worktree_root(&location.identity) {
312 Some(root) => return Ok(root.to_path_buf()),
313 None => {
314 return Err(failed(
315 EngineFailureCode::ContentUnavailable,
316 "cli-git could not find the repository's top level to resolve the forbidden-strings rules file.",
317 ));
318 }
319 }
320}
321
322/// What:
323/// The candidates whose content a parent commit's tree already holds,
324/// or none when that cannot be
325/// determined.
326/// `&mut ContentState` lends the prepared candidates;
327/// `Option<&[u8]>` is the rules file's
328/// own pathname.
329/// Why:
330/// Skipping content is only ever a saving.
331/// When the parents cannot be listed,
332/// every candidate is
333/// scanned in full as it was before the rule existed,
334/// and the warning says why,
335/// so a Git failure
336/// can make the policy slower or stricter and never more permissive.
337///
338/// In TS you'd write (pseudocode):
339/// ```ts
340/// async function knownContentOrNone(content, lifecycle, rulesPath): Promise<KnownContent>;
341/// ```
342fn known_content_or_none(
343 content: &mut ContentState,
344 lifecycle: &LifecycleContent,
345 rules_path: Option<&[u8]>,
346) -> KnownContent {
347 // Only a lifecycle with candidates has a request to take the parents from.
348 let LifecycleContent::Requested(request) = lifecycle else {
349 return KnownContent::default();
350 };
351 match content.known_content(request, rules_path) {
352 Ok(found) => return found,
353 Err(error) => {
354 warn(
355 "checkForbiddenStrings",
356 format!(
357 "could not list the parent commits' files, so every candidate is scanned in full: {}",
358 error.message
359 )
360 .as_str(),
361 );
362 return KnownContent::default();
363 }
364 }
365}
366
367/// What:
368/// Record each skipped candidate as a decision:
369/// its masked pathname,
370/// its blob and the parent commit that holds it.
371/// `&[CandidateScan]` borrows the scan results,
372/// whose identity is the candidate's position.
373/// Why:
374/// A skip changes what the policy reads,
375/// so the inputs that justify it are kept for whoever checks a verdict later.
376/// The pathname is the scanner's masked display,
377/// never the raw pathname,
378/// which may hold a secret.
379///
380/// In TS you'd write (pseudocode):
381/// ```ts
382/// function logKnownContent(scans: CandidateScan[], known: KnownContent): void;
383/// ```
384fn log_known_content(scans: &[CandidateScan], known: &KnownContent) {
385 for scan in scans {
386 if let Some(entry) = known.entry(scan.identity) {
387 debug(
388 "checkForbiddenStrings",
389 entry.describe(scan.display_path.as_str()).as_str(),
390 );
391 }
392 }
393}
394
395/// What:
396/// Scan every candidate the scanner is given and report each match.
397/// `&ScannerSettings` borrows where the rules come from;
398/// `&mut ScannerState` lends
399/// the invocation's scanner.
400/// Why:
401/// Deleted candidates,
402/// the rule sources and the rules file itself are not scanned,
403/// as the installed wrapper skips them.
404/// When nothing is left,
405/// no rules are loaded,
406/// so a missing rules file stops nothing that would not have been scanned.
407///
408/// In TS you'd write (pseudocode):
409/// ```ts
410/// async function checkForbiddenStrings(content, lifecycle, facts, settings, scanner): Promise<PolicyOutcome>;
411/// ```
412pub fn check_forbidden_strings<F: RepositoryFacts>(
413 content: &mut ContentState,
414 lifecycle: &LifecycleContent,
415 facts: &mut F,
416 settings: &ScannerSettings,
417 scanner: &mut ScannerState,
418) -> PolicyOutcome {
419 let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
420 Ok(Some(found)) => found,
421 // `Vec::new()` is the empty list: a lifecycle without candidates has nothing to report.
422 Ok(None) => return PolicyOutcome::Findings(Vec::new()),
423 Err(outcome) => return outcome,
424 };
425 let root: PathBuf = match repository_root(facts) {
426 Ok(found) => found,
427 Err(outcome) => return outcome,
428 };
429 let rules: RulesSource = rules_source(
430 // `.as_deref()` lends the optional owned text as optional borrowed text.
431 settings.options.rules_file.as_deref(),
432 settings.rules_variable.as_deref(),
433 root.as_path(),
434 );
435 let rules_path: Option<Vec<u8>> = rules_candidate_path(rules.path.as_path(), root.as_path());
436 let mut any_scannable: bool = false;
437 for candidate in version.candidates() {
438 if is_scannable(candidate, rules_path.as_deref()) {
439 any_scannable = true;
440 break;
441 }
442 }
443 if !any_scannable {
444 return PolicyOutcome::Findings(Vec::new());
445 }
446 let loaded: &CandidateScanner = match scanner.load(&rules, settings.options.builtin_rules) {
447 Ok(found) => found,
448 Err(outcome) => return outcome,
449 };
450 let known: KnownContent = known_content_or_none(content, lifecycle, rules_path.as_deref());
451 let scans: Vec<CandidateScan> =
452 match content.scan_skipping(loaded, rules_path.as_deref(), &known) {
453 Ok(found) => found,
454 // `.to_string()` renders the wrapped failure's message, which names no pathname.
455 Err(error) => {
456 return failed(scan_run_failure_code(&error), error.to_string().as_str());
457 }
458 };
459 log_known_content(&scans, &known);
460 // Every result is checked for a failure before any match is reported.
461 for scan in &scans {
462 if let Some(outcome) = incomplete_scan(scan) {
463 return outcome;
464 }
465 }
466 let mut findings: Vec<PolicyFinding> = Vec::new();
467 for scan in &scans {
468 for finding in &scan.findings {
469 if let Some(reported) = match_finding(finding, scan.display_path.as_str()) {
470 findings.push(reported);
471 }
472 }
473 }
474 return PolicyOutcome::Findings(findings);
475}
476
477/// Rules,
478/// scan and redaction controls stay out of the release executable.
479#[cfg(test)]
480#[path = "policy_forbidden_strings_tests.rs"]
481mod tests;