openmoat-core 0.5.2

Policy model and decision engine for OpenMoat. Pure: no I/O, no OS calls.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Shell command classification (docs/ARCHITECTURE.md §3, docs/POLICY.md §3.1).
//!
//! A command line is lexed ([`crate::lexer`]), grouped into simple commands,
//! and each simple command is decomposed into [`AtomicAction`]s:
//!
//! | Source | Atomic action |
//! |---|---|
//! | the command itself | `Shell { argv }`; for `git`, without its global options (`git.rs`) |
//! | every pipeline / list suffix with ≥ 2 commands | `Pipeline { argv }` |
//! | a decoder stage piped into an interpreter reading stdin (`decoders.rs`) | canonical `Pipeline { <decoder> -d \| <interpreter> }` |
//! | leading `VAR=value`, `export`/`declare`/`set` assignments | `EnvSet` |
//! | `$VAR` / `${VAR}` references, `printenv NAME` | `EnvRead` |
//! | path-looking arguments, relative operands (`operands.rs`), `<` targets other than `/dev/null` and stdin, `source`/`.` files | `FsRead`, one per directory `cd` may have moved to (`cwd.rs`); also listed as a glob when unquoted `* ? [` make the shell expand it (`crate::expand`) |
//! | `>`/`>>`/`&>` targets other than `/dev/null` and the standard streams (`tables::STREAM_SINKS`), `tee`, destructive/destination args | `FsWrite` |
//! | URLs with any host, bare dotted names under a known TLD, IPv4 literals (`crate::host`) | `Net` |
//! | `$( … )`, backticks, `sh -c` (any option spelling, `invocation.rs`), `eval`, `xargs`, `sudo`, `env`, … | nested classification |
//! | `python -c`, `node -e`, `perl -e`, … payloads | URL/path scan of the payload |
//! | `find -exec/-execdir/-ok/-okdir` commands, `--output=FILE`, `gofmt -w` operands (`options.rs`) | nested classification, `FsWrite` |
//! | `watch`, `git rebase --exec`, `git submodule foreach`, `git bisect run`, `ssh -o ProxyCommand=`, `rsync -e` (`embedded.rs`) | nested classification |
//! | `file:` URLs (`curl file:///etc/passwd`) | `FsRead` |
//! | `make --eval`, `-e`, `SHELL=`, `X!=cmd`, `$(shell …)` (`make.rs`) | `make <arg>` `Shell` atom + nested classification |
//! | text tool options (`text.rs`): `sort -o FILE`, `uniq in out`; an unknown option | `FsWrite`; `<program> @<option>` `Shell` atom |
//! | `sed` scripts (`sed/`): `r FILE`, `w FILE`; `e`, anything not understood | `FsRead`, `FsWrite`; `sed @<script>` `Shell` atom |
//! | `awk` programs (`awk.rs`) with `system`, `\|`, `getline`, `ENVIRON`, `ARGV`, `@`, `print … >` | `awk @<program>` `Shell` atom |
//!
//! Classification is conservative by design: when the input cannot be parsed
//! safely the result is [`ParseOutcome::Unparseable`], which the engine maps to
//! `ask`, never `allow`. A simple command (or a path in it) that cannot be
//! classified does not stop the others: their atoms are kept, so a `deny` among
//! them still wins over the `ask`. `<<<` here-strings and here-document bodies are data,
//! except that the substitutions and variables the shell expands in them are
//! classified and a shell or interpreter reading stdin runs them as its program.
//!
//! Module layout: `commands` groups tokens and classifies each simple command;
//! `tokens` recognises assignments and variable references inside one word;
//! `invocation` parses a shell's own options (`bash -lc`, `sh -ec`, `--login`);
//! `git` takes git's global options out of the command;
//! `operands` decides which plain arguments name files, and `copy` where a copy
//! or a move writes (`cp f ~/` writes `~/f`);
//! `decoders`, `make`, `options` and `embedded` handle constructs that hide a command or a
//! write (decoded pipelines, make arguments, `find -exec`, `--output=`, `watch 'cmd'`,
//! `git rebase --exec`, `ssh -o ProxyCommand=`);
//! `text` reads the options of text tools, whose values are data, not files,
//! and `sed` and `awk` their programs;
//! `tables` holds the program lists that drive all of them.

mod awk;
mod commands;
mod copy;
mod cwd;
mod database;
mod decoders;
mod embedded;
pub(crate) mod git;
mod invocation;
mod make;
mod operands;
mod options;
mod path_options;
mod sed;
pub(crate) mod tables;
#[cfg(test)]
mod tests;
mod text;
pub(crate) mod tokens;

