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};
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;