Skip to main content

openmoat_core/
diffscan.rs

1//! What the added lines of a change do (ADR-055): the content checks of
2//! `openmoat policy check-diff`, on top of its path checks (ADR-050).
3//!
4//! Each check is a pure function over one changed file: its path, the lines
5//! it adds with their numbers in the new file, and the blocks of lines it
6//! removes and adds together ([`Block`]). Nothing is read from disk or the
7//! network: the same change gives the same findings on every machine.
8//!
9//! - Supply chain (`manifest.rs`, `lockfile.rs`): a new dependency that is a
10//!   near miss of a popular name (the bundled lists of `packages.rs`,
11//!   the ones the shell classifier uses), an npm install script, a dependency
12//!   or a locked package fetched from outside its registry, and a lockfile
13//!   hash that changes while the version stays.
14//! - CI takeover (`ci.rs`): GitHub workflow and action files.
15//! - Backdoors (`backdoor.rs`): a downloaded or decoded script that runs, a
16//!   Python start-up file, a Dockerfile `ADD` from a URL, a long encoded blob
17//!   in source code.
18//! - Secrets (`keys.rs`): a private key, a real-looking value in a `.env`
19//!   file. Known token formats are the push check's scanner, which the CLI
20//!   already runs on every added line (`diff-secret`).
21//! - New network endpoints (`hosts.rs`): one finding per host the change adds
22//!   that the policy's network allow list does not name; the caller decides
23//!   that with the compiled policy ([`new_hosts`]).
24//!
25//! Every rule is in [`RULES`], with its default level; the policy's
26//! `check_diff.levels` ([`Settings`]) sets another one, or `off`.
27
28use 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/// How much a content finding holds up a merge.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Level {
49    /// Not reported: only `check_diff.levels` sets this.
50    Off,
51    /// A person should look before it is merged.
52    Review,
53    /// Fails the check: what a change should never do unnoticed.
54    Deny,
55}
56
57/// One content check: its stable id, the level it reports at, and what it finds.
58#[derive(Debug)]
59pub struct Rule {
60    /// The id findings carry (`diff-ci-unpinned`); stable, like the policy's rule ids.
61    pub id: &'static str,
62    /// The level of its findings when the policy does not set one. A rule
63    /// whose findings differ (a key in a test fixture) gives the stricter one here.
64    pub level: Level,
65    /// What it looks for, for docs and `--format json` readers.
66    pub summary: &'static str,
67}
68
69/// A dependency name one typo or trick away from a popular package's.
70pub const PACKAGE_NEAR_MISS: &str = "diff-package-near-miss";
71/// An npm `preinstall`, `install` or `postinstall` script.
72pub const INSTALL_SCRIPT: &str = "diff-install-script";
73/// A dependency or a locked package fetched from git, plain http or another host.
74pub const PACKAGE_SOURCE: &str = "diff-package-source";
75/// A lockfile hash changed or removed while the package's version stays.
76pub const LOCK_INTEGRITY: &str = "diff-lock-integrity";
77/// `pull_request_target`, and with it a checkout of the pull request's code.
78pub const CI_PR_TARGET: &str = "diff-ci-pr-target";
79/// `permissions: write-all` or a write scope.
80pub const CI_PERMISSIONS: &str = "diff-ci-permissions";
81/// `uses:` an action at a tag or branch, not a full commit SHA.
82pub const CI_UNPINNED: &str = "diff-ci-unpinned";
83/// `${{ github.event.* }}`, `${{ inputs.* }}` or `${{ github.head_ref }}` inside a script.
84pub const CI_INJECTION: &str = "diff-ci-injection";
85/// A secret printed into the log or handed to `curl`/`wget` in a script.
86pub const CI_SECRET_OUT: &str = "diff-ci-secret-out";
87/// A downloaded script piped into a shell or an interpreter.
88pub const PIPE_TO_SHELL: &str = "diff-pipe-to-shell";
89/// Decoded text (base64, hex, compressed) run as code.
90pub const DECODE_EXEC: &str = "diff-decode-exec";
91/// A `.pth` file with an `import` line, which every Python start runs.
92pub const PYTHON_STARTUP: &str = "diff-python-startup";
93/// A Dockerfile `ADD` of a URL without `--checksum`.
94pub const DOCKER_REMOTE_ADD: &str = "diff-docker-remote-add";
95/// A long base64 blob in source code.
96pub const ENCODED_BLOB: &str = "diff-encoded-blob";
97/// A private key (PEM or `PuTTY`).
98pub const PRIVATE_KEY: &str = "diff-private-key";
99/// A real-looking secret value in a `.env` file.
100pub const ENV_SECRET: &str = "diff-env-secret";
101/// A host the policy's network allow list does not name.
102pub const NEW_HOST: &str = "diff-new-host";
103
104/// Every content check, in the order the docs list them.
105pub 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/// The rule `id` names, if it is a content check.
194#[must_use]
195pub fn rule(id: &str) -> Option<&'static Rule> {
196    RULES.iter().find(|r| r.id == id)
197}
198
199/// One run of removed and added lines with no kept line between them.
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct Block {
202    /// The new file's line number where the block sits.
203    pub at: usize,
204    /// The removed lines, in order.
205    pub removed: Vec<String>,
206    /// Indices into [`Change::added`] of the lines the block adds.
207    pub added: Range<usize>,
208}
209
210/// One file a change writes, as the checks see it.
211#[derive(Debug, Clone, Copy)]
212pub struct Change<'a> {
213    /// The path in the repository, `/`-separated.
214    pub path: &'a str,
215    /// Each added line with its number in the new file.
216    pub added: &'a [(usize, String)],
217    /// The blocks of the change; a file with none is checked by its added lines alone.
218    pub blocks: &'a [Block],
219}
220
221impl<'a> Change<'a> {
222    /// The lines `block` adds.
223    fn added_in(&self, block: &Block) -> &'a [(usize, String)] {
224        self.added.get(block.added.clone()).unwrap_or_default()
225    }
226
227    /// The added lines block by block, or all of them as one run when the
228    /// change has no blocks.
229    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/// One thing a check found.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Hit {
244    /// The rule id, one of [`RULES`].
245    pub rule: &'static str,
246    /// The level the check gives this finding, before the policy's `check_diff.levels`.
247    pub level: Level,
248    /// The added line, in the new file; `None` for the file as a whole.
249    pub line: Option<usize>,
250    /// Why it matters, naming what was found but never a secret's value.
251    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/// Every content finding of `change`, but [`NEW_HOST`] ([`new_hosts`]), in line order.
266#[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/// The policy's `check_diff:` section: a level for any content check.
288///
289/// ```yaml
290/// check_diff:
291///   levels:
292///     diff-ci-unpinned: off      # this team pins by tag on purpose
293///     diff-new-host: deny
294/// ```
295#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct Settings {
298    /// Rule id ([`RULES`]) → `deny`, `review` or `off`.
299    #[serde(default)]
300    pub levels: BTreeMap<String, Level>,
301}
302
303impl Settings {
304    /// The level `hit` is reported at: the policy's for its rule, else the check's own.
305    #[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    /// The rules the policy turns off, for `policy lint` to warn about.
314    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
322/// Every id under `check_diff.levels` names a content check.
323pub(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
338/// `text` for a reason: control characters dropped, at most 80 characters.
339fn 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;