use crate::action::AtomicAction;
use crate::lexer::{LexError, Token};
use crate::paths;

/// Result of classifying a command line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseOutcome {
    Parsed(Vec<AtomicAction>),
    /// Some part could not be classified (`reason`); `atoms` are the parts that
    /// were, still decided so that a `deny` among them is not lost.
    Unparseable {
        reason: String,
        atoms: Vec<AtomicAction>,
    },
}

impl ParseOutcome {
    /// The atoms classified, and why the rest could not be, if anything was left.
    #[must_use]
    pub fn into_parts(self) -> (Vec<AtomicAction>, Option<String>) {
        match self {
            Self::Parsed(atoms) => (atoms, None),
            Self::Unparseable { reason, atoms } => (atoms, Some(reason)),
        }
    }
}

/// Context needed to normalise paths inside a command. No I/O.
#[derive(Debug, Clone, Copy)]
pub struct ShellContext<'a> {
    pub home: &'a str,
    pub project: Option<&'a str>,
    /// Every directory the command may be running in: the session's working
    /// directory, plus wherever an earlier `cd` may have moved (`cwd.rs`).
    /// `None` is a directory that cannot be known (`cd "$X"`, `cd -`).
    pub cwd: &'a [Option<String>],
}

impl ShellContext<'_> {
    /// Every canonical path a shell word may name: one per possible working
    /// directory for a relative word. An error when one of them is not known
    /// (`~-`, or a relative word after `cd "$X"`), which makes the command `ask`.
    pub(crate) fn paths(&self, word: &str) -> Result<Vec<String>, ClassifyError> {
        if let Some(path) = paths::resolve(word, self.home, self.project, None) {
            return Ok(vec![path]);
        }
        self.cwd
            .iter()
            .map(|cwd| {
                paths::resolve(word, self.home, self.project, cwd.as_deref())
                    .ok_or_else(|| ClassifyError::UnknownDirectory(word.to_owned()))
            })
            .collect()
    }
}

/// Maximum nesting of `$( … )`, `sh -c`, `eval` and wrapper commands.
pub const MAX_DEPTH: u8 = 4;
/// Upper bound on atomic actions per command line, to bound work on hostile input.
pub const MAX_ATOMS: usize = 2048;
/// Upper bound on the directories one command line may be running in after `cd`.
pub const MAX_DIRS: usize = 16;

/// Classify a shell command string into atomic actions.
#[cfg(test)]
#[must_use]
pub fn classify(command: &str, ctx: &ShellContext<'_>) -> ParseOutcome {
    classify_globs(command, ctx).0
}

/// Classify a shell command string into atomic actions, and list the reads
/// and writes among them whose path is a glob the shell expands
/// (`crate::expand`).
#[must_use]
pub fn classify_globs(command: &str, ctx: &ShellContext<'_>) -> (ParseOutcome, Vec<AtomicAction>) {
    let mut sink = Sink::default();
    let mut stopped = commands::classify_into(command, ctx, &mut sink, 0).err();
    // `cat${IFS}~/.ssh/id_rsa`: the shell splits words where `$IFS` expands. The
    // line is classified a second time with it read as a space, and both sets of
    // actions count, so the split never loosens the verdict.
    let split = command.replace("${IFS}", " ").replace("$IFS", " ");
    if split != command {
        let again = commands::classify_into(&split, ctx, &mut sink, 0).err();
        stopped = stopped.or(again);
    }
    let outcome = match sink.skipped.or(stopped) {
        Some(e) => ParseOutcome::Unparseable {
            reason: e.to_string(),
            atoms: sink.atoms,
        },
        None if sink.atoms.is_empty() => ParseOutcome::Unparseable {
            reason: "no command found".to_owned(),
            atoms: Vec::new(),
        },
        None => ParseOutcome::Parsed(sink.atoms),
    };
    (outcome, sink.globs)
}

