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 immutable version type.
28use super::candidate_version::CandidateVersion;
29/// Import the validated options of this policy.
30use super::config_schema::{ForbiddenStringsOptions, PolicyConfig};
31/// Import the engine-failure codes.
32use super::diagnostics::EngineFailureCode;
33/// Import the event form of a pathname.
34use super::event_path::EventPath;
35/// Import the lifecycle's candidates.
36use super::policy_content::{ContentState, LifecycleContent};
37/// Import the finding and outcome types of a check.
38use super::policy_engine::{PolicyFinding, PolicyOutcome};
39/// Import the facts interface for the repository's top level.
40use super::repository_facts::RepositoryFacts;
41/// Import the repository location answer.
42use super::repository_location::RepositoryLocation;
43/// Import the scanner adapter and its failure.
44use super::scanner_adapter::{CandidateScanner, RulesSource, ScannerError};
45/// Import the code of each scanner and scan failure.
46use super::scanner_failure_code::{
47 finding_failure_code, scan_run_failure_code, scanner_failure_code,
48};
49/// Import the rules-file precedence and which candidates are scanned.
50use super::scanner_selection::{is_scannable, rules_candidate_path, rules_source};
51/// Import the worktree's top level from Git's answer.
52use super::worktree_identity::worktree_root;
53/// The scanner library's per-candidate result and per-match finding.
54use forbidden_strings::{CandidateScan, ScanFinding};
55/// What:
56/// `OsString` is owned operating-system text of raw OS bytes (sibling `String`
57/// must be UTF-8).
58/// Why:
59/// The variable's value is a path and need not be UTF-8.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// // string, but byte-preserving.
64/// ```
65use std::ffi::OsString;
66/// `PathBuf` is an owned filesystem path.
67use std::path::PathBuf;
68/// `Rc<T>` is a shared,
69/// read-only handle.
70use std::rc::Rc;
71
72/// The policy-local code of a match.
73pub const FORBIDDEN_STRING_CODE: &str = "forbidden-string";
74
75/// The engine-failure message of a scan a matcher could not complete.
76pub const ENGINE_ERROR_MESSAGE: &str = "The forbidden-strings scanner reported a matcher failure while scanning a candidate, so the scan is incomplete.";
77
78/// The engine-failure message of a pathname the line-based scanner cannot inspect.
79pub const LINE_BREAK_MESSAGE: &str = "The forbidden-strings scanner cannot inspect a candidate pathname that contains a line break, so the scan is incomplete.";
80
81/// What:
82/// Where the rules come from:
83/// the policy's options and the value of
84/// `FORBIDDEN_STRINGS_RULES` in the invocation's environment.
85/// Why:
86/// The lifecycle reads both once;
87/// the check resolves them against the repository
88/// root only when it scans.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// type ScannerSettings = { options: ForbiddenStringsOptions; rulesVariable?: string };
93/// ```
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct ScannerSettings {
96 /// The policy's validated options;
97 /// defaults until the configuration is loaded.
98 pub options: ForbiddenStringsOptions,
99 /// The value of `FORBIDDEN_STRINGS_RULES`,
100 /// when the variable is set.
101 pub rules_variable: Option<OsString>,
102}
103
104/// What:
105/// Settings with the default options and no variable.
106/// Why:
107/// A lifecycle that loads no configuration and has no candidates never scans.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const defaultScannerSettings = (): ScannerSettings => ({ options: defaults.forbiddenStrings });
112/// ```
113pub fn default_scanner_settings() -> ScannerSettings {
114 return ScannerSettings {
115 options: PolicyConfig::defaults().forbidden_strings,
116 rules_variable: None,
117 };
118}
119
120/// What:
121/// The scanner of one invocation,
122/// loaded on first use,
123/// or the remembered failure.
124/// Why:
125/// Rules are loaded once per invocation and only when a candidate needs scanning.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// class ScannerState { #loaded?: CandidateScanner | ScannerError }
130/// ```
131pub struct ScannerState {
132 /// The loaded scanner,
133 /// or the failure of the one attempt to load it.
134 loaded: Option<Result<CandidateScanner, ScannerError>>,
135}
136
137/// What:
138/// A state that has loaded nothing.
139/// Why:
140/// Loading waits until a candidate can be scanned.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// const unloadedScanner = (): ScannerState => new ScannerState();
145/// ```
146pub fn unloaded_scanner() -> ScannerState {
147 // `None` is the "absent" variant: nothing has been loaded.
148 return ScannerState { loaded: None };
149}
150
151/// What:
152/// The outcome of a check that could not finish.
153/// `&str` is borrowed message text.
154/// Why:
155/// Every failure of this policy names its cause through its code.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// const failed = (code, message): PolicyOutcome => ({ kind: 'failed', code, message });
160/// ```
161fn failed(code: EngineFailureCode, message: &str) -> PolicyOutcome {
162 return PolicyOutcome::Failed {
163 code,
164 message: String::from(message),
165 // `None`: a scan failure never names a pathname, which may hold a secret.
166 path: None,
167 };
168}
169
170/// What:
171/// The failure a scan result carries,
172/// or nothing when its findings are all matches.
173/// `&CandidateScan` borrows one candidate's result.
174/// Why:
175/// The scanner reports a failed matcher and an uninspectable pathname inside an
176/// otherwise successful result;
177/// either makes the whole scan incomplete,
178/// so it is
179/// found before any match is reported.
180///
181/// In TS you'd write (pseudocode):
182/// ```ts
183/// function incompleteScan(scan: CandidateScan): PolicyOutcome | undefined;
184/// ```
185fn incomplete_scan(scan: &CandidateScan) -> Option<PolicyOutcome> {
186 for finding in &scan.findings {
187 // `if let Some(code) = ...` runs only for a finding that is a failure.
188 if let Some(code) = finding_failure_code(finding) {
189 let message: &str = if *finding == ScanFinding::PathnameLineBreak {
190 LINE_BREAK_MESSAGE
191 } else {
192 ENGINE_ERROR_MESSAGE
193 };
194 return Some(failed(code, message));
195 }
196 }
197 return None;
198}
199
200/// What:
201/// The finding for one match in one scan,
202/// or nothing for a finding that is not a match.
203/// Why:
204/// The words,
205/// the code and the masked path are the installed wrapper's,
206/// so a match
207/// is reported the same way by both wrappers and never shows the matched text.
208///
209/// In TS you'd write (pseudocode):
210/// ```ts
211/// const toFinding = (hit, displayPath): PolicyFinding => ({ code: 'forbidden-string', message, path: displayPath });
212/// ```
213fn match_finding(finding: &ScanFinding, display_path: &str) -> Option<PolicyFinding> {
214 let message: String = match finding {
215 ScanFinding::Content { line, rule } => {
216 format!("Forbidden string matched at line {line} (rule {rule}).")
217 }
218 ScanFinding::Name { component, rule } => {
219 format!("Forbidden string matched in pathname segment {component} (rule {rule}).")
220 }
221 ScanFinding::EngineError | ScanFinding::PathnameLineBreak => return None,
222 };
223 return Some(PolicyFinding {
224 code: FORBIDDEN_STRING_CODE,
225 message,
226 // The scanner's masked display is text only: its bytes would undo the masking.
227 path: Some(EventPath::display(display_path)),
228 location: None,
229 fix_available: false,
230 });
231}
232
233/// What:
234/// `impl ScannerState { ... }` attaches the one way to get the loaded scanner.
235/// Why:
236/// Loading and its remembered failure stay behind one owner.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// class ScannerState { load(rules, builtinRules): CandidateScanner }
241/// ```
242impl ScannerState {
243 /// What:
244 /// The scanner,
245 /// loaded from `rules` on first use.
246 /// `Result<&CandidateScanner, ..>`
247 /// lends the loaded scanner,
248 /// or returns the outcome the policy reports.
249 /// Why:
250 /// A second call in the same invocation reuses the loaded rules,
251 /// and a failure
252 /// is not retried.
253 ///
254 /// In TS you'd write (pseudocode):
255 /// ```ts
256 /// load(rules: RulesSource, builtinRules: boolean): CandidateScanner; // throws PolicyOutcome
257 /// ```
258 fn load(
259 &mut self,
260 rules: &RulesSource,
261 builtin_rules: bool,
262 ) -> Result<&CandidateScanner, PolicyOutcome> {
263 if self.loaded.is_none() {
264 self.loaded = Some(CandidateScanner::load(rules, builtin_rules));
265 }
266 // `match` on the remembered attempt, lending the scanner when it loaded.
267 match &self.loaded {
268 Some(Ok(scanner)) => return Ok(scanner),
269 Some(Err(error)) => {
270 return Err(failed(
271 scanner_failure_code(error.failure),
272 error.message.as_str(),
273 ));
274 }
275 None => {
276 return Err(failed(
277 EngineFailureCode::PolicyIncomplete,
278 "cli-git did not load the forbidden-strings rules; this is a defect in cli-git.",
279 ));
280 }
281 }
282 }
283}
284
285/// What:
286/// The repository's top level,
287/// from Git's answer about where the command runs.
288/// Why:
289/// The rules file is named relative to it,
290/// as the scanner child's working
291/// directory was under the installed wrapper.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// async function repositoryRoot(facts): Promise<string>;
296/// ```
297fn repository_root<F: RepositoryFacts>(facts: &mut F) -> Result<PathBuf, PolicyOutcome> {
298 let location: RepositoryLocation = match facts.location() {
299 Ok(found) => found,
300 Err(message) => {
301 return Err(failed(
302 EngineFailureCode::ContentUnavailable,
303 message.as_str(),
304 ));
305 }
306 };
307 match worktree_root(&location.identity) {
308 Some(root) => return Ok(root.to_path_buf()),
309 None => {
310 return Err(failed(
311 EngineFailureCode::ContentUnavailable,
312 "cli-git could not find the repository's top level to resolve the forbidden-strings rules file.",
313 ));
314 }
315 }
316}
317
318/// What:
319/// Scan every candidate the scanner is given and report each match.
320/// `&ScannerSettings` borrows where the rules come from;
321/// `&mut ScannerState` lends
322/// the invocation's scanner.
323/// Why:
324/// Deleted candidates,
325/// the rule sources and the rules file itself are not scanned,
326/// as the installed wrapper skips them.
327/// When nothing is left,
328/// no rules are loaded,
329/// so a missing rules file stops nothing that would not have been scanned.
330///
331/// In TS you'd write (pseudocode):
332/// ```ts
333/// async function checkForbiddenStrings(content, lifecycle, facts, settings, scanner): Promise<PolicyOutcome>;
334/// ```
335pub fn check_forbidden_strings<F: RepositoryFacts>(
336 content: &mut ContentState,
337 lifecycle: &LifecycleContent,
338 facts: &mut F,
339 settings: &ScannerSettings,
340 scanner: &mut ScannerState,
341) -> PolicyOutcome {
342 let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
343 Ok(Some(found)) => found,
344 // `Vec::new()` is the empty list: a lifecycle without candidates has nothing to report.
345 Ok(None) => return PolicyOutcome::Findings(Vec::new()),
346 Err(outcome) => return outcome,
347 };
348 let root: PathBuf = match repository_root(facts) {
349 Ok(found) => found,
350 Err(outcome) => return outcome,
351 };
352 let rules: RulesSource = rules_source(
353 // `.as_deref()` lends the optional owned text as optional borrowed text.
354 settings.options.rules_file.as_deref(),
355 settings.rules_variable.as_deref(),
356 root.as_path(),
357 );
358 let rules_path: Option<Vec<u8>> = rules_candidate_path(rules.path.as_path(), root.as_path());
359 let mut any_scannable: bool = false;
360 for candidate in version.candidates() {
361 if is_scannable(candidate, rules_path.as_deref()) {
362 any_scannable = true;
363 break;
364 }
365 }
366 if !any_scannable {
367 return PolicyOutcome::Findings(Vec::new());
368 }
369 let loaded: &CandidateScanner = match scanner.load(&rules, settings.options.builtin_rules) {
370 Ok(found) => found,
371 Err(outcome) => return outcome,
372 };
373 let scans: Vec<CandidateScan> = match content.scan(loaded, rules_path.as_deref()) {
374 Ok(found) => found,
375 // `.to_string()` renders the wrapped failure's message, which names no pathname.
376 Err(error) => return failed(scan_run_failure_code(&error), error.to_string().as_str()),
377 };
378 // Every result is checked for a failure before any match is reported.
379 for scan in &scans {
380 if let Some(outcome) = incomplete_scan(scan) {
381 return outcome;
382 }
383 }
384 let mut findings: Vec<PolicyFinding> = Vec::new();
385 for scan in &scans {
386 for finding in &scan.findings {
387 if let Some(reported) = match_finding(finding, scan.display_path.as_str()) {
388 findings.push(reported);
389 }
390 }
391 }
392 return PolicyOutcome::Findings(findings);
393}
394
395/// Rules,
396/// scan and redaction controls stay out of the release executable.
397#[cfg(test)]
398#[path = "policy_forbidden_strings_tests.rs"]
399mod tests;