1use std::collections::BTreeMap;
29use std::ops::Range;
30
31use serde::{Deserialize, Serialize};
32
33use crate::policy::PolicyError;
34
35mod backdoor;
36mod ci;
37mod files;
38mod hosts;
39mod keys;
40mod lockfile;
41mod manifest;
42
43pub use hosts::new_hosts;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Level {
49 Off,
51 Review,
53 Deny,
55}
56
57#[derive(Debug)]
59pub struct Rule {
60 pub id: &'static str,
62 pub level: Level,
65 pub summary: &'static str,
67}
68
69pub const PACKAGE_NEAR_MISS: &str = "diff-package-near-miss";
71pub const INSTALL_SCRIPT: &str = "diff-install-script";
73pub const PACKAGE_SOURCE: &str = "diff-package-source";
75pub const LOCK_INTEGRITY: &str = "diff-lock-integrity";
77pub const CI_PR_TARGET: &str = "diff-ci-pr-target";
79pub const CI_PERMISSIONS: &str = "diff-ci-permissions";
81pub const CI_UNPINNED: &str = "diff-ci-unpinned";
83pub const CI_INJECTION: &str = "diff-ci-injection";
85pub const CI_SECRET_OUT: &str = "diff-ci-secret-out";
87pub const PIPE_TO_SHELL: &str = "diff-pipe-to-shell";
89pub const DECODE_EXEC: &str = "diff-decode-exec";
91pub const PYTHON_STARTUP: &str = "diff-python-startup";
93pub const DOCKER_REMOTE_ADD: &str = "diff-docker-remote-add";
95pub const ENCODED_BLOB: &str = "diff-encoded-blob";
97pub const PRIVATE_KEY: &str = "diff-private-key";
99pub const ENV_SECRET: &str = "diff-env-secret";
101pub const NEW_HOST: &str = "diff-new-host";
103
104pub const RULES: &[Rule] = &[
106 Rule {
107 id: PACKAGE_NEAR_MISS,
108 level: Level::Review,
109 summary: "a new dependency whose name is one typo or trick away from a popular package's",
110 },
111 Rule {
112 id: INSTALL_SCRIPT,
113 level: Level::Review,
114 summary: "an npm preinstall, install or postinstall script added or changed",
115 },
116 Rule {
117 id: PACKAGE_SOURCE,
118 level: Level::Review,
119 summary: "a dependency or locked package fetched from git, plain http or a host that is not its registry",
120 },
121 Rule {
122 id: LOCK_INTEGRITY,
123 level: Level::Deny,
124 summary: "a lockfile integrity hash or checksum changed or removed while the version stays",
125 },
126 Rule {
127 id: CI_PR_TARGET,
128 level: Level::Deny,
129 summary: "pull_request_target with a checkout of the pull request's code (review: the trigger alone)",
130 },
131 Rule {
132 id: CI_PERMISSIONS,
133 level: Level::Review,
134 summary: "permissions: write-all or a write scope added to a workflow",
135 },
136 Rule {
137 id: CI_UNPINNED,
138 level: Level::Review,
139 summary: "uses: an action or image at a tag or branch instead of a full commit SHA or digest",
140 },
141 Rule {
142 id: CI_INJECTION,
143 level: Level::Deny,
144 summary: "an attacker-written event field expanded inside a script (review: other event fields and inputs)",
145 },
146 Rule {
147 id: CI_SECRET_OUT,
148 level: Level::Deny,
149 summary: "a secret printed into the log (review: handed to curl or wget)",
150 },
151 Rule {
152 id: PIPE_TO_SHELL,
153 level: Level::Review,
154 summary: "a downloaded script piped into a shell or interpreter",
155 },
156 Rule {
157 id: DECODE_EXEC,
158 level: Level::Deny,
159 summary: "base64, hex or compressed text decoded and run as code (review: in tests and fixtures)",
160 },
161 Rule {
162 id: PYTHON_STARTUP,
163 level: Level::Deny,
164 summary: "a .pth file with an import line, which every Python start runs (review: in tests and fixtures)",
165 },
166 Rule {
167 id: DOCKER_REMOTE_ADD,
168 level: Level::Review,
169 summary: "a Dockerfile ADD of a URL without --checksum",
170 },
171 Rule {
172 id: ENCODED_BLOB,
173 level: Level::Review,
174 summary: "a base64 blob of 400 or more characters in source code",
175 },
176 Rule {
177 id: PRIVATE_KEY,
178 level: Level::Deny,
179 summary: "a private key (review: in a test fixture, or marked as fake)",
180 },
181 Rule {
182 id: ENV_SECRET,
183 level: Level::Review,
184 summary: "a real-looking secret value in a .env file",
185 },
186 Rule {
187 id: NEW_HOST,
188 level: Level::Review,
189 summary: "a URL host in code or configuration that the policy's network allow list does not name",
190 },
191];
192
193#[must_use]
195pub fn rule(id: &str) -> Option<&'static Rule> {
196 RULES.iter().find(|r| r.id == id)
197}
198
199#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct Block {
202 pub at: usize,
204 pub removed: Vec<String>,
206 pub added: Range<usize>,
208}
209
210#[derive(Debug, Clone, Copy)]
212pub struct Change<'a> {
213 pub path: &'a str,
215 pub added: &'a [(usize, String)],
217 pub blocks: &'a [Block],
219}
220
221impl<'a> Change<'a> {
222 fn added_in(&self, block: &Block) -> &'a [(usize, String)] {
224 self.added.get(block.added.clone()).unwrap_or_default()
225 }
226
227 fn runs(&self) -> Vec<&'a [(usize, String)]> {
230 if self.blocks.is_empty() {
231 return vec![self.added];
232 }
233 self.blocks
234 .iter()
235 .map(|b| self.added_in(b))
236 .filter(|run| !run.is_empty())
237 .collect()
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Hit {
244 pub rule: &'static str,
246 pub level: Level,
248 pub line: Option<usize>,
250 pub reason: String,
252}
253
254impl Hit {
255 fn new(rule: &'static str, level: Level, line: usize, reason: String) -> Self {
256 Self {
257 rule,
258 level,
259 line: Some(line),
260 reason,
261 }
262 }
263}
264
265#[must_use]
267pub fn scan(change: &Change<'_>) -> Vec<Hit> {
268 let file = files::classify(change.path);
269 let mut hits = Vec::new();
270 if let Some(manifest) = file.manifest {
271 manifest::scan(manifest, change, &mut hits);
272 }
273 if let Some(lock) = file.lock {
274 lockfile::scan(lock, change, &mut hits);
275 }
276 if file.is(files::Kind::Workflow) {
277 ci::scan(change, &mut hits);
278 }
279 if !file.is(files::Kind::Doc) && file.lock.is_none() {
280 backdoor::scan(&file, change, &mut hits);
281 }
282 keys::scan(&file, change, &mut hits);
283 hits.sort_by_key(|h| h.line);
284 hits
285}
286
287#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct Settings {
298 #[serde(default)]
300 pub levels: BTreeMap<String, Level>,
301}
302
303impl Settings {
304 #[must_use]
306 pub fn level(settings: Option<&Self>, hit: &Hit) -> Level {
307 settings
308 .and_then(|s| s.levels.get(hit.rule))
309 .copied()
310 .unwrap_or(hit.level)
311 }
312
313 pub fn turned_off(&self) -> impl Iterator<Item = &str> {
315 self.levels
316 .iter()
317 .filter(|(_, l)| **l == Level::Off)
318 .map(|(id, _)| id.as_str())
319 }
320}
321
322pub(crate) fn check(settings: Option<&Settings>) -> Result<(), PolicyError> {
324 let Some(unknown) = settings
325 .into_iter()
326 .flat_map(|s| s.levels.keys())
327 .find(|id| rule(id).is_none())
328 else {
329 return Ok(());
330 };
331 let known: Vec<&str> = RULES.iter().map(|r| r.id).collect();
332 Err(PolicyError::Rule {
333 rule: format!("check_diff.levels.{unknown}"),
334 problem: format!("not a check-diff content rule; one of {}", known.join(", ")),
335 })
336}
337
338fn shown(text: &str) -> String {
340 let clean: String = text.trim().chars().filter(|c| !c.is_control()).collect();
341 match clean.char_indices().nth(80) {
342 Some((cut, _)) => format!("{}…", &clean[..cut]),
343 None => clean,
344 }
345}
346
347#[cfg(test)]
348mod tests;