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;