syncable_cli/analyzer/hadolint/rules/
dl3013.rs1use crate::analyzer::hadolint::parser::instruction::Instruction;
7use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
8use crate::analyzer::hadolint::shell::ParsedShell;
9use crate::analyzer::hadolint::types::Severity;
10
11pub fn rule() -> SimpleRule<impl Fn(&Instruction, Option<&ParsedShell>) -> bool + Send + Sync> {
12 simple_rule(
13 "DL3013",
14 Severity::Warning,
15 "Pin versions in pip. Instead of `pip install <package>` use `pip install <package>==<version>` or `pip install --requirement <requirements file>`",
16 |instr, shell| {
17 match instr {
18 Instruction::Run(_) => {
19 if let Some(shell) = shell {
20 let packages = pip_packages(shell);
22 let uses_requirements = uses_requirements_file(shell);
24 uses_requirements || packages.iter().all(|pkg| is_pip_version_pinned(pkg))
26 } else {
27 true
28 }
29 }
30 _ => true,
31 }
32 },
33 )
34}
35
36fn pip_packages(shell: &ParsedShell) -> Vec<String> {
38 let mut packages = Vec::new();
39
40 for cmd in &shell.commands {
41 if cmd.is_pip_install() {
42 let skip_args = ["install", "pip", "-m"];
44 let args: Vec<&str> = cmd
45 .args_no_flags()
46 .into_iter()
47 .filter(|a| !skip_args.contains(a))
48 .collect();
49
50 packages.extend(args.into_iter().map(|s| s.to_string()));
51 }
52 }
53
54 packages
55}
56
57fn uses_requirements_file(shell: &ParsedShell) -> bool {
59 shell.any_command(|cmd| {
60 cmd.is_pip_install() && (cmd.has_any_flag(&["r", "requirement"]) || cmd.has_flag("constraint"))
61 })
62}
63
64fn is_pip_version_pinned(package: &str) -> bool {
66 if package.starts_with('-') {
68 return true;
69 }
70
71 if package.contains("://") || package.starts_with('/') || package.starts_with('.') {
73 return true;
74 }
75
76 package.contains("==")
78 || package.contains(">=")
79 || package.contains("<=")
80 || package.contains("!=")
81 || package.contains("~=")
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use crate::analyzer::hadolint::parser::instruction::RunArgs;
88 use crate::analyzer::hadolint::rules::{Rule, RuleState};
89
90 #[test]
91 fn test_pinned_version() {
92 let rule = rule();
93 let mut state = RuleState::new();
94
95 let instr = Instruction::Run(RunArgs::shell("pip install requests==2.28.0"));
96 let shell = ParsedShell::parse("pip install requests==2.28.0");
97 rule.check(&mut state, 1, &instr, Some(&shell));
98 assert!(state.failures.is_empty());
99 }
100
101 #[test]
102 fn test_unpinned_version() {
103 let rule = rule();
104 let mut state = RuleState::new();
105
106 let instr = Instruction::Run(RunArgs::shell("pip install requests"));
107 let shell = ParsedShell::parse("pip install requests");
108 rule.check(&mut state, 1, &instr, Some(&shell));
109 assert_eq!(state.failures.len(), 1);
110 assert_eq!(state.failures[0].code.as_str(), "DL3013");
111 }
112
113 #[test]
114 fn test_requirements_file() {
115 let rule = rule();
116 let mut state = RuleState::new();
117
118 let instr = Instruction::Run(RunArgs::shell("pip install -r requirements.txt"));
119 let shell = ParsedShell::parse("pip install -r requirements.txt");
120 rule.check(&mut state, 1, &instr, Some(&shell));
121 assert!(state.failures.is_empty());
122 }
123
124 #[test]
125 fn test_min_version() {
126 let rule = rule();
127 let mut state = RuleState::new();
128
129 let instr = Instruction::Run(RunArgs::shell("pip install requests>=2.28.0"));
130 let shell = ParsedShell::parse("pip install requests>=2.28.0");
131 rule.check(&mut state, 1, &instr, Some(&shell));
132 assert!(state.failures.is_empty());
133 }
134}