Skip to main content

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, ScannerFailure};
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::{RULES_VARIABLE, is_scannable, rules_candidate_paths, 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, 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;