syncable_cli/analyzer/hadolint/rules/
dl3013.rs

1//! DL3013: Pin versions in pip install
2//!
3//! Package versions should be pinned in pip install to ensure
4//! reproducible builds.
5
6use crate::analyzer::hadolint::parser::instruction::Instruction;
7use crate::analyzer::hadolint::rules::{SimpleRule, simple_rule};
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                        // Get pip install packages
21                        let packages = pip_packages(shell);
22                        // Check if using requirements file
23                        let uses_requirements = uses_requirements_file(shell);
24                        // All packages should have versions pinned or use requirements
25                        uses_requirements || packages.iter().all(|pkg| is_pip_version_pinned(pkg))
26                    } else {
27                        true
28                    }
29                }
30                _ => true,
31            }
32        },
33    )
34}
35
36/// Extract packages from pip install commands.
37fn 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            // Get arguments that aren't flags and aren't pip-related commands
43            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
57/// Check if pip uses a requirements file.
58fn uses_requirements_file(shell: &ParsedShell) -> bool {
59    shell.any_command(|cmd| {
60        cmd.is_pip_install()
61            && (cmd.has_any_flag(&["r", "requirement"]) || cmd.has_flag("constraint"))
62    })
63}
64
65/// Check if a pip package has a version pinned.
66fn is_pip_version_pinned(package: &str) -> bool {
67    // Skip if it starts with - (it's a flag)
68    if package.starts_with('-') {
69        return true;
70    }
71
72    // Skip if it looks like a URL or path
73    if package.contains("://") || package.starts_with('/') || package.starts_with('.') {
74        return true;
75    }
76
77    // Version pinned: package==version or package>=version, etc.
78    package.contains("==")
79        || package.contains(">=")
80        || package.contains("<=")
81        || package.contains("!=")
82        || package.contains("~=")
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::analyzer::hadolint::parser::instruction::RunArgs;
89    use crate::analyzer::hadolint::rules::{Rule, RuleState};
90
91    #[test]
92    fn test_pinned_version() {
93        let rule = rule();
94        let mut state = RuleState::new();
95
96        let instr = Instruction::Run(RunArgs::shell("pip install requests==2.28.0"));
97        let shell = ParsedShell::parse("pip install requests==2.28.0");
98        rule.check(&mut state, 1, &instr, Some(&shell));
99        assert!(state.failures.is_empty());
100    }
101
102    #[test]
103    fn test_unpinned_version() {
104        let rule = rule();
105        let mut state = RuleState::new();
106
107        let instr = Instruction::Run(RunArgs::shell("pip install requests"));
108        let shell = ParsedShell::parse("pip install requests");
109        rule.check(&mut state, 1, &instr, Some(&shell));
110        assert_eq!(state.failures.len(), 1);
111        assert_eq!(state.failures[0].code.as_str(), "DL3013");
112    }
113
114    #[test]
115    fn test_requirements_file() {
116        let rule = rule();
117        let mut state = RuleState::new();
118
119        let instr = Instruction::Run(RunArgs::shell("pip install -r requirements.txt"));
120        let shell = ParsedShell::parse("pip install -r requirements.txt");
121        rule.check(&mut state, 1, &instr, Some(&shell));
122        assert!(state.failures.is_empty());
123    }
124
125    #[test]
126    fn test_min_version() {
127        let rule = rule();
128        let mut state = RuleState::new();
129
130        let instr = Instruction::Run(RunArgs::shell("pip install requests>=2.28.0"));
131        let shell = ParsedShell::parse("pip install requests>=2.28.0");
132        rule.check(&mut state, 1, &instr, Some(&shell));
133        assert!(state.failures.is_empty());
134    }
135}