/// Why a command line could not be classified safely; the engine turns it into `ask`.
#[derive(Debug, thiserror::Error)]
pub(crate) enum ClassifyError {
    #[error(transparent)]
    Lex(#[from] LexError),
    #[error("command nesting deeper than {MAX_DEPTH}")]
    TooDeep,
    #[error("command expands to more than {MAX_ATOMS} actions")]
    TooManyActions,
    #[error("cannot interpret `{option}` for `{program}`")]
    ShellOption { program: String, option: String },
    #[error("`{program} {option}` runs code from a file or module the command line names")]
    LoadsCode { program: String, option: String },
    #[error("`{0}` names a path relative to a directory that is not known")]
    UnknownDirectory(String),
    #[error("`cd` may lead to more than {MAX_DIRS} directories")]
    TooManyDirectories,
    #[error("unbalanced parentheses")]
    UnbalancedParens,
    #[error("the shell runs text from `{0}` that the command line does not show")]
    UnknownStdin(String),
    #[error("`{0}` depends on a variable whose value is not known")]
    UnknownValue(String),
    #[error("a variable set on the line may hold more than {MAX_DIRS} combinations of values")]
    TooManyValues,
}

/// All lowercase letters, digits and `_`, with one letter at least: a shell
/// variable by convention, not an environment variable.
pub(crate) fn is_lowercase(name: &str) -> bool {
    name.bytes().any(|b| b.is_ascii_lowercase())
        && name
            .bytes()
            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}

/// Accumulates atomic actions with de-duplication and a hard size bound.
///
/// Every classifier function writes through a `&mut Sink`, never a bare
/// `Vec`, so no code path can exceed [`MAX_ATOMS`].
#[derive(Debug, Default)]
pub(crate) struct Sink {
    atoms: Vec<AtomicAction>,
    /// The first part classification went past without understanding it.
    skipped: Option<ClassifyError>,
    /// Words of the command line, nested commands included, that the shell
    /// expands as globs ([`crate::lexer::Word::glob`]).
    glob_words: Vec<String>,
    /// Read and write atoms whose path is one of those globs: the engine
    /// evaluates every file it matches as well as the literal path.
    globs: Vec<AtomicAction>,
    /// Words of the command line with a `$` the shell expands (not one from
    /// single quotes): a path taken from one is not known as written.
    dollar_words: Vec<String>,
    /// Shell variables an earlier command of the line set on its own (`x=1;`,
    /// `for x in`, `export X=1`), with the nesting depth and the values they may
    /// hold, when those are known text. Such a variable is substituted where it
    /// is used, whatever its case; reading a lowercase one is no environment
    /// read, while an uppercase name, by convention an environment variable,
    /// keeps its `env.read`. A child shell (`sh -c`) does not see them.
    locals: Vec<(String, u8, Option<Vec<String>>)>,
    /// The nesting depth of the simple command being classified.
    depth: u8,
}

impl Sink {
    /// Classify the next simple command at `depth`.
    pub(crate) fn enter(&mut self, depth: u8) {
        self.depth = depth;
    }

    /// `name`, set by a command of its own, is a shell variable from here on, if
    /// it is all lowercase: environment variables, secrets among them, are by
    /// convention uppercase, and deny rules name them so.
    pub(crate) fn note_local(&mut self, name: &str, values: Option<Vec<String>>) {
        // a later assignment replaces the earlier one
        self.locals
            .retain(|(n, d, _)| !(n == name && *d == self.depth));
        self.locals.push((name.to_owned(), self.depth, values));
    }

    /// The variables at `depth` whose values are all known.
    pub(crate) fn known_locals(&self, depth: u8) -> Vec<(String, Vec<String>)> {
        self.locals
            .iter()
            .filter(|(_, d, _)| *d == depth)
            .filter_map(|(n, _, v)| Some((n.clone(), v.clone()?)))
            .collect()
    }

    pub(crate) fn push(&mut self, atom: AtomicAction) -> Result<(), ClassifyError> {
        if let AtomicAction::EnvRead { name } = &atom
            && is_lowercase(name)
            && self
                .locals
                .iter()
                .any(|(n, d, v)| n == name && *d == self.depth && v.is_some())
        {
            return Ok(());
        }
        if self.atoms.contains(&atom) {
            return Ok(());
        }
        if self.atoms.len() >= MAX_ATOMS {
            return Err(ClassifyError::TooManyActions);
        }
        self.atoms.push(atom);
        Ok(())
    }

    /// Note a part that could not be classified and carry on with the rest:
    /// the command still asks, and what else it does is still decided.
    pub(crate) fn skip(&mut self, error: ClassifyError) {
        self.skipped.get_or_insert(error);
    }

