Skip to main content

alint_rules/
executable_has_shebang.rs

1//! `executable_has_shebang` — every `+x` file in scope must
2//! begin with a shebang line (`#!`).
3//!
4//! Catches the common bug where a script has been marked
5//! executable but its content is something else (a text file,
6//! a binary missing the shebang). Running such a file silently
7//! invokes the user's login shell — surprising at best, exploit
8//! vector at worst.
9//!
10//! Non-Unix platforms: rule is a no-op (no real +x semantics).
11//! No fix op — the correct resolution (add shebang vs. remove +x)
12//! is a human judgment call.
13
14use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
15
16#[derive(Debug)]
17// Fields are read only by the `#[cfg(unix)]` evaluate path; on
18// Windows the struct is constructed but never inspected.
19#[cfg_attr(not(unix), allow(dead_code))]
20pub struct ExecutableHasShebangRule {
21    id: String,
22    level: Level,
23    policy_url: Option<String>,
24    message: Option<String>,
25    scope: Scope,
26}
27
28impl Rule for ExecutableHasShebangRule {
29    fn id(&self) -> &str {
30        &self.id
31    }
32    fn level(&self) -> Level {
33        self.level
34    }
35    fn policy_url(&self) -> Option<&str> {
36        self.policy_url.as_deref()
37    }
38
39    #[cfg(unix)]
40    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
41        use std::os::unix::fs::PermissionsExt;
42
43        let mut violations = Vec::new();
44        for entry in ctx.index.files() {
45            if !self.scope.matches(&entry.path) {
46                continue;
47            }
48            let full = ctx.root.join(&entry.path);
49            let Ok(meta) = std::fs::metadata(&full) else {
50                continue;
51            };
52            if meta.permissions().mode() & 0o111 == 0 {
53                continue;
54            }
55            let Ok(bytes) = std::fs::read(&full) else {
56                continue;
57            };
58            if !bytes.starts_with(b"#!") {
59                let msg = self
60                    .message
61                    .clone()
62                    .unwrap_or_else(|| "executable file has no shebang (#!)".to_string());
63                violations.push(Violation::new(msg).with_path(entry.path.clone()));
64            }
65        }
66        Ok(violations)
67    }
68
69    #[cfg(not(unix))]
70    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
71        Ok(Vec::new())
72    }
73}
74
75pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
76    let paths = spec.paths.as_ref().ok_or_else(|| {
77        Error::rule_config(&spec.id, "executable_has_shebang requires a `paths` field")
78    })?;
79    if spec.fix.is_some() {
80        return Err(Error::rule_config(
81            &spec.id,
82            "executable_has_shebang has no fix op — add a shebang or clear +x is a human call",
83        ));
84    }
85    Ok(Box::new(ExecutableHasShebangRule {
86        id: spec.id.clone(),
87        level: spec.level,
88        policy_url: spec.policy_url.clone(),
89        message: spec.message.clone(),
90        scope: Scope::from_paths_spec(paths)?,
91    }))
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::test_support::spec_yaml;
98    #[cfg(unix)]
99    use crate::test_support::{ctx, tempdir_with_files};
100
101    #[test]
102    fn build_rejects_missing_paths_field() {
103        let spec = spec_yaml(
104            "id: t\n\
105             kind: executable_has_shebang\n\
106             level: warning\n",
107        );
108        assert!(build(&spec).is_err());
109    }
110
111    #[test]
112    fn build_rejects_fix_block() {
113        let spec = spec_yaml(
114            "id: t\n\
115             kind: executable_has_shebang\n\
116             paths: \"scripts/**\"\n\
117             level: warning\n\
118             fix:\n  \
119               file_remove: {}\n",
120        );
121        assert!(build(&spec).is_err());
122    }
123
124    #[cfg(unix)]
125    #[test]
126    fn evaluate_fires_when_exec_lacks_shebang() {
127        use std::os::unix::fs::PermissionsExt;
128        let spec = spec_yaml(
129            "id: t\n\
130             kind: executable_has_shebang\n\
131             paths: \"scripts/**\"\n\
132             level: warning\n",
133        );
134        let rule = build(&spec).unwrap();
135        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"echo hi\n")]);
136        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
137            .unwrap()
138            .permissions();
139        perms.set_mode(0o755);
140        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
141        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
142        assert_eq!(v.len(), 1, "exec without shebang must fire");
143    }
144
145    #[cfg(unix)]
146    #[test]
147    fn evaluate_passes_when_exec_has_shebang() {
148        use std::os::unix::fs::PermissionsExt;
149        let spec = spec_yaml(
150            "id: t\n\
151             kind: executable_has_shebang\n\
152             paths: \"scripts/**\"\n\
153             level: warning\n",
154        );
155        let rule = build(&spec).unwrap();
156        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
157        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
158            .unwrap()
159            .permissions();
160        perms.set_mode(0o755);
161        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
162        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
163        assert!(v.is_empty(), "exec with shebang should pass: {v:?}");
164    }
165
166    #[cfg(unix)]
167    #[test]
168    fn evaluate_silent_on_non_exec_files() {
169        use std::os::unix::fs::PermissionsExt;
170        let spec = spec_yaml(
171            "id: t\n\
172             kind: executable_has_shebang\n\
173             paths: \"**/*\"\n\
174             level: warning\n",
175        );
176        let rule = build(&spec).unwrap();
177        let (tmp, idx) = tempdir_with_files(&[("README.md", b"# title\n")]);
178        let mut perms = std::fs::metadata(tmp.path().join("README.md"))
179            .unwrap()
180            .permissions();
181        perms.set_mode(0o644);
182        std::fs::set_permissions(tmp.path().join("README.md"), perms).unwrap();
183        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
184        assert!(v.is_empty(), "non-exec doesn't need shebang: {v:?}");
185    }
186}