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