Skip to main content

remem/rules/
evaluator.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5mod bash_ast;
6
7use crate::rules::artifact::{
8    CompiledRule, CompiledRulesArtifact, RuleAction, RulePredicate, LEGACY_ARTIFACT_VERSION,
9};
10use crate::rules::store::{load_artifact_fail_open, ArtifactLoad, ArtifactLoadErrorKind};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct EvaluationInput {
14    pub command: String,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct EvaluationOutcome {
19    pub verdict: EvaluationVerdict,
20    pub matches: Vec<RuleMatch>,
21    pub diagnostics: Vec<EvaluationDiagnostic>,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum EvaluationVerdict {
26    Allow,
27    Warn,
28    Block,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RuleMatch {
33    pub rule_id: String,
34    pub source_memory_id: i64,
35    pub action: RuleAction,
36    pub message: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct EvaluationDiagnostic {
41    pub status: EvaluationDiagnosticStatus,
42    pub message: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) struct CodedEvaluationOutcome {
47    pub outcome: EvaluationOutcome,
48    pub diagnostic_codes: Vec<EvaluationDiagnosticCode>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub(crate) enum EvaluationDiagnosticCode {
54    ArtifactMissing,
55    ArtifactRead,
56    ArtifactParse,
57    ArtifactValidate,
58    RuleEvaluation,
59    HookInputRead,
60    Config,
61    HookInput,
62    OutputSerialize,
63}
64
65impl EvaluationDiagnosticCode {
66    pub(crate) fn as_str(self) -> &'static str {
67        match self {
68            Self::ArtifactMissing => "artifact_missing",
69            Self::ArtifactRead => "artifact_read",
70            Self::ArtifactParse => "artifact_parse",
71            Self::ArtifactValidate => "artifact_validate",
72            Self::RuleEvaluation => "rule_evaluation",
73            Self::HookInputRead => "hook_input_read",
74            Self::Config => "config",
75            Self::HookInput => "hook_input",
76            Self::OutputSerialize => "output_serialize",
77        }
78    }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum EvaluationDiagnosticStatus {
83    Error,
84}
85
86pub fn evaluate_artifact(
87    artifact: &CompiledRulesArtifact,
88    input: &EvaluationInput,
89) -> EvaluationOutcome {
90    evaluate_artifact_with_codes(artifact, input).outcome
91}
92
93fn evaluate_artifact_with_codes(
94    artifact: &CompiledRulesArtifact,
95    input: &EvaluationInput,
96) -> CodedEvaluationOutcome {
97    let mut matches = Vec::new();
98    let mut diagnostics = Vec::new();
99    let mut diagnostic_codes = Vec::new();
100
101    for rule in &artifact.rules {
102        if rule.override_state.disabled {
103            continue;
104        }
105        match rule_matches(artifact.version, rule, input) {
106            Ok(true) => matches.push(RuleMatch {
107                rule_id: rule.rule_id.clone(),
108                source_memory_id: rule.source_memory_id,
109                action: rule.effective_action(),
110                message: rule.predicate.message().to_string(),
111            }),
112            Ok(false) => {}
113            Err(message) => {
114                diagnostics.push(EvaluationDiagnostic {
115                    status: EvaluationDiagnosticStatus::Error,
116                    message,
117                });
118                diagnostic_codes.push(EvaluationDiagnosticCode::RuleEvaluation);
119            }
120        }
121    }
122
123    if !diagnostics.is_empty() {
124        return CodedEvaluationOutcome {
125            outcome: EvaluationOutcome {
126                verdict: EvaluationVerdict::Allow,
127                matches: Vec::new(),
128                diagnostics,
129            },
130            diagnostic_codes,
131        };
132    }
133
134    CodedEvaluationOutcome {
135        outcome: EvaluationOutcome {
136            verdict: verdict_for_matches(&matches),
137            matches,
138            diagnostics,
139        },
140        diagnostic_codes,
141    }
142}
143
144pub fn evaluate_artifact_file(
145    path: impl AsRef<Path>,
146    input: &EvaluationInput,
147) -> EvaluationOutcome {
148    evaluate_artifact_file_with_codes(path, input).outcome
149}
150
151pub(crate) fn evaluate_artifact_file_with_codes(
152    path: impl AsRef<Path>,
153    input: &EvaluationInput,
154) -> CodedEvaluationOutcome {
155    match load_artifact_fail_open(path) {
156        ArtifactLoad::Loaded(artifact) => evaluate_artifact_with_codes(&artifact, input),
157        ArtifactLoad::FailOpen { kind, message } => CodedEvaluationOutcome {
158            outcome: EvaluationOutcome {
159                verdict: EvaluationVerdict::Allow,
160                matches: Vec::new(),
161                diagnostics: vec![EvaluationDiagnostic {
162                    status: EvaluationDiagnosticStatus::Error,
163                    message,
164                }],
165            },
166            diagnostic_codes: vec![diagnostic_code_for_artifact_error(kind)],
167        },
168    }
169}
170
171fn diagnostic_code_for_artifact_error(kind: ArtifactLoadErrorKind) -> EvaluationDiagnosticCode {
172    match kind {
173        ArtifactLoadErrorKind::Missing => EvaluationDiagnosticCode::ArtifactMissing,
174        ArtifactLoadErrorKind::Read => EvaluationDiagnosticCode::ArtifactRead,
175        ArtifactLoadErrorKind::Parse => EvaluationDiagnosticCode::ArtifactParse,
176        ArtifactLoadErrorKind::Validate => EvaluationDiagnosticCode::ArtifactValidate,
177    }
178}
179
180fn rule_matches(
181    artifact_version: u32,
182    rule: &CompiledRule,
183    input: &EvaluationInput,
184) -> Result<bool, String> {
185    match &rule.predicate {
186        RulePredicate::CommandRegex { pattern, .. } => {
187            if artifact_version == LEGACY_ARTIFACT_VERSION {
188                regex::Regex::new(pattern)
189                    .map(|regex| regex.is_match(&input.command))
190                    .map_err(|err| format!("rule {} has invalid regex: {err}", rule.rule_id))
191            } else {
192                regex_lite::Regex::new(pattern)
193                    .map(|regex| regex.is_match(&input.command))
194                    .map_err(|err| format!("rule {} has invalid regex: {err}", rule.rule_id))
195            }
196        }
197        RulePredicate::CommitTrailerForbidden { trailer, .. } => {
198            command_adds_forbidden_commit_trailer(&input.command, trailer)
199                .map_err(|err| format!("rule {} could not parse command: {err}", rule.rule_id))
200        }
201        RulePredicate::GitPushForceForbidden { .. } => command_forces_git_push(&input.command)
202            .map_err(|err| format!("rule {} could not parse command: {err}", rule.rule_id)),
203    }
204}
205
206fn command_adds_forbidden_commit_trailer(command: &str, trailer: &str) -> Result<bool, String> {
207    let segments = shell_command_segments(command)?;
208    Ok(segments
209        .iter()
210        .any(|tokens| git_commit_segment_adds_trailer(tokens, trailer)))
211}
212
213fn git_commit_segment_adds_trailer(tokens: &[String], trailer: &str) -> bool {
214    git_subcommand_args(tokens, "commit").is_some_and(|args| commit_args_add_trailer(args, trailer))
215}
216
217fn command_forces_git_push(command: &str) -> Result<bool, String> {
218    let segments = shell_command_segments(command)?;
219    Ok(segments.iter().any(|tokens| {
220        git_subcommand_args(tokens, "push").is_some_and(git_push_args_force)
221            || git_alias_forces_push(tokens)
222    }))
223}
224
225fn git_subcommand_args<'a>(tokens: &'a [String], expected: &str) -> Option<&'a [String]> {
226    let index = git_subcommand_index(tokens)?;
227    (tokens.get(index)? == expected).then_some(&tokens[index + 1..])
228}
229
230fn git_subcommand_index(tokens: &[String]) -> Option<usize> {
231    let command_index = bash_ast::unwrap::effective_command_index(tokens)?;
232    if !is_git_executable(bash_ast::unwrap::semantic_token(tokens.get(command_index)?)) {
233        return None;
234    }
235    let mut index = command_index;
236    index += 1;
237
238    while let Some(token) = tokens.get(index) {
239        match token.as_str() {
240            "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--work-tree"
241            | "--namespace" | "--super-prefix" => {
242                index += 2;
243            }
244            "-p"
245            | "--paginate"
246            | "-P"
247            | "--no-pager"
248            | "--bare"
249            | "--no-replace-objects"
250            | "--literal-pathspecs"
251            | "--glob-pathspecs"
252            | "--noglob-pathspecs"
253            | "--icase-pathspecs"
254            | "--no-optional-locks" => {
255                index += 1;
256            }
257            value
258                if value.starts_with("-C")
259                    || value.starts_with("-c")
260                    || value.starts_with("--exec-path=")
261                    || value.starts_with("--config-env=")
262                    || value.starts_with("--git-dir=")
263                    || value.starts_with("--work-tree=")
264                    || value.starts_with("--namespace=")
265                    || value.starts_with("--super-prefix=") =>
266            {
267                index += 1;
268            }
269            _ => return Some(index),
270        }
271    }
272
273    None
274}
275
276fn git_alias_forces_push(tokens: &[String]) -> bool {
277    let Some(subcommand_index) = git_subcommand_index(tokens) else {
278        return false;
279    };
280    let Some(alias_name) = tokens.get(subcommand_index) else {
281        return false;
282    };
283    let Some(payload) = git_config_alias_payload(tokens, subcommand_index, alias_name) else {
284        return false;
285    };
286    let shell_alias = payload.starts_with('!');
287    let payload = payload.strip_prefix('!').unwrap_or(payload);
288    if shell_alias {
289        let Ok(segments) = shell_command_segments(payload) else {
290            return false;
291        };
292        return segments.into_iter().any(|mut segment| {
293            segment.extend_from_slice(&tokens[subcommand_index + 1..]);
294            git_subcommand_args(&segment, "push").is_some_and(git_push_args_force)
295        });
296    }
297
298    let Some(mut alias_args) = split_git_alias(payload) else {
299        return false;
300    };
301    alias_args.extend_from_slice(&tokens[subcommand_index + 1..]);
302    alias_args
303        .strip_prefix(&["push".to_string()])
304        .is_some_and(git_push_args_force)
305}
306
307fn split_git_alias(payload: &str) -> Option<Vec<String>> {
308    let mut arguments = Vec::new();
309    let mut argument = String::new();
310    let mut quoted = None;
311    let mut escaped = false;
312    let mut started = false;
313
314    for ch in payload.chars() {
315        if escaped {
316            argument.push(ch);
317            escaped = false;
318            started = true;
319            continue;
320        }
321        if ch == '\\' && quoted != Some('\'') {
322            escaped = true;
323            started = true;
324            continue;
325        }
326        if matches!(ch, '\'' | '\"') {
327            if quoted == Some(ch) {
328                quoted = None;
329            } else if quoted.is_none() {
330                quoted = Some(ch);
331            } else {
332                argument.push(ch);
333            }
334            started = true;
335            continue;
336        }
337        if quoted.is_none() && ch.is_ascii_whitespace() {
338            if started {
339                arguments.push(std::mem::take(&mut argument));
340                started = false;
341            }
342            continue;
343        }
344        argument.push(ch);
345        started = true;
346    }
347
348    if escaped || quoted.is_some() {
349        return None;
350    }
351    if started {
352        arguments.push(argument);
353    }
354    Some(arguments)
355}
356
357fn git_config_alias_payload<'a>(
358    tokens: &'a [String],
359    subcommand_index: usize,
360    alias_name: &str,
361) -> Option<&'a str> {
362    let command_index = bash_ast::unwrap::effective_command_index(tokens)?;
363    let mut index = command_index + 1;
364    let mut payload = None;
365    while index < subcommand_index {
366        let token = tokens.get(index)?;
367        let assignment = if token == "-c" {
368            index += 2;
369            tokens.get(index - 1)?.as_str()
370        } else if let Some(assignment) = token.strip_prefix("-c") {
371            index += 1;
372            assignment
373        } else {
374            index += 1;
375            continue;
376        };
377        if let Some((key, value)) = assignment.split_once('=') {
378            if let Some((section, name)) = key.split_once('.') {
379                if section.eq_ignore_ascii_case("alias") && name.eq_ignore_ascii_case(alias_name) {
380                    payload = Some(value);
381                }
382            }
383        }
384    }
385    payload
386}
387
388fn is_git_executable(command: &str) -> bool {
389    let basename = command.rsplit(['/', '\\']).next().unwrap_or(command);
390    basename == "git" || basename.eq_ignore_ascii_case("git.exe")
391}
392
393fn git_push_args_force(args: &[String]) -> bool {
394    let mut index = 0;
395    let mut repository_supplied = false;
396    let mut options_terminated = false;
397    let mut force_enabled = false;
398    let mut mirror_enabled = false;
399    let mut delete_enabled = false;
400    while let Some(arg) = args.get(index) {
401        if !options_terminated && arg == "--" {
402            options_terminated = true;
403            index += 1;
404            continue;
405        }
406        if !options_terminated && arg == "--force" {
407            force_enabled = true;
408            index += 1;
409            continue;
410        }
411        if !options_terminated && arg == "--no-force" {
412            force_enabled = false;
413            index += 1;
414            continue;
415        }
416        if !options_terminated {
417            if let Some(enabled) = mirror_option_state(arg) {
418                mirror_enabled = enabled;
419                index += 1;
420                continue;
421            }
422            if let Some(enabled) = delete_option_state(arg) {
423                delete_enabled = enabled;
424                index += 1;
425                continue;
426            }
427        }
428        if !options_terminated && (arg == "--repo" || arg.starts_with("--repo=")) {
429            repository_supplied = arg.starts_with("--repo=") || args.get(index + 1).is_some();
430            index += if arg == "--repo" { 2 } else { 1 };
431            continue;
432        }
433        if !options_terminated {
434            if git_push_short_option_enables_delete(arg) {
435                delete_enabled = true;
436            }
437            match git_push_short_option_effect(arg) {
438                PushShortOptionEffect::Forces => {
439                    force_enabled = true;
440                    index += 1;
441                    continue;
442                }
443                PushShortOptionEffect::ConsumesNext => {
444                    index += 2;
445                    continue;
446                }
447                PushShortOptionEffect::Other => {}
448            }
449            if arg.starts_with('-') {
450                index += if git_push_long_option_consumes_next(arg) {
451                    2
452                } else {
453                    1
454                };
455                continue;
456            }
457        }
458        if repository_supplied && !delete_enabled && is_force_push_refspec(arg) {
459            return true;
460        }
461        repository_supplied = true;
462        index += 1;
463    }
464    force_enabled || mirror_enabled
465}
466
467fn git_push_short_option_enables_delete(arg: &str) -> bool {
468    let Some(cluster) = arg
469        .strip_prefix('-')
470        .filter(|value| !value.starts_with('-'))
471    else {
472        return false;
473    };
474    for option in cluster.chars() {
475        match option {
476            'd' => return true,
477            'o' => return false,
478            _ => {}
479        }
480    }
481    false
482}
483
484fn delete_option_state(arg: &str) -> Option<bool> {
485    if let Some(prefix) = arg.strip_prefix("--no-") {
486        return (!prefix.is_empty() && "delete".starts_with(prefix)).then_some(false);
487    }
488    let prefix = arg.strip_prefix("--")?;
489    (!prefix.is_empty() && "delete".starts_with(prefix)).then_some(true)
490}
491
492pub(super) fn git_push_arg_changes_force_state(arg: &str) -> bool {
493    matches!(arg, "--force" | "--no-force")
494        || mirror_option_state(arg).is_some()
495        || git_push_short_option_effect(arg) == PushShortOptionEffect::Forces
496        || is_force_push_refspec(arg)
497}
498
499fn mirror_option_state(arg: &str) -> Option<bool> {
500    if let Some(prefix) = arg.strip_prefix("--no-") {
501        return (!prefix.is_empty() && "mirror".starts_with(prefix)).then_some(false);
502    }
503    let prefix = arg.strip_prefix("--")?;
504    (!prefix.is_empty() && "mirror".starts_with(prefix)).then_some(true)
505}
506
507fn is_force_push_refspec(arg: &str) -> bool {
508    let Some(refspec) = arg.strip_prefix('+') else {
509        return false;
510    };
511    let source = refspec
512        .split_once(':')
513        .map_or(refspec, |(source, _)| source);
514    !source.is_empty() && !source.starts_with('+')
515}
516
517#[derive(Clone, Copy, PartialEq, Eq)]
518enum PushShortOptionEffect {
519    Forces,
520    ConsumesNext,
521    Other,
522}
523
524fn git_push_short_option_effect(arg: &str) -> PushShortOptionEffect {
525    let Some(cluster) = arg
526        .strip_prefix('-')
527        .filter(|value| !value.starts_with('-'))
528    else {
529        return PushShortOptionEffect::Other;
530    };
531    let chars = cluster.chars().collect::<Vec<_>>();
532    for (index, option) in chars.iter().enumerate() {
533        match option {
534            'f' => return PushShortOptionEffect::Forces,
535            'o' if index + 1 == chars.len() => return PushShortOptionEffect::ConsumesNext,
536            'o' => return PushShortOptionEffect::Other,
537            _ => {}
538        }
539    }
540    PushShortOptionEffect::Other
541}
542
543fn git_push_long_option_consumes_next(arg: &str) -> bool {
544    matches!(
545        arg,
546        "--push-option" | "--receive-pack" | "--exec" | "--repo" | "--recurse-submodules"
547    )
548}
549
550fn commit_args_add_trailer(args: &[String], trailer: &str) -> bool {
551    let mut index = 0;
552    while let Some(arg) = args.get(index) {
553        if arg == "--" {
554            return false;
555        }
556        if commit_option_consumes_next(arg) {
557            index += 2;
558            continue;
559        }
560        if arg == "--trailer" {
561            if args
562                .get(index + 1)
563                .is_some_and(|value| trailer_arg_matches(value, trailer))
564            {
565                return true;
566            }
567            index += 2;
568            continue;
569        }
570        if let Some(value) = arg.strip_prefix("--trailer=") {
571            if trailer_arg_matches(value, trailer) {
572                return true;
573            }
574        }
575        index += 1;
576    }
577
578    false
579}
580
581fn commit_option_consumes_next(arg: &str) -> bool {
582    if matches!(
583        arg,
584        "-m" | "-F"
585            | "-C"
586            | "-c"
587            | "--message"
588            | "--file"
589            | "--reuse-message"
590            | "--reedit-message"
591            | "--author"
592            | "--date"
593            | "--cleanup"
594            | "--template"
595            | "--fixup"
596            | "--squash"
597            | "--pathspec-from-file"
598    ) {
599        return true;
600    }
601    if arg.starts_with("--") {
602        return false;
603    }
604
605    arg.len() > 2
606        && arg
607            .chars()
608            .last()
609            .is_some_and(|ch| matches!(ch, 'm' | 'F' | 'C' | 'c'))
610}
611
612fn trailer_arg_matches(value: &str, trailer: &str) -> bool {
613    let trimmed = value.trim_start();
614    if trimmed == trailer {
615        return true;
616    }
617    let Some(rest) = trimmed.strip_prefix(trailer) else {
618        return false;
619    };
620    rest.chars()
621        .next()
622        .is_some_and(|ch| matches!(ch, '=' | ':'))
623}
624
625fn shell_command_segments(command: &str) -> Result<Vec<Vec<String>>, String> {
626    bash_ast::command_segments(command)
627}
628
629fn verdict_for_matches(matches: &[RuleMatch]) -> EvaluationVerdict {
630    if matches
631        .iter()
632        .any(|rule_match| rule_match.action == RuleAction::Block)
633    {
634        EvaluationVerdict::Block
635    } else if matches.is_empty() {
636        EvaluationVerdict::Allow
637    } else {
638        EvaluationVerdict::Warn
639    }
640}
641
642#[cfg(test)]
643mod tests;