Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge the self-hosted runner's host-side hardening: a job's token is spent once its spec is fetched (rotated, with brokered secrets still found by the token the job started with), job folders a crashed runner left are removed, the runner's folders are owner-only on Windows and Unix, the sandbox warns of a host-mode runner under the same account, its WSL default user is a locked account rather than root, a VM's seed no longer keeps the registration token, and the self-hosted runners guide says what your computer can see.

syntaqxcommitted Parents4ffd59c495a2cbBrowse files
20 files+896−690/20 viewed
+7−2
769769 _ => (rest.to_owned(), "job_report"),
770770 };
771771 let body = json_body(&mut request).await;
772+ // `rotate`: a harness that takes a new token with the spec and
773+ // lets go of the one it asked with (plan.rs `job_spec`).
774+ let rotate = method == "job_spec" && body["rotate"].as_bool() == Some(true);
772775 let answered: Outcome<Value> = g1t_kit::call(
773776 &services.actions,
774777 method,
775− &json!({ "job": job, "token": body["token"], "report": body["report"] }),
778+ &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate }),
776779 )
777780 .await?;
778781 return match answered {
942945 #[test]
943946 fn a_job_spec_gets_the_toolkits_variables() {
944947 // As the actions service sends it, converted as the API does.
945− let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
948+ let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true }, "nextToken": "n" });
946949 let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN);
947950 super::with_runtime(&mut spec, "https://api.g1t.sh", true);
948951 assert!(spec.get("runtime").is_none(), "the runner never sees it");
952+ // The token that replaces the one the harness asked with.
953+ assert_eq!(spec["next_token"], "n");
949954 let vars = &spec["variables"];
950955 assert_eq!(vars["GITHUB_SHA"], "abc");
951956 assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s");
+13−0
213213 The sandbox also needs virtualization turned on in your computer's
214214 firmware (Intel VT-x or AMD-V), which most computers have on.
215215
216+The sandbox keeps jobs from your computer, not your computer from the
217+sandbox: anything running as your account can look inside it while a job
218+runs. So don't choose **Directly on this computer** under the same
219+account you use for the sandbox, and don't run software you don't trust
220+there. A runner registered for running jobs directly stays registered when
221+you choose another way, so remove it once you no longer use it:
222+`g1t-runner remove --dir <data folder>/host`, with the data folder below.
223+`g1t-runner sandbox status` warns while one is there. A job's secrets go from inside the sandbox
224+straight to g1t, the job's token left on your computer is spent once the
225+job has started, and the runner's folders are yours alone. [What your
226+computer can see](/guides/self-hosted-runners/#what-your-computer-can-see)
227+has the details.
228+
216229 For the sandbox, the app runs the same commands you would, with its own
217230 copy of `g1t-runner` and its state in the app's local data folder, which
218231 never roams to other computers with your profile
+63−2
313313 run on a Mac or a Linux machine without Sysbox. Windows is where the
314314 sandbox has been checked end to end.
315315
316+### What your computer can see
317+
318+The sandbox keeps jobs from reaching your computer. It does not keep your
319+computer from looking at the sandbox, and so at what a job has while it
320+runs:
321+
322+- **A job's log and secrets go from inside the sandbox straight to g1t.**
323+ The runner inside fetches the job, its secrets and its `G1T_TOKEN`
324+ itself, and sends its log back the same way, masked. None of it passes
325+ through a log or file of your computer's.
326+- **Anything running as your account can look inside a running
327+ sandbox**, as you can: open the WSL distribution with `wsl -u root`,
328+ run a command in a Windows Sandbox, or read the VM's disk. So can an
329+ administrator, root in another WSL distribution (all of WSL 2 shares one
330+ VM), and on Linux with Sysbox anyone in the `docker` group. Don't run a
331+ [host-mode](#host-mode-is-deprecated) runner, or software you don't
332+ trust, under the account the sandbox runs as: what its jobs run could
333+ read the sandbox's runner credentials and the secrets of the job
334+ running in it. `g1t-runner sandbox status` warns when it finds a
335+ host-mode runner registered for the same account (in `~/.g1t-runner`,
336+ `G1T_RUNNER_DIR`, or the desktop app's folder for running jobs directly
337+ on the computer). Remove it with `g1t-runner remove --dir <its folder>`,
338+ or register it under another account.
339+- **The job's token on your computer is spent after its first use.** A
340+ workflow job reaches its runner with a token of its own, which sits
341+ where your computer can see it: in a Windows job's script, in the job
342+ folder Windows Sandbox reads, and in a container's environment. The
343+ first thing the runner in the sandbox does is fetch the job with it,
344+ and g1t answers with a new token that only the sandbox has; the first
345+ stops working at once for everything that can be asked through g1t's
346+ API (inside g1t, only the lookup of [brokered
347+ secrets](/guides/secrets-and-variables/#brokered-secrets) still takes it, and that is not reachable
348+ from your computer or a job). A copy left
349+ behind can't fetch the job, its secrets or a `G1T_TOKEN` again. A job's
350+ folder is deleted when the job ends, and a runner that was stopped
351+ mid-job (a crash, or `taskkill`) deletes what it left when it next
352+ starts.
353+- **The registration token goes once the runner has registered.** It is
354+ kept only until the runner inside has registered with it; then the
355+ launcher takes it out of the VM's seed disk on macOS and Linux, and the
356+ sandbox out of its own settings. On Windows, an `update` keeps the
357+ runner's registration on your computer only while the VM is swapped.
358+- **The state folders are yours alone.** The runner's folder
359+ (`~/.g1t-runner`, `--dir` or `G1T_RUNNER_DIR`) and the sandbox's
360+ (`--state-dir`) hold the runners' credentials, a Windows job's script
361+ while it runs, and the VM's disk. On macOS and Linux they are made
362+ `0700` and their files `0600`. On Windows a folder in your profile is
363+ already yours alone; one elsewhere, such as `C:\runner`, would let every
364+ user of the computer read it, so the runner makes it private (you,
365+ SYSTEM and Administrators, and nothing inherited from the folder
366+ above). If it can't, it refuses to start and says how to fix it.
367+- **On Windows, opening the sandbox's distribution without naming a user**
368+ (Explorer at `\\wsl.localhost\g1t-sandbox`, or `wsl -d g1t-sandbox`)
369+ gets a locked account with no shell, which can't read the runner's
370+ registration. The launcher names root itself for what it does there.
371+
316372 ### The status as JSON
317373
318374 `g1t-runner sandbox status --json` prints one JSON object, which [the
341397 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
342398 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
343399 ],
344− "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox"
400+ "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox",
401+ "warnings": []
345402 }
346403 ```
347404
354411 | `error` | A sentence for a person when something is wrong, or null. |
355412 | `capabilities` | Each kind of job the computer can run sandboxed: `linux`, and `windows` or `macos`. `available` says whether it can be had here; when not, `cause` says why and `fix` what to do. Each has its own `state`, `job` and `runner_version`. A native one whose runner didn't start is in `error`, with why in `cause`. |
356413 | `turned_off` | On each capability: someone stopped it (`down --only`, `--no-native`). Its `state` is then `stopped`: stopped by a person, not failing, and nothing in `error`. |
414+| `warnings` | Sentences about what on the computer weakens the sandbox ([what your computer can see](#what-your-computer-can-see)): a host-mode runner registered for the same account, or a state folder other users can read. Empty when nothing does; left out by earlier versions of `g1t-runner`. `status` without `--json` prints each as a line starting `Warning:`. |
357415
358416 Fields are only added; `schema` changes if one changes meaning.
359417
661719 given and remove itself, and nothing else; every other endpoint refuses
662720 it. It is kept only as a hash on g1t's side, rotates every day, and stops
663721 working the moment the runner is removed. It is never given to a job:
664− each job gets only its own short-lived token.
722+ each job gets only its own short-lived token, which the job spends on
723+ fetching itself and replaces with one only the job has. The runner's
724+ folder is its owner's alone, and the runner refuses to start in one
725+ other users can read ([what your computer can see](#what-your-computer-can-see)).
665726 - **Isolation**: run the runner's image under Sysbox. Each job then gets
666727 a container of its own inside the runner's container, removed after,
667728 and neither reaches the machine. A job with `--no-docker` runs as the
+6−0
991991 pub token: String,
992992 #[serde(default)]
993993 pub report: Value,
994+ /// `job_spec` only: the harness takes a new token with the spec, and
995+ /// the one it asked with stops working, so a copy of it left on the
996+ /// runner's computer cannot fetch the job's secrets again. Harnesses
997+ /// that predate it never ask, and keep their token.
998+ #[serde(default)]
999+ pub rotate: bool,
9941000 }
9951001
9961002 /// What the runner needs to start a job's sandbox.
+12−2
3535 use report::{Api, Log};
3636
3737 const TEMP: &str = "/home/runner/_temp";
38+/// Set to `1` by the self-hosted runner: the job's token is spent on its
39+/// spec, and the harness carries on with the one that replaces it
40+/// (`report::Api::spec`). A `G1T_` variable, so no step sees it.
41+pub(crate) const ROTATE_TOKEN: &str = "G1T_ROTATE_JOB_TOKEN";
3842
3943 /// Who is running steps: the job itself, or a composite action inside it.
4044 #[derive(Clone, Default)]
12091213 }
12101214
12111215 pub(crate) fn main() -> i32 {
1212− let api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
1216+ let mut api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
12131217 (Ok(base), Ok(job), Ok(token)) => Api { base, job, token },
12141218 _ => {
12151219 eprintln!("g1t-runner: G1T_API, ACTIONS_JOB and ACTIONS_TOKEN are needed");
12161220 return 2;
12171221 }
12181222 };
1219− let spec = match api.spec() {
1223+ // A self-hosted runner asks for the token to be spent on the spec
1224+ // (selfhosted/exec.rs): there, the token it was given sits where the
1225+ // computer can see it (a Windows job's script, a container's
1226+ // environment). g1t's own sandboxes keep theirs to report a sandbox
1227+ // that stopped, and do not ask.
1228+ let rotate = std::env::var(ROTATE_TOKEN).is_ok_and(|v| v == "1");
1229+ let spec = match api.spec(rotate) {
12201230 Ok(spec) => spec,
12211231 Err(error) => {
12221232 eprintln!("g1t-runner: could not fetch the job: {error:#}");
+41−5
7979 }
8080
8181 impl Api {
82− pub(crate) fn spec(&self) -> Result<Value> {
83− let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job))
84− .timeout(Duration::from_secs(60))
85− .send_json(json!({ "token": self.token }))?;
86− Ok(response.into_json()?)
82+ /// The job's spec. With `rotate`, g1t spends the token it was asked
83+ /// with and sends a new one beside the spec, which this takes for
84+ /// everything after (`next_token`): a copy of the first left on the
85+ /// runner's computer cannot fetch the job, its secrets or a G1T_TOKEN
86+ /// again. A g1t that predates it sends none, and the token stays.
87+ pub(crate) fn spec(&mut self, rotate: bool) -> Result<Value> {
88+ let mut body = json!({ "token": self.token });
89+ if rotate {
90+ body["rotate"] = json!(true);
91+ }
92+ let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)).timeout(Duration::from_secs(60)).send_json(body)?;
93+ let mut spec: Value = response.into_json()?;
94+ self.take_next_token(&mut spec);
95+ Ok(spec)
96+ }
97+
98+ /// Takes the token a spec carries in place of the one it was fetched
99+ /// with, and masks it in the log.
100+ fn take_next_token(&mut self, spec: &mut Value) {
101+ let next = spec.as_object_mut().and_then(|s| s.remove("next_token").or_else(|| s.remove("nextToken")));
102+ if let Some(next) = next.as_ref().and_then(Value::as_str).filter(|t| !t.is_empty()) {
103+ self.token = next.to_owned();
104+ if let Some(masks) = spec.get_mut("masks").and_then(Value::as_array_mut) {
105+ masks.push(json!(next));
106+ }
107+ }
87108 }
88109
89110 /// Where to fetch another repository's action from: `{"source": "g1t",
442463 use super::*;
443464
444465 #[test]
466+ fn the_token_a_spec_carries_replaces_the_one_it_was_fetched_with() {
467+ let mut api = Api::offline();
468+ let mut spec = json!({ "job": "job_1", "masks": ["s3cret"], "next_token": "fresh" });
469+ api.take_next_token(&mut spec);
470+ assert_eq!(api.token, "fresh");
471+ assert!(spec.get("next_token").is_none(), "the job's steps never see it");
472+ assert_eq!(spec["masks"], json!(["s3cret", "fresh"]));
473+ // A g1t that does not rotate sends none: the token stays.
474+ let mut spec = json!({ "job": "job_1", "masks": [] });
475+ api.take_next_token(&mut spec);
476+ assert_eq!(api.token, "fresh");
477+ assert_eq!(spec["masks"], json!([]));
478+ }
479+
480+ #[test]
445481 fn every_line_is_stamped_with_the_time_as_github_stamps_it() {
446482 assert_eq!(stamp(UNIX_EPOCH), "1970-01-01T00:00:00.0000000Z");
447483 assert_eq!(stamp(UNIX_EPOCH + Duration::new(1_791_797_067, 123_456_789)), "2026-10-12T09:24:27.1234567Z");
+5−13
9999 serde_json::from_str(&text).with_context(|| format!("{} does not read", file.display()))
100100 }
101101
102−/// Writes the configuration so only its owner can read it, through a
103−/// temporary file, so a crash never leaves half a credential.
102+/// Writes the configuration so only its owner can read it, in a folder
103+/// only its owner can read (private.rs), through a temporary file, so a
104+/// crash never leaves half a credential.
104105 pub fn save(folder: &Path, config: &Config) -> Result<()> {
105− std::fs::create_dir_all(folder).with_context(|| format!("could not make {}", folder.display()))?;
106− let file = path(folder);
107− let temp = folder.join("config.json.new");
108− std::fs::write(&temp, serde_json::to_string_pretty(config)?)?;
109− #[cfg(unix)]
110− {
111− use std::os::unix::fs::PermissionsExt;
112− std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
113− }
114− std::fs::rename(&temp, &file).with_context(|| format!("could not write {}", file.display()))?;
115− Ok(())
106+ super::private::folder(folder, "--dir")?;
107+ super::private::write_private(&path(folder), serde_json::to_string_pretty(config)?.as_bytes())
116108 }
117109
118110 pub fn forget(folder: &Path) {
+8−0
203203 env.insert("G1T_API".into(), config.api.clone());
204204 env.insert("ACTIONS_JOB".into(), work.id.clone());
205205 env.insert("ACTIONS_TOKEN".into(), token.clone());
206+ // That token is where this computer can see it (a Windows job's
207+ // script in the job's folder, a container's environment): the
208+ // harness spends it on fetching the job and works on with a new
209+ // one only it has.
210+ env.insert(crate::actions::ROTATE_TOKEN.into(), "1".into());
206211 }
207212 "agent" => {
208213 for (name, value) in work.env.clone().unwrap_or_default() {
622627 assert_eq!(env["ACTIONS_TOKEN"], "jobtoken");
623628 assert_eq!(env["G1T_API"], "https://api.g1t.sh");
624629 assert!(env.values().all(|value| !value.contains("g1tr_")));
630+ // That token is spent on fetching the job.
631+ assert_eq!(env[crate::actions::ROTATE_TOKEN], "1");
632+ assert!(!harness_env(&config(), &assignment("agent")).unwrap().contains_key(crate::actions::ROTATE_TOKEN));
625633 }
626634
627635 #[test]
+7−1
852852 #[test]
853853 fn the_image_turns_off_every_way_to_windows() {
854854 let conf = WSL_CONF.replace(' ', "");
855− for setting in ["[automount]\nenabled=false", "mountFsTab=false", "[interop]\nenabled=false", "appendWindowsPath=false", "default=root"] {
855+ for setting in ["[automount]\nenabled=false", "mountFsTab=false", "[interop]\nenabled=false", "appendWindowsPath=false", "[user]\ndefault=sandbox"] {
856856 assert!(conf.contains(setting), "{setting}");
857857 }
858+ // What opens the distribution without naming a user is not root;
859+ // the launcher names root on every call (wsl.rs).
860+ assert!(!conf.contains("default=root"));
861+ let dockerfile = include_str!("../../../../../deploy/sandbox/Dockerfile");
862+ assert!(dockerfile.contains("useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin sandbox"));
863+ assert!(dockerfile.contains("passwd --lock sandbox"));
858864 }
859865
860866 #[test]
+127−28
6868 //! "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
6969 //! "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, …" }
7070 //! ],
71−//! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox"
71+//! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
72+//! "warnings": []
7273 //! }
7374 //! ```
7475 //!
7778 //! or `error`; `error` is a sentence for a person, set whenever something
7879 //! is wrong, and `cause`/`fix` say why a capability is unavailable, or for
7980 //! a native one in `error` why it didn't start, and what to do.
81+//! `warnings` holds sentences about what on the computer weakens the
82+//! sandbox: a runner of the same account registered for host mode, whose
83+//! jobs could look inside it, or a state folder others can read. It is
84+//! empty when nothing does, and left out by launchers before it.
8085 //! `turned_off` is true for a capability someone stopped (`down --only`,
8186 //! `--no-native`), whose state is then `stopped`: stopped by a person, not
8287 //! failing. `job` is what the runner is running now, or null. Fields
102107 use serde::{Deserialize, Serialize};
103108
104109 use super::native::{Availability, MacVm, WsbFound};
110+use super::private::{self, write_private};
105111 use super::{JobStatus, Options, RunnerStatus, VERSION, config, log};
106112 use guest::{GuestStatus, Settings};
107113
248254 pub resources: Resources,
249255 pub capabilities: Vec<Capability>,
250256 pub state_dir: String,
257+ /// Sentences about what on the computer weakens the sandbox (a
258+ /// host-mode runner of the same account, a state folder others can
259+ /// read); empty when nothing does. Older launchers leave it out.
260+ #[serde(default)]
261+ pub warnings: Vec<String>,
251262 }
252263
253264 /// One kind of job this computer can run sandboxed.
267278 pub turned_off: bool,
268279 }
269280
270−pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
271− let temp = path.with_extension("new");
272− std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?;
273− #[cfg(unix)]
274− {
275− use std::os::unix::fs::PermissionsExt;
276− std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
277− }
278− std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?;
279− Ok(())
280−}
281−
282281 // --- The computer -------------------------------------------------------------
283282
284283 /// Whether a process is still running.
655654 resources: saved.resources,
656655 capabilities,
657656 state_dir: dir.display().to_string(),
657+ warnings: warnings(dir),
658+ }
659+}
660+
661+// --- What weakens it ---------------------------------------------------------------
662+
663+/// What on this computer lets someone look inside the sandbox: a runner of
664+/// this account's that runs jobs as it, and a state folder others can read.
665+fn warnings(dir: &Path) -> Vec<String> {
666+ let mut said: Vec<String> = host_mode_runners(&host_runner_folders()).iter().map(|folder| host_mode_warning(folder)).collect();
667+ if dir.exists() {
668+ let readers = private::readers(dir);
669+ if !readers.is_empty() {
670+ said.push(format!(
671+ "{} can be read by {}: the runners' credentials and the VM's disk are in it. The next `g1t-runner sandbox up` makes it private, or refuses to start.",
672+ dir.display(),
673+ readers.join(", ")
674+ ));
675+ }
658676 }
677+ said
659678 }
660679
680+/// Where a runner of this account's may be registered: `g1t-runner`'s own
681+/// folder (`G1T_RUNNER_DIR`, `~/.g1t-runner`), and the desktop app's for
682+/// running jobs directly on the computer.
683+fn host_runner_folders() -> Vec<PathBuf> {
684+ let mut folders = vec![config::folder(None)];
685+ let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
686+ if let Some(home) = &home {
687+ folders.push(home.join(".g1t-runner"));
688+ }
689+ // The desktop app's data folder (its `app_data_dir`): runner/host.
690+ let data = match std::env::consts::OS {
691+ "windows" => std::env::var_os("APPDATA").map(PathBuf::from),
692+ "macos" => home.as_ref().map(|h| h.join("Library").join("Application Support")),
693+ _ => std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(PathBuf::from).or_else(|| home.as_ref().map(|h| h.join(".local").join("share"))),
694+ };
695+ if let Some(data) = data {
696+ folders.push(data.join("sh.g1t.desktop").join("runner").join("host"));
697+ }
698+ folders.dedup();
699+ folders
700+}
701+
702+/// Those of `folders` that hold a runner registered for host mode: jobs as
703+/// processes of this account, neither in a container nor in a VM.
704+fn host_mode_runners(folders: &[PathBuf]) -> Vec<PathBuf> {
705+ folders.iter().filter(|folder| config::load(folder).is_ok_and(|c| !c.docker && !c.native)).cloned().collect()
706+}
707+
708+fn host_mode_warning(folder: &Path) -> String {
709+ format!(
710+ "A runner registered in {} runs jobs directly on this computer, as you (host mode). Whenever it runs, what its jobs run can read this sandbox's runner credentials and reach inside the sandbox, as you can. Remove it (`g1t-runner remove --dir {}`), or register it under another account.",
711+ folder.display(),
712+ folder.display()
713+ )
714+}
715+
661716 /// The native capability's state: running while its runner lives;
662717 /// stopped when turned off, or registered and not running; `error` when
663718 /// it didn't start; `absent` before it is registered.
701756 }
702757 }
703758 }
759+ for warning in &status.warnings {
760+ println!("Warning: {warning}");
761+ }
704762 if let Some(error) = &status.error {
705763 println!("Error: {error}");
706764 }
771829 };
772830 let json = options.flag("json");
773831 let dir = options.value("state-dir").map(PathBuf::from).unwrap_or_else(|| config::folder(None).join("sandbox"));
832+ // The state folder holds the runners' credentials and the VM's disk:
833+ // only this account's, or nothing is made or started in it.
834+ let private = || private::folder(&dir, "--state-dir");
774835 let outcome = match args.first().map(String::as_str) {
775− Some("up") => up(&options, &dir),
776− Some("down") => down(&options, &dir),
777− Some("update") => update_sandbox(&options, &dir),
836+ Some("up") => private().and_then(|()| up(&options, &dir)),
837+ Some("down") => private().and_then(|()| down(&options, &dir)),
838+ Some("update") => private().and_then(|()| update_sandbox(&options, &dir)),
778839 Some("status") => Ok(()),
779840 _ => {
780841 println!("{HELP}");
889950 if local.runner_image.is_some() {
890951 saved.runner_image = local.runner_image.clone();
891952 }
892− // An update that stopped half-way left the registration here.
893− let carried = dir.join("carry").join("config.json");
894− if let Ok(bytes) = std::fs::read(&carried) {
895− backend.import_state(&bytes)?;
896− let _ = std::fs::remove_file(&carried);
897− }
898953 }
954+ // An update that stopped half-way, after the old system was
955+ // removed, left the registration here.
956+ let carried = dir.join("carry").join("config.json");
957+ if let Ok(bytes) = std::fs::read(&carried) {
958+ backend.import_state(&bytes)?;
959+ let _ = std::fs::remove_dir_all(dir.join("carry"));
960+ }
899961 if let Some(tar) = options.value("runner-image-file") {
900962 backend.load_image(Path::new(tar))?;
901963 }
10631125 log("Draining: the runner finishes the job it is running, then stops.");
10641126 backend.stop(StopMode::Drain)?;
10651127 }
1066− // The registration, kept on the computer until the new sandbox has it.
1067− let carried = dir.join("carry").join("config.json");
1128+ // The registration, kept on the computer (in the private state
1129+ // folder) only while no sandbox has it: from just before the old
1130+ // system goes until the new one has it. Should the update stop in
1131+ // between, the next `up` puts it in the new system and removes it.
1132+ let carry = dir.join("carry");
1133+ let carried = carry.join("config.json");
10681134 if let Some(bytes) = backend.export_state()? {
1069− std::fs::create_dir_all(dir.join("carry"))?;
1135+ std::fs::create_dir_all(&carry)?;
10701136 write_private(&carried, &bytes)?;
10711137 }
1072− backend.remove_system()?;
1138+ if let Err(error) = backend.remove_system() {
1139+ // The old system, and the registration in it, are still there.
1140+ let _ = std::fs::remove_dir_all(&carry);
1141+ return Err(error);
1142+ }
10731143 backend.create(&local, &saved.resources)?;
10741144 if let Ok(bytes) = std::fs::read(&carried) {
10751145 backend.import_state(&bytes)?;
1076− let _ = std::fs::remove_file(&carried);
10771146 }
1147+ let _ = std::fs::remove_dir_all(&carry);
10781148 if let Some(tar) = options.value("runner-image-file") {
10791149 backend.load_image(Path::new(tar))?;
10801150 }
11501220 ..Status::default()
11511221 };
11521222 let value = serde_json::to_value(&status).unwrap();
1153− for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir"] {
1223+ for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir", "warnings"] {
11541224 assert!(value.get(field).is_some(), "{field}");
11551225 }
11561226 for field in ["id", "available", "state", "isolation", "runner_version", "job", "cause", "fix", "turned_off"] {
12141284 }
12151285
12161286 #[test]
1287+ fn a_host_mode_runner_of_the_same_account_is_warned_of() {
1288+ let root = std::env::temp_dir().join(format!("g1t-sandbox-host-{}", std::process::id()));
1289+ let registered = |name: &str, how: &str| {
1290+ let folder = root.join(name);
1291+ std::fs::create_dir_all(&folder).unwrap();
1292+ let config = format!(
1293+ r#"{{"url":"https://g1t.sh","api":"https://api.g1t.sh","runner":"rnr_1","name":"a","credential":"g1tr_x","workspace":"acme","labels":[],"work_dir":"/tmp/w"{how}}}"#
1294+ );
1295+ std::fs::write(folder.join("config.json"), config).unwrap();
1296+ folder
1297+ };
1298+ let host = registered("host", r#","docker":false"#);
1299+ let docker = registered("docker", "");
1300+ let native = registered("native", r#","docker":false,"native":true"#);
1301+ let none = root.join("none");
1302+ let found = host_mode_runners(&[host.clone(), docker, native, none]);
1303+ assert_eq!(found.len(), 1);
1304+ assert_eq!(found[0], host);
1305+ let said = host_mode_warning(&host);
1306+ assert!(said.contains("as you") && said.contains("g1t-runner remove --dir"), "{said}");
1307+ // The desktop app's folder for running jobs directly is looked at.
1308+ assert!(host_runner_folders().iter().any(|f| f.ends_with(Path::new("sh.g1t.desktop").join("runner").join("host"))));
1309+ // An older status, without warnings, still reads.
1310+ let old: Status = serde_json::from_str(r#"{"schema":1,"state":"absent","backend":null,"runner_version":null,"launcher_version":"0.3.0","image_version":null,"isolation":null,"job":null,"error":null,"resources":{"cpus":0,"memory_mib":0,"disk_gib":0},"capabilities":[],"state_dir":"x"}"#).unwrap();
1311+ assert!(old.warnings.is_empty());
1312+ let _ = std::fs::remove_dir_all(&root);
1313+ }
1314+
1315+ #[test]
12171316 fn this_process_is_alive() {
12181317 assert!(process_alive(std::process::id()));
12191318 assert!(!process_alive(u32::MAX - 1));
+38−1
191191 Paths::in_dir(&self.dir.join("vm"))
192192 }
193193
194+ /// Takes the registration token out of the seed disk, once the runner
195+ /// has registered with it. The guest reads the seed only as it boots,
196+ /// and a token it lacks then is one it no longer needs.
197+ fn forget_token(&self) {
198+ let seed = self.paths().seed;
199+ let Some(mut settings) = std::fs::read(&seed).ok().and_then(|bytes| guest::read_seed(&bytes).ok()) else { return };
200+ if settings.token.take().is_some()
201+ && let Ok(bytes) = guest::write_seed(&settings, SEED_BYTES)
202+ {
203+ let _ = super::write_private(&seed, &bytes);
204+ }
205+ }
206+
194207 fn pid(&self) -> Option<u32> {
195208 std::fs::read_to_string(self.paths().pid).ok().and_then(|p| p.trim().parse().ok())
196209 }
335348 let log = std::fs::read(self.paths().console).ok()?;
336349 // The console's tail is enough: statuses come every few seconds.
337350 let tail = &log[log.len().saturating_sub(64 * 1024)..];
338− guest::status_from_console(&String::from_utf8_lossy(tail))
351+ let status = guest::status_from_console(&String::from_utf8_lossy(tail))?;
352+ // Registered: the token is no longer kept here either, as the
353+ // guest no longer keeps its own copy (guest.rs).
354+ if status.runner.is_some() {
355+ self.forget_token();
356+ }
357+ Some(status)
339358 }
340359
341360 fn stop(&self, mode: StopMode) -> Result<()> {
445464 }
446465
447466 #[test]
467+ fn the_seed_lets_go_of_the_token_once_the_runner_registered() {
468+ let dir = std::env::temp_dir().join(format!("g1t-vm-seed-{}", std::process::id()));
469+ let vm = DiskVm { hypervisor: Hypervisor::Qemu, dir: dir.clone() };
470+ std::fs::create_dir_all(dir.join("vm")).unwrap();
471+ let settings = Settings { url: Some("https://g1t.sh".into()), token: Some("g1trt_x".into()), runner_image: "img@sha256:ab".into(), ..Settings::default() };
472+ std::fs::write(vm.paths().seed, guest::write_seed(&settings, SEED_BYTES).unwrap()).unwrap();
473+ // The console says the runner is registered and running.
474+ let status = GuestStatus { state: "running".into(), runner: Some(crate::selfhosted::RunnerStatus::default()), ..GuestStatus::default() };
475+ std::fs::write(vm.paths().console, format!("boot\n{}{}\n", guest::STATUS_PREFIX, serde_json::to_string(&status).unwrap())).unwrap();
476+ assert!(vm.guest_status().is_some_and(|s| s.runner.is_some()));
477+ let kept = guest::read_seed(&std::fs::read(vm.paths().seed).unwrap()).unwrap();
478+ assert_eq!(kept.token, None);
479+ assert_eq!(kept.url.as_deref(), Some("https://g1t.sh"));
480+ assert_eq!(kept.runner_image, "img@sha256:ab");
481+ let _ = std::fs::remove_dir_all(&dir);
482+ }
483+
484+ #[test]
448485 fn a_vm_needs_its_three_files() {
449486 let vm = DiskVm { hypervisor: Hypervisor::Vfkit, dir: PathBuf::from("/s") };
450487 let keys = vm.image_keys();
+4−0
3030 mod exec;
3131 mod launcher;
3232 mod native;
33+mod private;
3334 mod sandbox;
3435 mod service;
3536 mod update;
454455 }
455456
456457 fn run(folder: &Path, once: bool) -> Result<()> {
458+ // Its folder holds its credential and, while a Windows job runs, that
459+ // job's script: a runner whose folder others can read does not start.
457460 let mut config = config::load(folder)?;
461+ private::folder(folder, "--dir")?;
458462 let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
459463 std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?;
460464 let isolation = isolation(config.docker, config.native)?;
+34−1
271271 folder.join("native").join("sandbox-id")
272272 }
273273
274−/// Stops the sandbox an earlier run of this runner left, if any.
274+/// Stops the sandbox an earlier run of this runner left, if any, and
275+/// removes the job folders it left: a runner stopped mid-job (a crash, or
276+/// `taskkill /F`) never cleaned its own, and a job's script holds the
277+/// job's token.
275278 pub fn sweep(folder: &Path) {
276279 let note = noted(folder);
277280 if let Ok(id) = std::fs::read_to_string(&note) {
281284 }
282285 let _ = std::fs::remove_file(&note);
283286 }
287+ for left in job_folders(&folder.join("native")) {
288+ let _ = std::fs::remove_dir_all(left);
289+ }
284290 }
285291
292+/// The job folders in a runner's `native` folder: `g1t-<job id>`, as
293+/// exec.rs names them.
294+pub fn job_folders(native: &Path) -> Vec<PathBuf> {
295+ let Ok(entries) = std::fs::read_dir(native) else { return Vec::new() };
296+ entries
297+ .flatten()
298+ .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()) && entry.file_name().to_string_lossy().starts_with("g1t-"))
299+ .map(|entry| entry.path())
300+ .collect()
301+}
302+
286303 /// The ids in `wsb list --raw`.
287304 pub fn parse_list(raw: &str) -> Vec<String> {
288305 let Some(start) = raw.find('{') else { return Vec::new() };
387404 }
388405
389406 #[test]
407+ fn a_runner_stopped_mid_job_has_its_job_folders_swept() {
408+ let folder = std::env::temp_dir().join(format!("g1t-native-sweep-{}", std::process::id()));
409+ let native = folder.join("native");
410+ std::fs::create_dir_all(native.join("g1t-job_1")).unwrap();
411+ std::fs::write(native.join("g1t-job_1").join("job.ps1"), "${env:ACTIONS_TOKEN} = 't'").unwrap();
412+ std::fs::create_dir_all(native.join("native")).unwrap();
413+ std::fs::write(native.join("runner.log"), "").unwrap();
414+ assert_eq!(job_folders(&native), [native.join("g1t-job_1")]);
415+ // No sandbox was noted, so nothing is stopped; the job's folder goes.
416+ sweep(&folder);
417+ assert!(!native.join("g1t-job_1").exists());
418+ assert!(native.join("native").exists() && native.join("runner.log").exists(), "only job folders");
419+ let _ = std::fs::remove_dir_all(&folder);
420+ }
421+
422+ #[test]
390423 fn wsb_output_reads() {
391424 assert_eq!(parse_exit_code("{\n \"ExitCode\": 7\n}\n"), Some(7));
392425 assert_eq!(parse_exit_code("nothing"), None);
+360−0
1+//! The runner's folders, kept to the account that runs it. A runner's
2+//! folder holds its credential (`config.json`), a Windows job's script
3+//! while the job runs, and a sandbox's disk; anyone else on the computer
4+//! who could read them could act as the runner, or look at a job.
5+//!
6+//! On Unix a folder is `0700` and its files `0600`. On Windows a folder's
7+//! permissions come from the folder it is made in: in the user's profile
8+//! (the default, `~/.g1t-runner`) only its owner, SYSTEM and Administrators
9+//! can read it, but one made at `C:\runner` (`--dir`, `G1T_RUNNER_DIR`,
10+//! `--state-dir`) is readable by every user of the computer. So the folder
11+//! is made private when it is not, and the runner refuses to use one that
12+//! stays readable by others.
13+
14+use std::path::Path;
15+
16+use anyhow::{Context, Result, bail};
17+
18+/// Writes a file only its owner can read, through a temporary file, so a
19+/// crash never leaves half of it. On Windows its permissions are its
20+/// folder's ([`folder`]).
21+pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
22+ let temp = path.with_extension("new");
23+ std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?;
24+ #[cfg(unix)]
25+ {
26+ use std::os::unix::fs::PermissionsExt;
27+ std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
28+ }
29+ std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?;
30+ Ok(())
31+}
32+
33+/// Makes `dir` if it is not there, and makes sure only this account (and,
34+/// on Windows, SYSTEM and Administrators) can read it: tightened if it is
35+/// open to others, and refused if it stays so. `option` is how a person
36+/// picks another folder (`--dir`, `--state-dir`).
37+pub fn folder(dir: &Path, option: &str) -> Result<()> {
38+ std::fs::create_dir_all(dir).with_context(|| format!("could not make {}", dir.display()))?;
39+ let open = readers(dir);
40+ if open.is_empty() {
41+ return Ok(());
42+ }
43+ let tightened = tighten(dir);
44+ let still = readers(dir);
45+ if still.is_empty() {
46+ return Ok(());
47+ }
48+ let why = tightened.err().map(|e| format!(" ({e:#})")).unwrap_or_default();
49+ bail!(
50+ "{} can be read by {}, and could not be made private to this account{why}. It holds the runner's credential: {}, or pick a folder in your own profile with {option}",
51+ dir.display(),
52+ still.join(", "),
53+ fix(dir)
54+ )
55+}
56+
57+/// Who other than this account can read `dir`, as a person names them;
58+/// empty when no one.
59+pub fn readers(dir: &Path) -> Vec<String> {
60+ #[cfg(unix)]
61+ {
62+ use std::os::unix::fs::PermissionsExt;
63+ let Ok(meta) = std::fs::metadata(dir) else { return Vec::new() };
64+ unix_readers(meta.permissions().mode())
65+ }
66+ #[cfg(windows)]
67+ {
68+ match windows::sddl(dir) {
69+ Some(sddl) => broad_readers(&sddl),
70+ None => Vec::new(),
71+ }
72+ }
73+}
74+
75+#[cfg(unix)]
76+fn tighten(dir: &Path) -> Result<()> {
77+ use std::os::unix::fs::PermissionsExt;
78+ std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context(|| format!("could not chmod {}", dir.display()))
79+}
80+
81+#[cfg(windows)]
82+fn tighten(dir: &Path) -> Result<()> {
83+ let Some(user) = windows::user_sid() else { bail!("could not tell which account this is") };
84+ for args in icacls_args(&dir.display().to_string(), &user) {
85+ let mut command = std::process::Command::new("icacls");
86+ command.args(&args).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());
87+ super::native::hide_window(&mut command);
88+ let output = command.output().context("could not run icacls")?;
89+ if !output.status.success() {
90+ bail!("icacls: {}", String::from_utf8_lossy(&output.stderr).trim());
91+ }
92+ }
93+ Ok(())
94+}
95+
96+fn fix(dir: &Path) -> String {
97+ if cfg!(windows) {
98+ format!("in {}'s Properties, Security, Advanced, turn off inheritance and leave only yourself, SYSTEM and Administrators", dir.display())
99+ } else {
100+ format!("run `chmod 700 {}` as its owner", dir.display())
101+ }
102+}
103+
104+/// Who a Unix folder's mode lets in besides its owner.
105+#[cfg_attr(not(unix), allow(dead_code))]
106+fn unix_readers(mode: u32) -> Vec<String> {
107+ let mut who = Vec::new();
108+ if mode & 0o040 != 0 {
109+ who.push("its group".to_owned());
110+ }
111+ if mode & 0o004 != 0 {
112+ who.push("every user".to_owned());
113+ }
114+ who
115+}
116+
117+/// The well-known groups that mean every (or any) user of a Windows
118+/// computer, by SID and by SDDL's short name.
119+#[cfg_attr(not(windows), allow(dead_code))]
120+const BROAD: &[(&str, &str, &str)] = &[
121+ ("S-1-1-0", "WD", "Everyone"),
122+ ("S-1-5-32-545", "BU", "Users"),
123+ ("S-1-5-11", "AU", "Authenticated Users"),
124+ ("S-1-5-4", "IU", "Interactive users"),
125+ ("S-1-5-7", "AN", "Anonymous"),
126+ ("S-1-5-32-546", "BG", "Guests"),
127+];
128+
129+/// Who of [`BROAD`] a folder's DACL, in SDDL (`D:PAI(A;OICI;FA;;;SY)…`),
130+/// lets read it. A folder with no DACL at all lets everyone in.
131+#[cfg_attr(not(windows), allow(dead_code))]
132+fn broad_readers(sddl: &str) -> Vec<String> {
133+ let Some(start) = sddl.find("D:") else { return vec!["Everyone".to_owned()] };
134+ let dacl = &sddl[start + 2..];
135+ // The SACL, if it was asked for, follows the DACL.
136+ let dacl = dacl.find("S:").map_or(dacl, |end| &dacl[..end]);
137+ if dacl.starts_with("NO_ACCESS_CONTROL") {
138+ return vec!["Everyone".to_owned()];
139+ }
140+ let mut who = Vec::new();
141+ for ace in dacl.split('(').skip(1).filter_map(|rest| rest.split(')').next()) {
142+ let fields: Vec<&str> = ace.split(';').collect();
143+ let [kind, _flags, rights, _, _, sid, ..] = fields[..] else { continue };
144+ if !matches!(kind, "A" | "XA") || !reads(rights) {
145+ continue;
146+ }
147+ if let Some((_, _, name)) = BROAD.iter().find(|(long, short, _)| sid.eq_ignore_ascii_case(long) || sid == *short)
148+ && !who.iter().any(|w| w == name)
149+ {
150+ who.push((*name).to_owned());
151+ }
152+ }
153+ who
154+}
155+
156+/// Whether an ACE's rights, in SDDL, include reading a file's contents.
157+#[cfg_attr(not(windows), allow(dead_code))]
158+fn reads(rights: &str) -> bool {
159+ // FILE_READ_DATA, GENERIC_ALL, GENERIC_READ.
160+ const READ: u32 = 0x1 | 0x1000_0000 | 0x8000_0000;
161+ if let Some(hex) = rights.strip_prefix("0x").or_else(|| rights.strip_prefix("0X")) {
162+ return u32::from_str_radix(hex, 16).is_ok_and(|mask| mask & READ != 0);
163+ }
164+ if let Ok(mask) = rights.parse::<u32>() {
165+ return mask & READ != 0;
166+ }
167+ // Two letters a right: `FA` all, `FR` read, `GA`/`GR` generic all and
168+ // read, `CC` (bit 0, which for a file is reading its data).
169+ rights.as_bytes().chunks(2).any(|code| matches!(code, b"FA" | b"FR" | b"GA" | b"GR" | b"KA" | b"KR" | b"CC"))
170+}
171+
172+/// The `icacls` commands that leave a folder to `user`, SYSTEM and
173+/// Administrators only, inherited by what is in it: no permissions from
174+/// the folder above, and none for the groups that mean everyone.
175+#[cfg_attr(not(windows), allow(dead_code))]
176+fn icacls_args(dir: &str, user: &str) -> Vec<Vec<String>> {
177+ let mut grant = vec![dir.to_owned(), "/inheritance:r".into(), "/grant:r".into()];
178+ for sid in ["S-1-5-18", "S-1-5-32-544", user] {
179+ grant.push(format!("*{sid}:(OI)(CI)F"));
180+ }
181+ let mut remove = vec![dir.to_owned(), "/remove:g".into()];
182+ remove.extend(BROAD.iter().map(|(sid, _, _)| format!("*{sid}")));
183+ vec![grant, remove]
184+}
185+
186+#[cfg(windows)]
187+mod windows {
188+ //! Reading a folder's permissions and this process's account, from
189+ //! Windows' own calls: SDDL and SIDs, which are the same in every
190+ //! language, where `icacls`' listing names groups in the computer's.
191+
192+ use std::os::windows::ffi::OsStrExt;
193+ use std::path::Path;
194+ use std::ptr::null_mut;
195+
196+ type Handle = *mut core::ffi::c_void;
197+
198+ const SE_FILE_OBJECT: i32 = 1;
199+ const DACL_SECURITY_INFORMATION: u32 = 4;
200+ const SDDL_REVISION_1: u32 = 1;
201+ const TOKEN_QUERY: u32 = 8;
202+ const TOKEN_USER: i32 = 1;
203+
204+ #[link(name = "advapi32")]
205+ unsafe extern "system" {
206+ fn GetNamedSecurityInfoW(
207+ name: *const u16,
208+ object: i32,
209+ info: u32,
210+ owner: *mut Handle,
211+ group: *mut Handle,
212+ dacl: *mut Handle,
213+ sacl: *mut Handle,
214+ descriptor: *mut Handle,
215+ ) -> u32;
216+ fn ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor: Handle, revision: u32, info: u32, text: *mut *mut u16, length: *mut u32) -> i32;
217+ fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32;
218+ fn GetTokenInformation(token: Handle, class: i32, info: *mut core::ffi::c_void, length: u32, returned: *mut u32) -> i32;
219+ fn ConvertSidToStringSidW(sid: Handle, text: *mut *mut u16) -> i32;
220+ }
221+
222+ unsafe extern "system" {
223+ fn LocalFree(memory: Handle) -> Handle;
224+ fn GetCurrentProcess() -> Handle;
225+ fn CloseHandle(handle: Handle) -> i32;
226+ }
227+
228+ /// A string Windows allocated, read and freed.
229+ unsafe fn take(text: *mut u16) -> String {
230+ let mut length = 0;
231+ while unsafe { *text.add(length) } != 0 {
232+ length += 1;
233+ }
234+ let read = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, length) });
235+ unsafe { LocalFree(text.cast()) };
236+ read
237+ }
238+
239+ /// A file or folder's DACL, in SDDL.
240+ pub fn sddl(path: &Path) -> Option<String> {
241+ let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
242+ let mut descriptor: Handle = null_mut();
243+ let error = unsafe {
244+ GetNamedSecurityInfoW(wide.as_ptr(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, null_mut(), null_mut(), null_mut(), null_mut(), &mut descriptor)
245+ };
246+ if error != 0 || descriptor.is_null() {
247+ return None;
248+ }
249+ let mut text: *mut u16 = null_mut();
250+ let converted = unsafe { ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor, SDDL_REVISION_1, DACL_SECURITY_INFORMATION, &mut text, null_mut()) };
251+ unsafe { LocalFree(descriptor) };
252+ (converted != 0 && !text.is_null()).then(|| unsafe { take(text) })
253+ }
254+
255+ /// This process's account's SID: `S-1-5-21-…`.
256+ pub fn user_sid() -> Option<String> {
257+ let mut token: Handle = null_mut();
258+ if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
259+ return None;
260+ }
261+ // TOKEN_USER and the SID after it; usize keeps its pointer aligned.
262+ let mut buffer = vec![0usize; 64];
263+ let mut returned = 0u32;
264+ let ok = unsafe { GetTokenInformation(token, TOKEN_USER, buffer.as_mut_ptr().cast(), (buffer.len() * size_of::<usize>()) as u32, &mut returned) };
265+ unsafe { CloseHandle(token) };
266+ if ok == 0 {
267+ return None;
268+ }
269+ // TOKEN_USER begins with the SID's pointer.
270+ let sid = buffer[0] as Handle;
271+ let mut text: *mut u16 = null_mut();
272+ if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 || text.is_null() {
273+ return None;
274+ }
275+ Some(unsafe { take(text) })
276+ }
277+}
278+
279+#[cfg(test)]
280+mod tests {
281+ use super::*;
282+
283+ #[test]
284+ fn a_folder_open_to_every_user_is_found_by_its_groups() {
285+ // C:\runner, made under C:\: Users read it, Authenticated Users change it.
286+ let open = "D:AI(A;OICIID;FA;;;BA)(A;OICIID;FA;;;SY)(A;OICIID;0x1200a9;;;BU)(A;ID;0x1301bf;;;AU)(A;OICIIOID;SDGXGWGR;;;AU)";
287+ assert_eq!(broad_readers(open), ["Users", "Authenticated Users"]);
288+ // The user's profile, or a folder made private.
289+ let private = "D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;S-1-5-21-1-2-3-1001)";
290+ assert!(broad_readers(private).is_empty());
291+ // Everyone, by SID; and a right to list and traverse only, which reads nothing.
292+ assert_eq!(broad_readers("D:(A;;0x120089;;;S-1-1-0)"), ["Everyone"]);
293+ assert!(broad_readers("D:(A;;0x100020;;;WD)").is_empty());
294+ // A deny is no way in, and no DACL at all is every way in.
295+ assert!(broad_readers("D:(D;;FA;;;WD)").is_empty());
296+ assert_eq!(broad_readers("D:NO_ACCESS_CONTROL"), ["Everyone"]);
297+ assert_eq!(broad_readers("O:BAG:SY"), ["Everyone"]);
298+ }
299+
300+ #[test]
301+ fn rights_that_read_a_file_are_told_from_those_that_do_not() {
302+ assert!(reads("FA") && reads("FR") && reads("GR") && reads("0x1200a9") && reads("0x80000000") && reads("1"));
303+ assert!(!reads("FX") && !reads("SDGXGW") && !reads("0x100020") && !reads(""));
304+ }
305+
306+ #[test]
307+ fn a_folder_is_left_to_its_owner_system_and_administrators() {
308+ let [grant, remove] = &icacls_args(r"C:\runner", "S-1-5-21-1-2-3-1001")[..] else { panic!() };
309+ assert_eq!(grant, &[r"C:\runner", "/inheritance:r", "/grant:r", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F", "*S-1-5-21-1-2-3-1001:(OI)(CI)F"]);
310+ assert_eq!(remove[..2], [r"C:\runner", "/remove:g"]);
311+ for sid in ["*S-1-1-0", "*S-1-5-32-545", "*S-1-5-11"] {
312+ assert!(remove.iter().any(|a| a == sid), "{sid}");
313+ }
314+ }
315+
316+ #[test]
317+ fn a_unix_folder_is_open_when_its_group_or_others_may_read_it() {
318+ assert!(unix_readers(0o700).is_empty());
319+ assert!(unix_readers(0o711).is_empty());
320+ assert_eq!(unix_readers(0o750), ["its group"]);
321+ assert_eq!(unix_readers(0o755), ["its group", "every user"]);
322+ }
323+
324+ #[test]
325+ fn a_folder_the_runner_makes_is_private() {
326+ let dir = std::env::temp_dir().join(format!("g1t-private-{}", std::process::id())).join("state");
327+ folder(&dir, "--dir").unwrap();
328+ assert!(readers(&dir).is_empty(), "{:?}", readers(&dir));
329+ write_private(&dir.join("config.json"), b"{}").unwrap();
330+ assert_eq!(std::fs::read(dir.join("config.json")).unwrap(), b"{}");
331+ #[cfg(unix)]
332+ {
333+ use std::os::unix::fs::PermissionsExt;
334+ assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
335+ assert_eq!(std::fs::metadata(dir.join("config.json")).unwrap().permissions().mode() & 0o777, 0o600);
336+ }
337+ #[cfg(windows)]
338+ assert!(windows::user_sid().is_some_and(|sid| sid.starts_with("S-1-")));
339+ let _ = std::fs::remove_dir_all(dir.parent().unwrap());
340+ }
341+
342+ /// Makes a folder every user can read and tightens it: run with
343+ /// `cargo test -p g1t-runner -- --ignored a_folder_every_user_can_read`
344+ /// on Windows (it uses `C:\Users\Public`).
345+ #[test]
346+ #[ignore]
347+ #[cfg(windows)]
348+ fn a_folder_every_user_can_read_is_made_private() {
349+ let dir = std::path::PathBuf::from(format!(r"C:\Users\Public\g1t-private-{}", std::process::id()));
350+ std::fs::create_dir_all(&dir).unwrap();
351+ assert!(!readers(&dir).is_empty(), "Public is open to every user");
352+ folder(&dir, "--dir").unwrap();
353+ assert!(readers(&dir).is_empty(), "{:?}", windows::sddl(&dir));
354+ let sddl = windows::sddl(&dir).unwrap();
355+ assert!(sddl.starts_with("D:P"), "no permissions from the folder above: {sddl}");
356+ std::fs::write(dir.join("config.json"), "{}").unwrap();
357+ assert!(readers(&dir.join("config.json")).is_empty(), "{:?}", windows::sddl(&dir.join("config.json")));
358+ std::fs::remove_dir_all(&dir).unwrap();
359+ }
360+}
+6−0
5050 RUN echo "$IMAGE_VERSION" > /etc/g1t-sandbox-version
5151
5252 FROM base AS wsl
53+# WSL's default user (wsl.conf): a locked account with no shell and no
54+# home, so whatever opens the distribution without naming a user (Explorer
55+# at \\wsl.localhost\g1t-sandbox, a bare `wsl -d g1t-sandbox`) is not root
56+# and cannot read the runner's registration. The launcher names root itself.
57+RUN useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin sandbox \
58+ && passwd --lock sandbox
5359 COPY wsl.conf /etc/wsl.conf
5460
5561 FROM base AS vm
+5−1
1515 [boot]
1616 systemd = false
1717
18+# Every call the launcher makes names root itself (`wsl -u root`). Anything
19+# else that opens the distribution, a `wsl -d g1t-sandbox` with no user or
20+# Explorer at \\wsl.localhost\g1t-sandbox, gets this account instead: no
21+# password, no shell, no home, and none of root's files (the Dockerfile).
1822 [user]
19−default = root
23+default = sandbox
2024
2125 # WSL's resolv.conf is a link into /mnt/wsl, which the sandbox unmounts:
2226 # the supervisor writes its own from what WSL offers at start.
+7−0
1+-- A self-hosted runner's harness spends the job's token on fetching the
2+-- job, and gets a new one in its place (plan.rs `job_spec` with `rotate`).
3+-- The token the job started with stops working for everything but
4+-- `brokered_secrets`, which the runner Worker asks with the token it was
5+-- given at the start and which no API route passes on: its hash is kept
6+-- here from the swap until the job ends or is claimed again.
7+ALTER TABLE jobs ADD COLUMN first_token_hash TEXT;
+5−2
186186 /// `brokered_secrets`: a running job's stand-ins and what they stand
187187 /// for, for the runner Worker to fill in on the job's requests. Asked
188188 /// with the job's own token, which only the runner and the job hold;
189− /// the API never passes this method on, so the job cannot ask it.
189+ /// the API never passes this method on, so the job cannot ask it. The
190+ /// Worker holds the token the job started with, so that one still
191+ /// answers here after the job's harness swapped it for another
192+ /// (`job_spec` with `rotate`), and nowhere else.
190193 pub async fn brokered_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> {
191− let job = check!(self.job_for_token(&a).await?);
194+ let job = check!(self.job_for_started_token(&a).await?);
192195 Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?))
193196 }
194197 }
+147−10
3030 /// The most a single log report may add.
3131 const MAX_CHUNK_BYTES: usize = 256 * 1024;
3232 const MAX_ANNOTATIONS: usize = 50;
33+/// Swaps a running job's token for a new one, only while it still has the
34+/// one the caller showed (`job_spec` with `rotate`), keeping the hash of
35+/// the one it started with for `brokered_secrets` (migration 0020).
36+const TOKEN_ROTATE: &str = "UPDATE jobs SET first_token_hash = COALESCE(first_token_hash, token_hash), token_hash = ?1
37+ WHERE id = ?2 AND status = 'in_progress' AND token_hash = ?3 RETURNING id";
38+
39+/// Whether `token` is a running job's: its current one, or with `started`
40+/// (`brokered_secrets` only) the one it started with before its harness
41+/// swapped it.
42+fn token_opens(current: Option<&str>, first: Option<&str>, token: &str, started: bool) -> bool {
43+ let hash = sha256_hex(token);
44+ current.is_some_and(|h| same(h, &hash)) || (started && first.is_some_and(|h| same(h, &hash)))
45+}
3346 /// A batch of a job's log, under the next `seq` of that job's, worked out
3447 /// in the same statement (SQLite runs one write at a time). `?4` is the
3548 /// runner's number for the batch (null from runners that send none): a
236249 /// 0019), so its log is masked for their values too.
237250 #[serde(default)]
238251 pub brokered: u32,
252+ /// The hash of the token it started with, once its harness swapped that
253+ /// for another (migration 0020): good for `brokered_secrets` only.
254+ #[serde(default)]
255+ pub first_token_hash: Option<String>,
239256 }
240257
241258 /// How long a cancelled job has to run its `if: always()` and `cancelled()`
16081625 let claimed = self
16091626 .db
16101627 .prepare(
1611− "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id",
1628+ "UPDATE jobs SET status = 'in_progress', token_hash = ?, first_token_hash = NULL, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id",
16121629 )
16131630 .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])?
16141631 .first::<Value>(None)
16751692 .db
16761693 .prepare(
16771694 "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs),
1678− finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *",
1695+ finished_at = ?, token_hash = NULL, first_token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *",
16791696 )
16801697 .bind(&[
16811698 conclusion.into(),
17831800 }
17841801 let stopped = self
17851802 .db
1786− .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *")
1803+ .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL, first_token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *")
17871804 .bind(&[reason.into(), now().into(), job.id.as_str().into()])?
17881805 .first::<JobRow>(None)
17891806 .await?;
22162233 self.db
22172234 .prepare(
22182235 "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL,
2219− matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
2236+ matrix = NULL, call = NULL, token_hash = NULL, first_token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
22202237 labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL,
22212238 concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?",
22222239 )
22572274 // --- The sandbox's side -----------------------------------------------------
22582275
22592276 pub(crate) async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> {
2277+ self.job_for(a, false).await
2278+ }
2279+
2280+ /// As [`Self::job_for_token`], and also with the token the job started
2281+ /// with after its harness swapped it (`job_spec` with `rotate`): for
2282+ /// `brokered_secrets` alone, which the runner Worker asks with the
2283+ /// token it was given at the start, and which no API route passes on.
2284+ pub(crate) async fn job_for_started_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> {
2285+ self.job_for(a, true).await
2286+ }
2287+
2288+ async fn job_for(&self, a: &JobCallArgs, started: bool) -> Result<Outcome<JobRow>> {
22602289 let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?;
22612290 Ok(match job {
2262− Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => {
2263− Outcome::Ok(job)
2264− }
2291+ Some(job) if job.status == "in_progress" && token_opens(job.token_hash.as_deref(), job.first_token_hash.as_deref(), &a.token, started) => Outcome::Ok(job),
22652292 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
22662293 })
22672294 }
22762303 /// `job_spec`: everything the sandbox needs to run the job.
22772304 pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
22782305 let job = check!(self.job_for_token(&a).await?);
2306+ // Asked to, the token the harness asked with is spent here: it gets
2307+ // a new one with the spec, for everything after, so a copy of the
2308+ // first left on the runner's computer (a Windows job's script, a
2309+ // container's environment) cannot fetch the secrets again. The new
2310+ // token is made now, as the runtime token is signed with its hash,
2311+ // and swapped in last, so a spec that could not be made leaves the
2312+ // old one working.
2313+ let next = a.rotate.then(|| random_hex(24));
2314+ let token_hash = match &next {
2315+ Some(next) => sha256_hex(next),
2316+ None => job.token_hash.clone().unwrap_or_default(),
2317+ };
22792318 let Some(run) = self.run_row(&job.run_id).await? else {
22802319 return Ok(fail(FailureCode::NotFound, "No such run."));
22812320 };
23982437 let runtime_token = crate::runtime::runtime_token(
23992438 &job.id,
24002439 &job.run_id,
2401− job.token_hash.as_deref().unwrap_or_default(),
2440+ &token_hash,
24022441 now_ms() / 1000,
24032442 u64::from(job.timeout_minutes) * 60 + 600,
24042443 );
24692508 _ => format!("{SITE}/{}.git", run.repo),
24702509 };
24712510
2472− Ok(Outcome::Ok(json!({
2511+ // Only one fetch with a token gets its spec: of two at once, the
2512+ // second finds the hash already changed and is refused.
2513+ if next.is_some() {
2514+ let swapped = self
2515+ .db
2516+ .prepare(TOKEN_ROTATE)
2517+ .bind(&[token_hash.as_str().into(), job.id.as_str().into(), job.token_hash.as_deref().unwrap_or_default().into()])?
2518+ .first::<Value>(None)
2519+ .await?;
2520+ if swapped.is_none() {
2521+ return Ok(fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."));
2522+ }
2523+ }
2524+
2525+ let mut spec = json!({
24732526 "job": job.id,
24742527 "run": run.id,
24752528 "key": job.key,
25132566 "token": runtime_token,
25142567 "idToken": self.oidc_allowed(&run, &job),
25152568 },
2516− })))
2569+ });
2570+ if let Some(next) = next {
2571+ spec["nextToken"] = json!(next);
2572+ }
2573+ Ok(Outcome::Ok(spec))
25172574 }
25182575
25192576 /// `job_report`: the sandbox telling how the job is going.
28392896 }
28402897
28412898 #[cfg(test)]
2899+mod token_rotation {
2900+ use g1t_secrets::sha256_hex;
2901+ use rusqlite::{Connection, OptionalExtension, params};
2902+
2903+ use super::{TOKEN_ROTATE, token_opens};
2904+
2905+ fn database() -> Connection {
2906+ let db = Connection::open_in_memory().unwrap();
2907+ for name in ["0001_init.sql", "0020_job_token_rotation.sql"] {
2908+ let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR"));
2909+ db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
2910+ }
2911+ db.execute(
2912+ "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, status, token_hash) VALUES ('job_1', 'run_1', 'repo_1', 'acme', 'build', 'build', 'in_progress', ?1)",
2913+ [sha256_hex("first")],
2914+ )
2915+ .unwrap();
2916+ db
2917+ }
2918+
2919+ fn rotate(db: &Connection, next: &str, shown: &str) -> bool {
2920+ db.query_row(TOKEN_ROTATE, params![sha256_hex(next), "job_1", sha256_hex(shown)], |row| row.get::<_, String>(0))
2921+ .optional()
2922+ .unwrap()
2923+ .is_some()
2924+ }
2925+
2926+ /// The job's current token's hash, and the one it started with.
2927+ fn hashes(db: &Connection) -> (Option<String>, Option<String>) {
2928+ db.query_row("SELECT token_hash, first_token_hash FROM jobs WHERE id = 'job_1'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap()
2929+ }
2930+
2931+ #[test]
2932+ fn a_job_token_fetches_its_spec_once() {
2933+ let db = database();
2934+ assert!(rotate(&db, "second", "first"));
2935+ assert_eq!(hashes(&db), (Some(sha256_hex("second")), Some(sha256_hex("first"))));
2936+ // The first token, shown again (a copy left on the computer, or a
2937+ // second fetch racing the first), swaps nothing and is refused.
2938+ assert!(!rotate(&db, "third", "first"));
2939+ assert_eq!(hashes(&db).0, Some(sha256_hex("second")));
2940+ // A job that ended has no token to swap.
2941+ db.execute("UPDATE jobs SET status = 'completed', token_hash = NULL, first_token_hash = NULL", []).unwrap();
2942+ assert!(!rotate(&db, "fourth", "second"));
2943+ assert_eq!(hashes(&db), (None, None));
2944+ }
2945+
2946+ #[test]
2947+ fn the_worker_finds_brokered_secrets_with_the_token_the_job_started_with() {
2948+ let db = database();
2949+ let opens = |db: &Connection, token: &str, started: bool| {
2950+ let (current, first) = hashes(db);
2951+ token_opens(current.as_deref(), first.as_deref(), token, started)
2952+ };
2953+ // Before any swap, the job's one token opens everything.
2954+ assert!(opens(&db, "first", false) && opens(&db, "first", true));
2955+ assert!(rotate(&db, "second", "first"));
2956+ // The harness's new token opens everything.
2957+ assert!(opens(&db, "second", false) && opens(&db, "second", true));
2958+ // The token it started with, which the runner Worker holds, still
2959+ // finds the job's brokered secrets, and opens nothing else.
2960+ assert!(opens(&db, "first", true));
2961+ assert!(!opens(&db, "first", false));
2962+ assert!(!opens(&db, "other", true));
2963+ // Another swap keeps the token it started with, not the one between.
2964+ assert!(rotate(&db, "third", "second"));
2965+ assert!(opens(&db, "first", true));
2966+ assert!(!opens(&db, "second", true));
2967+ }
2968+
2969+ #[test]
2970+ fn the_runtime_token_is_signed_for_the_token_that_replaces_the_first() {
2971+ let now = 1_800_000_000;
2972+ let token = crate::runtime::runtime_token("job_1", "run_1", &sha256_hex("second"), now, 600);
2973+ assert!(crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("second"), now));
2974+ assert!(!crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("first"), now));
2975+ }
2976+}
2977+
2978+#[cfg(test)]
28422979 mod stopping {
28432980 use super::stops_runs;
28442981 use g1t_contracts::events::Event;
+1−1
12121212 let claimed = self
12131213 .db
12141214 .prepare(
1215− "UPDATE jobs SET status = 'in_progress', token_hash = ?, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ?
1215+ "UPDATE jobs SET status = 'in_progress', token_hash = ?, first_token_hash = NULL, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ?
12161216 WHERE id = ? AND status = 'queued' RETURNING id",
12171217 )
12181218 .bind(&[

Comments

Sign in to comment.