    /// Every path `word` may name, or none when one of them is not known (skipped).
    fn paths(&mut self, ctx: &ShellContext<'_>, word: &str) -> Vec<String> {
        // `cat $d/id_rsa`: the literal path is still checked, so a deny on it wins,
        // but the file the shell opens is not known
        if paths::has_unexpanded_variable(word)
            && self.dollar_words.iter().any(|w| w.contains(word))
        {
            // `@unknown-path <word>`: a rule can ask for a secret's file name in a
            // directory OpenMoat cannot place (`secret-names`)
            let atom = AtomicAction::Shell {
                argv: vec!["@unknown-path".to_owned(), word.to_owned()],
            };
            if let Err(full) = self.push(atom) {
                self.skip(full);
            }
            self.skip(ClassifyError::UnknownValue(word.to_owned()));
        }
        ctx.paths(word).unwrap_or_else(|e| {
            // Another user's home cannot be placed, and it may hold that user's
            // secrets: `@other-home <word>`, which the `other-home` rule asks for.
            if paths::other_user_home(word, ctx.home) {
                let atom = AtomicAction::Shell {
                    argv: vec!["@other-home".to_owned(), word.to_owned()],
                };
                if let Err(full) = self.push(atom) {
                    self.skip(full);
                }
            }
            self.skip(e);
            Vec::new()
        })
    }

    /// Remember the words of `tokens` the shell expands as globs, and those
    /// with a `$` it expands.
    pub(crate) fn note_globs(&mut self, tokens: &[Token]) {
        for token in tokens {
            if let Token::Word(w) = token {
                if w.glob && !self.glob_words.contains(&w.text) {
                    self.glob_words.push(w.text.clone());
                }
                self.note_dollar_word(w);
            }
        }
    }

    /// Remember `word` when the shell expands a `$` or a command substitution in it.
    pub(crate) fn note_dollar_word(&mut self, word: &crate::lexer::Word) {
        let expands = !word.substitutions.is_empty()
            || word
                .text
                .match_indices('$')
                .any(|(pos, _)| !word.literal.iter().any(|span| span.contains(&pos)));
        if expands && !self.dollar_words.contains(&word.text) {
            self.dollar_words.push(word.text.clone());
        }
    }

    /// An `FsRead` for every path `word` may name.
    pub(crate) fn read(&mut self, ctx: &ShellContext<'_>, word: &str) -> Result<(), ClassifyError> {
        self.fs(ctx, word, |path| AtomicAction::FsRead { path })
    }

    /// An `FsWrite` for every path `word` may name.
    pub(crate) fn write(
        &mut self,
        ctx: &ShellContext<'_>,
        word: &str,
    ) -> Result<(), ClassifyError> {
        self.fs(ctx, word, |path| AtomicAction::FsWrite { path })
    }

    /// An `FsRead` for every path a `<` redirection from `word` may name,
    /// except the empty source and standard input (`tables::STREAM_SOURCES`).
    pub(crate) fn redirect_read(
        &mut self,
        ctx: &ShellContext<'_>,
        word: &str,
    ) -> Result<(), ClassifyError> {
        self.redirect(ctx, word, tables::STREAM_SOURCES, |path| {
            AtomicAction::FsRead { path }
        })
    }

    /// An `FsWrite` for every path a `>` redirection to `word` may name,
    /// except the data sink and standard output and error (`tables::STREAM_SINKS`).
    pub(crate) fn redirect_write(
        &mut self,
        ctx: &ShellContext<'_>,
        word: &str,
    ) -> Result<(), ClassifyError> {
        self.redirect(ctx, word, tables::STREAM_SINKS, |path| {
            AtomicAction::FsWrite { path }
        })
    }

    /// `fs` for a redirection target, skipped when it resolves to one of
    /// `streams`. A glob is never skipped: the shell may expand it to a file.
    fn redirect(
        &mut self,
        ctx: &ShellContext<'_>,
        word: &str,
        streams: &[&str],
        atom: fn(String) -> AtomicAction,
    ) -> Result<(), ClassifyError> {
        let stream = !word.contains(['*', '?', '['])
            && ctx
                .paths(word)
                .is_ok_and(|paths| paths.iter().all(|p| streams.contains(&p.as_str())));
        if stream {
            return Ok(());
        }
        self.fs(ctx, word, atom)
    }

    /// An atom for every path `word` may name, also kept as a glob when `word`
    /// is, or ends, a word the shell expands (`@file` and `--out=FILE` keep
    /// the glob of their word). An operand with the text of a glob elsewhere
    /// in the command line is expanded too: stricter, never looser.
    fn fs(
        &mut self,
        ctx: &ShellContext<'_>,
        word: &str,
        atom: fn(String) -> AtomicAction,
    ) -> Result<(), ClassifyError> {
        let glob =
            word.contains(['*', '?', '[']) && self.glob_words.iter().any(|w| w.ends_with(word));
        for path in self.paths(ctx, word) {
            let atom = atom(path);
            if glob && !self.globs.contains(&atom) {
                self.globs.push(atom.clone());
            }
            self.push(atom)?;
        }
        Ok(())
    }
}