1use serde::{Deserialize, Serialize};
6
7use crate::pattern::GlobPattern;
8use crate::policy::PolicyError;
9
10const PROTECTED_WRITES: [&str; 84] = [
19 "**/.github/workflows/**",
20 "**/.gitlab-ci.yml",
21 "**/.circleci/**",
22 "**/azure-pipelines.yml",
23 "**/Jenkinsfile",
24 "**/.buildkite/**",
25 "**/.husky/**",
26 "**/.githooks/**",
27 "**/.pre-commit-config.yaml",
28 "**/lefthook.yml",
29 "**/.lefthook.yml",
30 "**/.vscode/**",
31 "**/package.json",
33 "**/.npmrc",
34 "**/.yarnrc",
35 "**/.yarnrc.yml",
36 "**/pnpm-workspace.yaml",
37 "**/Makefile",
39 "**/makefile",
40 "**/GNUmakefile",
41 "**/*.mk",
42 "**/justfile",
43 "**/Justfile",
44 "**/.justfile",
45 "**/Taskfile.yml",
46 "**/Taskfile.yaml",
47 "**/build.rs",
49 "**/Cargo.toml",
50 "**/.cargo/config",
51 "**/.cargo/config.toml",
52 "**/go.mod",
54 "**/go.work",
55 "**/CMakeLists.txt",
57 "**/*.cmake",
58 "**/CMakePresets.json",
59 "**/CMakeUserPresets.json",
60 "**/pom.xml",
62 "**/.mvn/**",
63 "**/mvnw",
64 "**/mvnw.cmd",
65 "**/*.gradle",
67 "**/*.gradle.kts",
68 "**/gradle.properties",
69 "**/gradlew",
70 "**/gradlew.bat",
71 "**/gradle/wrapper/**",
72 "**/*.csproj",
74 "**/*.fsproj",
75 "**/*.vbproj",
76 "**/*.props",
77 "**/*.targets",
78 "**/global.json",
79 "**/NuGet.config",
80 "**/nuget.config",
81 "**/Rakefile",
83 "**/*.rake",
84 "**/Gemfile",
85 "**/*.gemspec",
86 "**/.rspec",
87 "**/composer.json",
89 "**/artisan",
90 "**/phpunit.xml",
91 "**/phpunit.xml.dist",
92 "**/conftest.py",
94 "**/pytest.ini",
95 "**/pyproject.toml",
96 "**/setup.py",
97 "**/setup.cfg",
98 "**/tox.ini",
99 "**/noxfile.py",
100 "**/Dockerfile",
102 "**/docker-compose*.yml",
103 "**/docker-compose*.yaml",
104 "**/compose*.yml",
105 "**/compose*.yaml",
106 "**/CLAUDE.md",
108 "**/AGENTS.md",
109 "**/.claude/**",
110 "**/.claude.json",
111 "**/.codex/**",
112 "**/.cursor/**",
113 "**/.cursorrules",
114 "**/.mcp.json",
115 "**/.moat/**",
117];
118
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct TaintSettings {
123 #[serde(default)]
127 pub protected_writes: Vec<String>,
128}
129
130pub(crate) fn protected_writes(settings: Option<&TaintSettings>) -> impl Iterator<Item = &str> {
132 PROTECTED_WRITES.into_iter().chain(
133 settings
134 .into_iter()
135 .flat_map(|s| s.protected_writes.iter().map(String::as_str)),
136 )
137}
138
139pub(crate) fn check(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
142 let problem = |problem: String| PolicyError::Rule {
143 rule: "taint.protected_writes".to_owned(),
144 problem,
145 };
146 for p in settings.iter().flat_map(|s| &s.protected_writes) {
147 if p.trim_start().starts_with('!') {
148 return Err(problem(format!(
149 "`{p}` is an exclusion; the list can only add paths"
150 )));
151 }
152 GlobPattern::compile(p, false).map_err(|e| problem(e.to_string()))?;
153 }
154 Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159 use crate::policy::{Policy, PolicyError};
160
161 #[test]
162 fn protected_writes_must_be_plain_globs() {
163 let ok = "version: 1\ntaint:\n protected_writes: ['**/deploy/**', '${project}/run.sh']\n";
164 assert_eq!(
165 Policy::parse(ok)
166 .unwrap()
167 .taint
168 .unwrap()
169 .protected_writes
170 .len(),
171 2
172 );
173 assert!(Policy::parse("version: 1\ntaint: {}\n").is_ok());
174 for bad in ["'**/[x'", "'!**/package.json'", "''"] {
175 let yaml = format!("version: 1\ntaint:\n protected_writes: [{bad}]\n");
176 assert!(
177 matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "taint.protected_writes"),
178 "{bad} must be rejected"
179 );
180 }
181 assert!(Policy::parse("version: 1\ntaint: { bogus: [] }\n").is_err());
182 }
183
184 fn sample(pattern: &str) -> String {
187 let path = pattern
188 .replacen('~', "/home/u", 1)
189 .replacen("**/", "/home/u/work/repo/", 1);
190 let path = match path.strip_suffix("/**") {
191 Some(dir) => format!("{dir}/x"),
192 None => path,
193 };
194 path.replace('*', "v1")
195 }
196
197 #[test]
202 fn protected_writes_cover_runs_later_and_the_kernel_config_files() {
203 let policy = Policy::parse(crate::DEFAULT_POLICY).unwrap();
204 let protected: Vec<_> = super::PROTECTED_WRITES
205 .iter()
206 .map(|p| crate::pattern::GlobPattern::compile(p, false).unwrap())
207 .collect();
208 let rule = |groups: &[crate::RuleGroup], id: &str| {
209 groups.iter().find(|g| g.id == id).unwrap().fs_write.clone()
210 };
211 let mut files = rule(&policy.ask, "runs-later");
212 files.extend(
214 rule(&policy.deny, "kernel-self")
215 .into_iter()
216 .filter(|p| !p.contains("/bin/moat") && !p.contains("apps/moat")),
217 );
218 assert!(files.len() > 30, "{files:?}");
219 for pattern in &files {
220 let path = sample(pattern);
221 assert!(
222 protected.iter().any(|g| g.is_match(&path)),
223 "`{pattern}` ({path}) is not in PROTECTED_WRITES"
224 );
225 }
226 }
227}