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, ScopeFilter, Violation};
15
16#[cfg(unix)]
17use crate::io::read_prefix_n;
18
19#[derive(Debug)]
20// Fields are read only by the `#[cfg(unix)]` evaluate path; on
21// Windows the struct is constructed but never inspected.
22#[cfg_attr(not(unix), allow(dead_code))]
23pub struct ExecutableHasShebangRule {
24    id: String,
25    level: Level,
26    policy_url: Option<String>,
27    message: Option<String>,
28    scope: Scope,
29    scope_filter: Option<ScopeFilter>,
30}
31
32impl Rule for ExecutableHasShebangRule {
33    fn id(&self) -> &str {
34        &self.id
35    }
36    fn level(&self) -> Level {
37        self.level
38    }
39    fn policy_url(&self) -> Option<&str> {
40        self.policy_url.as_deref()
41    }
42
43    #[cfg(unix)]
44    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
45        use std::os::unix::fs::PermissionsExt;
46
47        let mut violations = Vec::new();
48        for entry in ctx.index.files() {
49            if !self.scope.matches(&entry.path) {
50                continue;
51            }
52            if let Some(filter) = &self.scope_filter
53                && !filter.matches(&entry.path, ctx.index)
54            {
55                continue;
56            }
57            let full = ctx.root.join(&entry.path);
58            let Ok(meta) = std::fs::metadata(&full) else {
59                continue;
60            };
61            if meta.permissions().mode() & 0o111 == 0 {
62                continue;
63            }
64            // Only the first 2 bytes (`#!`) matter — bounded
65            // read instead of the whole file. Non-`+x` files
66            // already short-circuited above; this read only
67            // happens on actual executables.
68            let Ok(bytes) = read_prefix_n(&full, 2) else {
69                continue;
70            };
71            if !bytes.starts_with(b"#!") {
72                let msg = self
73                    .message
74                    .clone()
75                    .unwrap_or_else(|| "executable file has no shebang (#!)".to_string());
76                violations.push(Violation::new(msg).with_path(entry.path.clone()));
77            }
78        }
79        Ok(violations)
80    }
81
82    #[cfg(not(unix))]
83    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
84        Ok(Vec::new())
85    }
86
87    fn scope_filter(&self) -> Option<&ScopeFilter> {
88        self.scope_filter.as_ref()
89    }
90}
91
92pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
93    let paths = spec.paths.as_ref().ok_or_else(|| {
94        Error::rule_config(&spec.id, "executable_has_shebang requires a `paths` field")
95    })?;
96    if spec.fix.is_some() {
97        return Err(Error::rule_config(
98            &spec.id,
99            "executable_has_shebang has no fix op — add a shebang or clear +x is a human call",
100        ));
101    }
102    Ok(Box::new(ExecutableHasShebangRule {
103        id: spec.id.clone(),
104        level: spec.level,
105        policy_url: spec.policy_url.clone(),
106        message: spec.message.clone(),
107        scope: Scope::from_paths_spec(paths)?,
108        scope_filter: spec.parse_scope_filter()?,
109    }))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::test_support::spec_yaml;
116    #[cfg(unix)]
117    use crate::test_support::{ctx, tempdir_with_files};
118
119    #[test]
120    fn build_rejects_missing_paths_field() {
121        let spec = spec_yaml(
122            "id: t\n\
123             kind: executable_has_shebang\n\
124             level: warning\n",
125        );
126        assert!(build(&spec).is_err());
127    }
128
129    #[test]
130    fn build_rejects_fix_block() {
131        let spec = spec_yaml(
132            "id: t\n\
133             kind: executable_has_shebang\n\
134             paths: \"scripts/**\"\n\
135             level: warning\n\
136             fix:\n  \
137               file_remove: {}\n",
138        );
139        assert!(build(&spec).is_err());
140    }
141
142    #[cfg(unix)]
143    #[test]
144    fn evaluate_fires_when_exec_lacks_shebang() {
145        use std::os::unix::fs::PermissionsExt;
146        let spec = spec_yaml(
147            "id: t\n\
148             kind: executable_has_shebang\n\
149             paths: \"scripts/**\"\n\
150             level: warning\n",
151        );
152        let rule = build(&spec).unwrap();
153        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"echo hi\n")]);
154        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
155            .unwrap()
156            .permissions();
157        perms.set_mode(0o755);
158        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
159        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
160        assert_eq!(v.len(), 1, "exec without shebang must fire");
161    }
162
163    #[cfg(unix)]
164    #[test]
165    fn evaluate_passes_when_exec_has_shebang() {
166        use std::os::unix::fs::PermissionsExt;
167        let spec = spec_yaml(
168            "id: t\n\
169             kind: executable_has_shebang\n\
170             paths: \"scripts/**\"\n\
171             level: warning\n",
172        );
173        let rule = build(&spec).unwrap();
174        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
175        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
176            .unwrap()
177            .permissions();
178        perms.set_mode(0o755);
179        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
180        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
181        assert!(v.is_empty(), "exec with shebang should pass: {v:?}");
182    }
183
184    #[cfg(unix)]
185    #[test]
186    fn evaluate_silent_on_non_exec_files() {
187        use std::os::unix::fs::PermissionsExt;
188        let spec = spec_yaml(
189            "id: t\n\
190             kind: executable_has_shebang\n\
191             paths: \"**/*\"\n\
192             level: warning\n",
193        );
194        let rule = build(&spec).unwrap();
195        let (tmp, idx) = tempdir_with_files(&[("README.md", b"# title\n")]);
196        let mut perms = std::fs::metadata(tmp.path().join("README.md"))
197            .unwrap()
198            .permissions();
199        perms.set_mode(0o644);
200        std::fs::set_permissions(tmp.path().join("README.md"), perms).unwrap();
201        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
202        assert!(v.is_empty(), "non-exec doesn't need shebang: {v:?}");
203    }
204
205    #[cfg(unix)]
206    #[test]
207    fn scope_filter_narrows() {
208        use std::os::unix::fs::PermissionsExt;
209        // Two +x scripts without shebang; only the one inside a
210        // dir with `marker.lock` as ancestor should fire.
211        let spec = spec_yaml(
212            "id: t\n\
213             kind: executable_has_shebang\n\
214             paths: \"**/*.sh\"\n\
215             scope_filter:\n  \
216               has_ancestor: marker.lock\n\
217             level: warning\n",
218        );
219        let rule = build(&spec).unwrap();
220        let (tmp, idx) = tempdir_with_files(&[
221            ("pkg/marker.lock", b""),
222            ("pkg/a.sh", b"echo hi\n"),
223            ("other/a.sh", b"echo hi\n"),
224        ]);
225        for rel in ["pkg/a.sh", "other/a.sh"] {
226            let mut perms = std::fs::metadata(tmp.path().join(rel))
227                .unwrap()
228                .permissions();
229            perms.set_mode(0o755);
230            std::fs::set_permissions(tmp.path().join(rel), perms).unwrap();
231        }
232        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
233        assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
234        assert_eq!(v[0].path.as_deref(), Some(std::path::Path::new("pkg/a.sh")));
235    }
236}