M3a review findings 3, 5 (the cast), 6, 7 (brokerd), 11. A config, directory or socket failure at start now ends with docs/runbook.md#brokerd-start-failed, and losing a listener with #brokerd-listener-lost; both entries are new. Threads start through thread::Builder, so a refused thread is reported instead of silently killing a listener; an aborted connection no longer stops the daemon. brokerd reads args_os and keeps the config path as a path. The "requester went away" result is recorded at the time it happens. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
374 lines
12 KiB
Rust
374 lines
12 KiB
Rust
//! Loading the owner's grant files into a `GrantSet`, failing closed.
|
|
//!
|
|
//! A mistyped `deny` grant that was skipped would silently become an allow wherever another grant
|
|
//! matches, so if any file is invalid the whole set is invalid. `load` therefore returns either a
|
|
//! complete valid set or the full list of problems, never a partial one, and reports every problem
|
|
//! in every file rather than only the first.
|
|
|
|
use std::path::Path;
|
|
|
|
use crate::args::{ToolName, valid_host_pattern, valid_path};
|
|
use proto::{DataClass, Grant, GrantProblem, Mode, sha256};
|
|
|
|
/// Where the owner reads the rules behind a rejected set.
|
|
pub const RUNBOOK: &str = "see docs/runbook.md#grants-invalid";
|
|
|
|
/// A grant read from one file: the id (the file stem), the parsed grant, and the file's hash.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LoadedGrant {
|
|
pub id: String, // the file stem
|
|
pub grant: proto::Grant,
|
|
pub sha256: proto::Hash32, // of the file's bytes as read
|
|
}
|
|
|
|
/// A set that passed every rule, in id order. Its field is private: `from_grants` and `load` are
|
|
/// the only ways to make one that is not empty.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct GrantSet {
|
|
grants: Vec<LoadedGrant>,
|
|
}
|
|
|
|
impl GrantSet {
|
|
/// Build a set, checking every grant and refusing the whole set if any grant is invalid.
|
|
pub fn from_grants(grants: Vec<LoadedGrant>) -> Result<GrantSet, Vec<GrantProblem>> {
|
|
let mut grants = grants;
|
|
grants.sort_by(|a, b| a.id.cmp(&b.id));
|
|
|
|
let mut problems: Vec<GrantProblem> = Vec::new();
|
|
let mut seen: Vec<String> = Vec::new();
|
|
for grant in &grants {
|
|
if seen.iter().any(|id| id == &grant.id) {
|
|
push(
|
|
&mut problems,
|
|
format!("{}.toml", grant.id),
|
|
None,
|
|
"two grants have this id",
|
|
);
|
|
}
|
|
seen.push(grant.id.clone());
|
|
check_grant(grant, &mut problems);
|
|
}
|
|
|
|
if problems.is_empty() {
|
|
Ok(GrantSet { grants })
|
|
} else {
|
|
Err(problems)
|
|
}
|
|
}
|
|
|
|
/// The grants in id order.
|
|
pub fn grants(&self) -> &[LoadedGrant] {
|
|
&self.grants
|
|
}
|
|
}
|
|
|
|
/// A file name is a valid grant id when it is 1 to 64 characters of `a-z`, `0-9` and `-`.
|
|
pub fn valid_id(id: &str) -> bool {
|
|
if !(1..=64).contains(&id.len()) {
|
|
return false;
|
|
}
|
|
id.bytes()
|
|
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-'))
|
|
}
|
|
|
|
/// Read every `*.toml` in `dir` into a `GrantSet`, or report every problem found.
|
|
pub fn load(dir: &Path) -> Result<GrantSet, Vec<GrantProblem>> {
|
|
// Step 1: the directory itself must be readable. A missing directory is not an empty set.
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(entries) => entries,
|
|
Err(error) => {
|
|
return Err(vec![GrantProblem {
|
|
file: dir.to_string_lossy().to_string(),
|
|
line: None,
|
|
problem: format!("the grants directory cannot be read: {}", error),
|
|
}]);
|
|
}
|
|
};
|
|
|
|
// Step 2: collect and sort the file names, so problems come out in the same order every time.
|
|
let mut names: Vec<String> = Vec::new();
|
|
for entry in entries {
|
|
match entry {
|
|
Ok(entry) => names.push(entry.file_name().to_string_lossy().to_string()),
|
|
Err(error) => {
|
|
// An entry that cannot be read is the same problem as the directory.
|
|
return Err(vec![GrantProblem {
|
|
file: dir.to_string_lossy().to_string(),
|
|
line: None,
|
|
problem: format!("the grants directory cannot be read: {}", error),
|
|
}]);
|
|
}
|
|
}
|
|
}
|
|
names.sort();
|
|
|
|
// Steps 3-7: read each file, keeping the valid grants and recording every other problem.
|
|
let mut kept: Vec<LoadedGrant> = Vec::new();
|
|
let mut problems: Vec<GrantProblem> = Vec::new();
|
|
for name in &names {
|
|
if !name.ends_with(".toml") {
|
|
continue;
|
|
}
|
|
let id = &name[..name.len() - ".toml".len()];
|
|
let path = dir.join(name);
|
|
|
|
let bytes = match std::fs::read(&path) {
|
|
Ok(bytes) => bytes,
|
|
Err(error) => {
|
|
// Never treat an unreadable file as absent.
|
|
problems.push(GrantProblem {
|
|
file: name.clone(),
|
|
line: None,
|
|
problem: format!("cannot be read: {}", error),
|
|
});
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let text = match std::str::from_utf8(&bytes) {
|
|
Ok(text) => text,
|
|
Err(_) => {
|
|
problems.push(GrantProblem {
|
|
file: name.clone(),
|
|
line: None,
|
|
problem: "is not UTF-8".to_string(),
|
|
});
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match toml::from_str::<Grant>(text) {
|
|
Ok(grant) => {
|
|
let sha256 = match sha256(&bytes) {
|
|
Ok(sha256) => sha256,
|
|
Err(error) => {
|
|
problems.push(GrantProblem {
|
|
file: name.clone(),
|
|
line: None,
|
|
problem: error.to_string(),
|
|
});
|
|
continue;
|
|
}
|
|
};
|
|
kept.push(LoadedGrant {
|
|
id: id.to_string(),
|
|
grant,
|
|
sha256,
|
|
});
|
|
}
|
|
Err(error) => {
|
|
problems.push(GrantProblem {
|
|
file: name.clone(),
|
|
line: Some(span_line(text, &error)),
|
|
problem: error.message().to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 8: even when the steps above found problems, still check the valid grants so the owner
|
|
// sees everything at once. Return Err if anything at all was reported.
|
|
let mut all = problems;
|
|
let set = match GrantSet::from_grants(kept) {
|
|
Ok(set) => Some(set),
|
|
Err(value_problems) => {
|
|
all.extend(value_problems);
|
|
None
|
|
}
|
|
};
|
|
all.sort_by(|a, b| a.file.cmp(&b.file));
|
|
match set {
|
|
Some(set) => {
|
|
if all.is_empty() {
|
|
Ok(set)
|
|
} else {
|
|
Err(all)
|
|
}
|
|
}
|
|
None => Err(all),
|
|
}
|
|
}
|
|
|
|
/// The 1-based line of a parse error's start: the number of newlines before it, plus one.
|
|
fn span_line(text: &str, error: &toml::de::Error) -> u64 {
|
|
match error.span() {
|
|
Some(range) => {
|
|
let before = match text.get(..range.start) {
|
|
Some(before) => before,
|
|
None => text,
|
|
};
|
|
let newlines = before.bytes().filter(|&b| b == b'\n').count();
|
|
u64::try_from(newlines).map_or(u64::MAX, |n| n.saturating_add(1))
|
|
}
|
|
None => 1,
|
|
}
|
|
}
|
|
|
|
/// Check one grant and push every problem it has. `file` is `"<id>.toml"`, `line` is `None`.
|
|
fn check_grant(grant: &LoadedGrant, problems: &mut Vec<GrantProblem>) {
|
|
let id = &grant.id;
|
|
let inner = &grant.grant;
|
|
let file = format!("{}.toml", id);
|
|
|
|
// Rule 2: the id must be a valid grant id.
|
|
if !valid_id(id) {
|
|
push(
|
|
problems,
|
|
file.clone(),
|
|
None,
|
|
"the file name is not a valid grant id",
|
|
);
|
|
}
|
|
|
|
// Rule 3: the tool must be one of the four; an unknown tool skips rule 6 but not the others.
|
|
match ToolName::parse(&grant.grant.tool) {
|
|
None => push(
|
|
problems,
|
|
file.clone(),
|
|
None,
|
|
"unknown tool; only read_file, write_file, shell and http_fetch",
|
|
),
|
|
Some(tool) => check_tool_constraints(tool, &file, inner, problems),
|
|
}
|
|
|
|
// Rule 4: secrets are not supported until M4.
|
|
if inner.secret.is_some() {
|
|
push(
|
|
problems,
|
|
file.clone(),
|
|
None,
|
|
"secrets are not supported until M4",
|
|
);
|
|
}
|
|
// Rule 5: patterns are not supported.
|
|
if !inner.constraints.patterns.is_empty() {
|
|
push(problems, file.clone(), None, "patterns are not supported");
|
|
}
|
|
// Rule 7: no path may be the whole file system or an invalid absolute path.
|
|
for path in &inner.constraints.paths {
|
|
if path == "/" {
|
|
push(
|
|
problems,
|
|
file.clone(),
|
|
None,
|
|
"a grant of the whole file system is not supported",
|
|
);
|
|
} else if !valid_path(path) {
|
|
push(
|
|
problems,
|
|
file.clone(),
|
|
None,
|
|
format!("{:?} is not a valid absolute path", path),
|
|
);
|
|
}
|
|
}
|
|
// Rule 8: every host must be a valid host pattern.
|
|
for host in &inner.constraints.hosts {
|
|
if !valid_host_pattern(host) {
|
|
push(
|
|
problems,
|
|
file.clone(),
|
|
None,
|
|
format!("{:?} is not a valid host pattern", host),
|
|
);
|
|
}
|
|
}
|
|
// Rule 9: a deny grant must apply at every taint.
|
|
if inner.mode == Mode::Deny && inner.max_taint != DataClass::Secret {
|
|
push(
|
|
problems,
|
|
file,
|
|
None,
|
|
"a deny grant must apply at every taint",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Rule 6: the per-tool checks on paths and hosts. `file` is `"<id>.toml"`.
|
|
fn check_tool_constraints(
|
|
tool: ToolName,
|
|
file: &str,
|
|
grant: &Grant,
|
|
problems: &mut Vec<GrantProblem>,
|
|
) {
|
|
let tool = tool.as_str();
|
|
match tool {
|
|
"read_file" | "write_file" => {
|
|
if grant.constraints.paths.is_empty() {
|
|
push(
|
|
problems,
|
|
file.to_string(),
|
|
None,
|
|
format!("{} needs at least one path", tool),
|
|
);
|
|
}
|
|
if !grant.constraints.hosts.is_empty() {
|
|
push(
|
|
problems,
|
|
file.to_string(),
|
|
None,
|
|
format!("{} does not take hosts", tool),
|
|
);
|
|
}
|
|
}
|
|
"shell" if !grant.constraints.hosts.is_empty() => {
|
|
push(
|
|
problems,
|
|
file.to_string(),
|
|
None,
|
|
format!("{} does not take hosts", tool),
|
|
);
|
|
}
|
|
"http_fetch" => {
|
|
if grant.constraints.hosts.is_empty() {
|
|
push(
|
|
problems,
|
|
file.to_string(),
|
|
None,
|
|
"http_fetch needs at least one host",
|
|
);
|
|
}
|
|
if !grant.constraints.paths.is_empty() {
|
|
push(
|
|
problems,
|
|
file.to_string(),
|
|
None,
|
|
"http_fetch does not take paths",
|
|
);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// One line per problem, then `RUNBOOK` on its own line; every line ends with `\n`.
|
|
pub fn render(problems: &[GrantProblem]) -> String {
|
|
let mut out = String::new();
|
|
for problem in problems {
|
|
match problem.line {
|
|
Some(line) => {
|
|
out.push_str(&format!("{}:{}: {}\n", problem.file, line, problem.problem));
|
|
}
|
|
None => {
|
|
out.push_str(&format!("{}: {}\n", problem.file, problem.problem));
|
|
}
|
|
}
|
|
}
|
|
out.push_str(RUNBOOK);
|
|
out.push('\n');
|
|
out
|
|
}
|
|
|
|
fn push(
|
|
problems: &mut Vec<GrantProblem>,
|
|
file: String,
|
|
line: Option<u64>,
|
|
problem: impl Into<String>,
|
|
) {
|
|
problems.push(GrantProblem {
|
|
file,
|
|
line,
|
|
problem: problem.into(),
|
|
});
|
|
}
|