| 14 |
14 |
|
use crate::capability::{CapabilityDenied, CapabilitySet};
|
| 15 |
15 |
|
use crate::executor::{Executor, SyncOpts, run_command_into_sink};
|
| 16 |
16 |
|
use crate::remote::{LogSink, RemoteHost, RunOutput, sh_quote};
|
| 17 |
|
- |
use crate::step::Step;
|
|
17 |
+ |
use crate::step::{Action, ObserveKind, Step};
|
| 18 |
18 |
|
use anyhow::{Context, Result};
|
| 19 |
19 |
|
use async_trait::async_trait;
|
| 20 |
20 |
|
use std::fmt::Write as _;
|
| 21 |
|
- |
use std::path::Path;
|
|
21 |
+ |
use std::path::{Component, Path, PathBuf};
|
| 22 |
22 |
|
use tokio::process::Command;
|
| 23 |
23 |
|
|
| 24 |
24 |
|
/// Render a step to a single `/bin/sh` command line: optional `cd`, env
|
| 51 |
51 |
|
}
|
| 52 |
52 |
|
}
|
| 53 |
53 |
|
|
|
54 |
+ |
/// Gate a sync-plane artifact pull: the caller-side equivalent of the agent's
|
|
55 |
+ |
/// `/pull` handler (`agent::pull`). Two conditions, both **fail-closed**:
|
|
56 |
+ |
///
|
|
57 |
+ |
/// 1. The grant must include `observe:artifact` — retrieving a produced release
|
|
58 |
+ |
/// artifact is an observe-plane capability, distinct from reading the build
|
|
59 |
+ |
/// log. Denied → [`CapabilityDenied`], downcastable for audit.
|
|
60 |
+ |
/// 2. `requested` must lie under the executor's declared `pull_root`. A
|
|
61 |
+ |
/// transport with **no** root pulls nothing — that is the "declared per-host
|
|
62 |
+ |
/// artifact root" the audit finding calls for.
|
|
63 |
+ |
///
|
|
64 |
+ |
/// Without this, `pull_file`/`pull_dir`/`pull_glob` would rsync ANY path off the
|
|
65 |
+ |
/// host (`collect('mbp', '/Users/max/.tauri/passwords.env', …)` deposits the
|
|
66 |
+ |
/// notary credential into `dist_root`): "THE WALL" held on the agent plane and
|
|
67 |
+ |
/// not on the sync plane the agent hosts are actually collected over.
|
|
68 |
+ |
///
|
|
69 |
+ |
/// Confinement is **lexical** (reject `..`, require a component-wise prefix),
|
|
70 |
+ |
/// not canonicalizing like the agent's `confine_to_root`: the ssh transport's
|
|
71 |
+ |
/// path is on a remote host we cannot `canonicalize()` without an extra
|
|
72 |
+ |
/// round-trip, so both transports share the one check. The root is
|
|
73 |
+ |
/// operator-declared on trusted infra and the threat closed here is a recipe- or
|
|
74 |
+ |
/// daemon-supplied wild path; a symlink *inside* the root pointing out is the
|
|
75 |
+ |
/// agent plane's stronger (canonicalizing) guarantee, out of scope here.
|
|
76 |
+ |
fn gate_pull(
|
|
77 |
+ |
caps: &CapabilitySet,
|
|
78 |
+ |
host: &str,
|
|
79 |
+ |
pull_root: Option<&Path>,
|
|
80 |
+ |
requested: &Path,
|
|
81 |
+ |
) -> Result<()> {
|
|
82 |
+ |
if !caps.permits_observe(&ObserveKind::Artifact) {
|
|
83 |
+ |
return Err(CapabilityDenied::new(host, &Action::Observe(ObserveKind::Artifact)).into());
|
|
84 |
+ |
}
|
|
85 |
+ |
let Some(root) = pull_root else {
|
|
86 |
+ |
anyhow::bail!(
|
|
87 |
+ |
"pull from `{host}` denied: no artifact root declared for this host \
|
|
88 |
+ |
(set `pull_root` in the host's topology entry)"
|
|
89 |
+ |
);
|
|
90 |
+ |
};
|
|
91 |
+ |
anyhow::ensure!(
|
|
92 |
+ |
!requested
|
|
93 |
+ |
.components()
|
|
94 |
+ |
.any(|c| matches!(c, Component::ParentDir)),
|
|
95 |
+ |
"pull path `{}` from `{host}` contains `..`",
|
|
96 |
+ |
requested.display()
|
|
97 |
+ |
);
|
|
98 |
+ |
anyhow::ensure!(
|
|
99 |
+ |
requested.starts_with(root),
|
|
100 |
+ |
"pull path `{}` escapes the declared artifact root `{}` on `{host}`",
|
|
101 |
+ |
requested.display(),
|
|
102 |
+ |
root.display()
|
|
103 |
+ |
);
|
|
104 |
+ |
Ok(())
|
|
105 |
+ |
}
|
|
106 |
+ |
|
| 54 |
107 |
|
/// An env *value* is sh-quoted at render time, but the *name* is interpolated
|
| 55 |
108 |
|
/// verbatim (`NAME=value`), so a name with shell metacharacters could inject a
|
| 56 |
109 |
|
/// command. Require each name to be a bare shell identifier before dispatch.
|
| 147 |
200 |
|
pub struct LocalExec {
|
| 148 |
201 |
|
host: RemoteHost,
|
| 149 |
202 |
|
caps: CapabilitySet,
|
|
203 |
+ |
pull_root: Option<PathBuf>,
|
| 150 |
204 |
|
}
|
| 151 |
205 |
|
|
| 152 |
206 |
|
impl LocalExec {
|
| 154 |
208 |
|
Self {
|
| 155 |
209 |
|
host: RemoteHost::new("local"),
|
| 156 |
210 |
|
caps,
|
|
211 |
+ |
pull_root: None,
|
| 157 |
212 |
|
}
|
| 158 |
213 |
|
}
|
|
214 |
+ |
|
|
215 |
+ |
/// Confine this transport's artifact pulls to `root` (see [`gate_pull`]).
|
|
216 |
+ |
/// Required before any `pull_*` succeeds — pulls are fail-closed, so an
|
|
217 |
+ |
/// executor built without a root refuses every pull.
|
|
218 |
+ |
#[must_use]
|
|
219 |
+ |
pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
|
|
220 |
+ |
self.pull_root = Some(root.into());
|
|
221 |
+ |
self
|
|
222 |
+ |
}
|
| 159 |
223 |
|
}
|
| 160 |
224 |
|
|
| 161 |
225 |
|
#[async_trait]
|
| 168 |
232 |
|
}
|
| 169 |
233 |
|
|
| 170 |
234 |
|
async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
|
|
235 |
+ |
gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
|
| 171 |
236 |
|
// No trailing slash: rsync copies the file itself.
|
| 172 |
237 |
|
let src = remote.to_string_lossy().to_string();
|
| 173 |
238 |
|
run_rsync(
|
| 178 |
243 |
|
}
|
| 179 |
244 |
|
|
| 180 |
245 |
|
async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
|
|
246 |
+ |
gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
|
| 181 |
247 |
|
// Local "pull" is just a local rsync; trailing slash = contents.
|
| 182 |
248 |
|
let src = format!("{}/", remote.display());
|
| 183 |
249 |
|
run_rsync(
|
| 188 |
254 |
|
}
|
| 189 |
255 |
|
|
| 190 |
256 |
|
async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
|
|
257 |
+ |
gate_pull(
|
|
258 |
+ |
&self.caps,
|
|
259 |
+ |
"local",
|
|
260 |
+ |
self.pull_root.as_deref(),
|
|
261 |
+ |
Path::new(remote_glob),
|
|
262 |
+ |
)?;
|
| 191 |
263 |
|
// No remote shell to expand for us, and `rsync` is spawned directly (no
|
| 192 |
264 |
|
// shell), so expand in-process. This is the half of `pull_glob` that
|
| 193 |
265 |
|
// differs from the ssh transport, and the reason the trait's docs warn
|
| 219 |
291 |
|
pub struct SshExec {
|
| 220 |
292 |
|
host: RemoteHost,
|
| 221 |
293 |
|
caps: CapabilitySet,
|
|
294 |
+ |
pull_root: Option<PathBuf>,
|
| 222 |
295 |
|
}
|
| 223 |
296 |
|
|
| 224 |
297 |
|
impl SshExec {
|
| 226 |
299 |
|
Self {
|
| 227 |
300 |
|
host: RemoteHost::new(ssh_target),
|
| 228 |
301 |
|
caps,
|
|
302 |
+ |
pull_root: None,
|
| 229 |
303 |
|
}
|
| 230 |
304 |
|
}
|
| 231 |
305 |
|
|
| 238 |
312 |
|
self
|
| 239 |
313 |
|
}
|
| 240 |
314 |
|
|
|
315 |
+ |
/// Confine this transport's artifact pulls to `root` — an absolute path as
|
|
316 |
+ |
/// seen ON THIS HOST (a remote root, so it is never tilde-expanded by the
|
|
317 |
+ |
/// caller). See [`gate_pull`]; required before any `pull_*` succeeds.
|
|
318 |
+ |
#[must_use]
|
|
319 |
+ |
pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
|
|
320 |
+ |
self.pull_root = Some(root.into());
|
|
321 |
+ |
self
|
|
322 |
+ |
}
|
|
323 |
+ |
|
| 241 |
324 |
|
pub fn ssh_target(&self) -> &str {
|
| 242 |
325 |
|
self.host.ssh_target()
|
| 243 |
326 |
|
}
|
| 253 |
336 |
|
}
|
| 254 |
337 |
|
|
| 255 |
338 |
|
async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
|
|
339 |
+ |
gate_pull(
|
|
340 |
+ |
&self.caps,
|
|
341 |
+ |
self.host.ssh_target(),
|
|
342 |
+ |
self.pull_root.as_deref(),
|
|
343 |
+ |
remote,
|
|
344 |
+ |
)?;
|
| 256 |
345 |
|
// No trailing slash: rsync copies the file itself, not "contents of".
|
| 257 |
346 |
|
let src = format!("{}:{}", self.host.ssh_target(), remote.display());
|
| 258 |
347 |
|
let ssh = Some(self.host.ssh_args());
|
| 264 |
353 |
|
}
|
| 265 |
354 |
|
|
| 266 |
355 |
|
async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
|
|
356 |
+ |
gate_pull(
|
|
357 |
+ |
&self.caps,
|
|
358 |
+ |
self.host.ssh_target(),
|
|
359 |
+ |
self.pull_root.as_deref(),
|
|
360 |
+ |
remote,
|
|
361 |
+ |
)?;
|
| 267 |
362 |
|
let src = format!("{}:{}/", self.host.ssh_target(), remote.display());
|
| 268 |
363 |
|
let ssh = Some(self.host.ssh_args());
|
| 269 |
364 |
|
run_rsync(
|
| 274 |
369 |
|
}
|
| 275 |
370 |
|
|
| 276 |
371 |
|
async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
|
|
372 |
+ |
gate_pull(
|
|
373 |
+ |
&self.caps,
|
|
374 |
+ |
self.host.ssh_target(),
|
|
375 |
+ |
self.pull_root.as_deref(),
|
|
376 |
+ |
Path::new(remote_glob),
|
|
377 |
+ |
)?;
|
| 277 |
378 |
|
// The REMOTE shell expands this one: rsync hands an un-`--protect-args`
|
| 278 |
379 |
|
// remote path to the login shell on the far side, so the wildcard must
|
| 279 |
380 |
|
// reach it intact — hence no quoting here. rsync exits non-zero when the
|
| 315 |
416 |
|
VecSink::default()
|
| 316 |
417 |
|
}
|
| 317 |
418 |
|
|
|
419 |
+ |
/// The grant a sync transport needs to pull artifacts: `observe:artifact`
|
|
420 |
+ |
/// and nothing else. Pair with `.with_pull_root(...)` in the pull tests.
|
|
421 |
+ |
fn artifact_caps() -> CapabilitySet {
|
|
422 |
+ |
CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"])
|
|
423 |
+ |
}
|
|
424 |
+ |
|
| 318 |
425 |
|
#[test]
|
| 319 |
426 |
|
fn render_plain_argv_quotes_each_token() {
|
| 320 |
427 |
|
let step = Step::new(Action::Build, ["echo", "a b", "c"]);
|
| 396 |
503 |
|
let dst = dir.path().join("dst");
|
| 397 |
504 |
|
tokio::fs::create_dir_all(&src).await.unwrap();
|
| 398 |
505 |
|
tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
|
| 399 |
|
- |
let exec = LocalExec::new(CapabilitySet::default());
|
|
506 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
|
| 400 |
507 |
|
exec.push_dir(&src, &mid, &SyncOpts::default())
|
| 401 |
508 |
|
.await
|
| 402 |
509 |
|
.unwrap();
|
| 416 |
523 |
|
let src = dir.path().join("dump.sql.gz");
|
| 417 |
524 |
|
let dst = dir.path().join("fetched.sql.gz");
|
| 418 |
525 |
|
tokio::fs::write(&src, b"DUMPBYTES").await.unwrap();
|
| 419 |
|
- |
let exec = LocalExec::new(CapabilitySet::default());
|
|
526 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
|
| 420 |
527 |
|
exec.pull_file(&src, &dst, &SyncOpts::default())
|
| 421 |
528 |
|
.await
|
| 422 |
529 |
|
.unwrap();
|
| 437 |
544 |
|
tokio::fs::create_dir_all(&src).await.unwrap();
|
| 438 |
545 |
|
tokio::fs::create_dir_all(&out).await.unwrap();
|
| 439 |
546 |
|
tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
|
| 440 |
|
- |
let exec = LocalExec::new(CapabilitySet::default());
|
|
547 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
|
| 441 |
548 |
|
exec.pull_file(&src, &out, &SyncOpts::default())
|
| 442 |
549 |
|
.await
|
| 443 |
550 |
|
.unwrap();
|
| 464 |
571 |
|
tokio::fs::write(src.join("b.msi"), b"B").await.unwrap();
|
| 465 |
572 |
|
tokio::fs::write(src.join("notes.txt"), b"N").await.unwrap();
|
| 466 |
573 |
|
|
| 467 |
|
- |
let exec = LocalExec::new(CapabilitySet::default());
|
|
574 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
|
| 468 |
575 |
|
let pattern = format!("{}/*.msi", src.display());
|
| 469 |
576 |
|
exec.pull_glob(&pattern, &out, &SyncOpts::default())
|
| 470 |
577 |
|
.await
|
| 483 |
590 |
|
let dir = tempfile::tempdir().unwrap();
|
| 484 |
591 |
|
let out = dir.path().join("dist");
|
| 485 |
592 |
|
tokio::fs::create_dir_all(&out).await.unwrap();
|
| 486 |
|
- |
let exec = LocalExec::new(CapabilitySet::default());
|
|
593 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
|
| 487 |
594 |
|
let pattern = format!("{}/nope/*.msi", dir.path().display());
|
| 488 |
595 |
|
let err = exec
|
| 489 |
596 |
|
.pull_glob(&pattern, &out, &SyncOpts::default())
|
| 502 |
609 |
|
tokio::fs::create_dir_all(&out).await.unwrap();
|
| 503 |
610 |
|
let f = dir.path().join("GoingsOn.dmg");
|
| 504 |
611 |
|
tokio::fs::write(&f, b"DMG").await.unwrap();
|
| 505 |
|
- |
let exec = LocalExec::new(CapabilitySet::default());
|
|
612 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
|
| 506 |
613 |
|
exec.pull_glob(&f.to_string_lossy(), &out, &SyncOpts::default())
|
| 507 |
614 |
|
.await
|
| 508 |
615 |
|
.unwrap();
|
| 512 |
619 |
|
);
|
| 513 |
620 |
|
}
|
| 514 |
621 |
|
|
|
622 |
+ |
/// The exfiltration the gate closes: a pull with no `observe:artifact`
|
|
623 |
+ |
/// grant is denied before any rsync spawns, even with a root set.
|
|
624 |
+ |
#[tokio::test]
|
|
625 |
+ |
async fn pull_denied_without_artifact_grant() {
|
|
626 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
627 |
+ |
let f = dir.path().join("secret");
|
|
628 |
+ |
tokio::fs::write(&f, b"S").await.unwrap();
|
|
629 |
+ |
// A build/sign grant is not an artifact-read grant.
|
|
630 |
+ |
let caps = CapabilitySet::from_tokens(["build", "sign"], ["build-log"]);
|
|
631 |
+ |
let exec = LocalExec::new(caps).with_pull_root(dir.path());
|
|
632 |
+ |
let err = exec
|
|
633 |
+ |
.pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
|
|
634 |
+ |
.await
|
|
635 |
+ |
.unwrap_err();
|
|
636 |
+ |
let denied = err
|
|
637 |
+ |
.downcast_ref::<CapabilityDenied>()
|
|
638 |
+ |
.expect("CapabilityDenied so a caller can audit-log it");
|
|
639 |
+ |
assert_eq!(denied.action, "observe:artifact");
|
|
640 |
+ |
assert!(!dir.path().join("out").exists(), "denied pull must not run");
|
|
641 |
+ |
}
|
|
642 |
+ |
|
|
643 |
+ |
/// Fail-closed: an executor with the grant but NO declared root pulls
|
|
644 |
+ |
/// nothing. This is what stops an un-configured host from being an open
|
|
645 |
+ |
/// read primitive.
|
|
646 |
+ |
#[tokio::test]
|
|
647 |
+ |
async fn pull_denied_without_pull_root() {
|
|
648 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
649 |
+ |
let f = dir.path().join("a.dmg");
|
|
650 |
+ |
tokio::fs::write(&f, b"D").await.unwrap();
|
|
651 |
+ |
let exec = LocalExec::new(artifact_caps()); // no with_pull_root
|
|
652 |
+ |
let err = exec
|
|
653 |
+ |
.pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
|
|
654 |
+ |
.await
|
|
655 |
+ |
.unwrap_err();
|
|
656 |
+ |
assert!(
|
|
657 |
+ |
err.to_string().contains("no artifact root declared"),
|
|
658 |
+ |
"{err}"
|
|
659 |
+ |
);
|
|
660 |
+ |
}
|
|
661 |
+ |
|
|
662 |
+ |
/// The `passwords.env` case: an authorized caller with a legitimate root
|
|
663 |
+ |
/// still cannot reach a sibling path outside it.
|
|
664 |
+ |
#[tokio::test]
|
|
665 |
+ |
async fn pull_denied_outside_declared_root() {
|
|
666 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
667 |
+ |
let root = dir.path().join("artifacts");
|
|
668 |
+ |
let secret = dir.path().join("passwords.env"); // sibling of root, not under it
|
|
669 |
+ |
tokio::fs::create_dir_all(&root).await.unwrap();
|
|
670 |
+ |
tokio::fs::write(&secret, b"NOTARY_PW=hunter2")
|
|
671 |
+ |
.await
|
|
672 |
+ |
.unwrap();
|
|
673 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
|
|
674 |
+ |
let err = exec
|
|
675 |
+ |
.pull_file(&secret, &dir.path().join("out"), &SyncOpts::default())
|
|
676 |
+ |
.await
|
|
677 |
+ |
.unwrap_err();
|
|
678 |
+ |
assert!(
|
|
679 |
+ |
err.to_string()
|
|
680 |
+ |
.contains("escapes the declared artifact root")
|
|
681 |
+ |
);
|
|
682 |
+ |
assert!(!dir.path().join("out").exists());
|
|
683 |
+ |
}
|
|
684 |
+ |
|
|
685 |
+ |
/// A `..` component is refused even when the resolved target would land back
|
|
686 |
+ |
/// inside the root — the check is lexical, so it never has to resolve it.
|
|
687 |
+ |
#[tokio::test]
|
|
688 |
+ |
async fn pull_denied_on_parent_dir_component() {
|
|
689 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
690 |
+ |
let root = dir.path().join("artifacts");
|
|
691 |
+ |
tokio::fs::create_dir_all(&root).await.unwrap();
|
|
692 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
|
|
693 |
+ |
let sneaky = root.join("..").join("passwords.env");
|
|
694 |
+ |
let err = exec
|
|
695 |
+ |
.pull_file(&sneaky, &dir.path().join("out"), &SyncOpts::default())
|
|
696 |
+ |
.await
|
|
697 |
+ |
.unwrap_err();
|
|
698 |
+ |
assert!(err.to_string().contains("contains `..`"), "{err}");
|
|
699 |
+ |
}
|
|
700 |
+ |
|
|
701 |
+ |
/// `starts_with` is component-wise, so a root prefix that is a string prefix
|
|
702 |
+ |
/// but NOT a path prefix (`/x/artifacts` vs `/x/artifacts-evil`) does not
|
|
703 |
+ |
/// leak. Pins that the confinement isn't a naive string compare.
|
|
704 |
+ |
#[tokio::test]
|
|
705 |
+ |
async fn pull_root_is_a_path_prefix_not_a_string_prefix() {
|
|
706 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
707 |
+ |
let root = dir.path().join("artifacts");
|
|
708 |
+ |
let evil = dir.path().join("artifacts-evil");
|
|
709 |
+ |
tokio::fs::create_dir_all(&root).await.unwrap();
|
|
710 |
+ |
tokio::fs::create_dir_all(&evil).await.unwrap();
|
|
711 |
+ |
let f = evil.join("x.dmg");
|
|
712 |
+ |
tokio::fs::write(&f, b"D").await.unwrap();
|
|
713 |
+ |
let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
|
|
714 |
+ |
let err = exec
|
|
715 |
+ |
.pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
|
|
716 |
+ |
.await
|
|
717 |
+ |
.unwrap_err();
|
|
718 |
+ |
assert!(
|
|
719 |
+ |
err.to_string()
|
|
720 |
+ |
.contains("escapes the declared artifact root")
|
|
721 |
+ |
);
|
|
722 |
+ |
}
|
|
723 |
+ |
|
| 515 |
724 |
|
#[test]
|
| 516 |
725 |
|
fn ssh_pull_glob_leaves_the_wildcard_for_the_remote_shell() {
|
| 517 |
726 |
|
// rsync hands an un---protect-args remote path to the far-side login
|