Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 1 | //! Brokered secrets: a secret a job uses only to call an API never enters |
| 2 | //! its sandbox. Its row names the hosts it is for (settings.rs); when a job | |
| 3 | //! starts, `job_spec` gives the job a stand-in in its place, one per job and | |
| 4 | //! secret, and keeps here what the stand-in stands for, sealed and bound to | |
| 5 | //! it, until the job's time is up. The runner Worker sees every HTTP and | |
| 6 | //! HTTPS request a job on g1t's machines makes (services/runner broker.ts), | |
| 7 | //! asks `brokered_secrets` with the job's token for its stand-ins, and puts | |
| 8 | //! the real value in place of a stand-in on requests to the secret's hosts | |
| 9 | //! as they leave. Anywhere else the stand-in goes as it is, which is | |
| 10 | //! useless to whoever receives it. | |
| 11 | //! | |
| Merge branch 'main' into rm-agents | 12 | //! A self-hosted runner's traffic never passes the Worker, and the real |
| 13 | //! value is never sent to a machine g1t does not run. A harness that can | |
| 14 | //! broker (on Linux or macOS, `JobCallArgs::broker`) is given each | |
| 15 | //! stand-in's hosts in its spec (`brokered`), never a value: its job's | |
| 16 | //! HTTPS to those hosts goes through a proxy of the harness's, which | |
| 17 | //! passes each request carrying a stand-in to g1t | |
| 18 | //! (`POST /actions/jobs/{job}/broker`), where the runner Worker fills it | |
| 19 | //! in (`relayed_secrets`), sends it and scrubs the answer. Any other | |
| 20 | //! harness's job keeps the stand-in, and its log says why it will not | |
| 21 | //! work. | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 22 | |
| 23 | use g1t_contracts::actions::{BrokeredSecret, JobCallArgs, STAND_IN_PREFIX}; | |
| 24 | use g1t_contracts::time::rfc3339; | |
| Merge branch 'main' into rm-agents | 25 | use g1t_contracts::{FailureCode, Outcome}; |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 26 | use g1t_kit::now_ms; |
| 27 | use g1t_secrets::random_hex; | |
| 28 | use serde::Deserialize; | |
| 29 | use serde_json::{Map, Value}; | |
| 30 | use worker::Result; | |
| 31 | use worker::wasm_bindgen::JsValue; | |
| 32 | ||
| 33 | use crate::plan::LOG_INSERT; | |
| Merge branch 'main' into rm-agents | 34 | use crate::{Actions, Count, check, fail}; |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 35 | |
| 36 | /// The most hosts one brokered secret names. | |
| 37 | pub(crate) const MAX_HOSTS: usize = 20; | |
| 38 | /// The shortest value a brokered secret may have: a shorter one would be | |
| 39 | /// found, and swapped, inside unrelated secrets. | |
| 40 | pub(crate) const MIN_VALUE_CHARS: usize = 8; | |
| 41 | ||
| 42 | /// Keeps a job's stand-in: what it stands for, sealed and bound to it. | |
| 43 | pub(crate) const STAND_IN_INSERT: &str = | |
| 44 | "INSERT OR REPLACE INTO brokered_secrets (stand_in, job_id, name, value, hosts, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"; | |
| 45 | /// A job's stand-ins that still work at `?2`. | |
| 46 | pub(crate) const STAND_INS_OF_JOB: &str = | |
| 47 | "SELECT stand_in, name, value, hosts FROM brokered_secrets WHERE job_id = ?1 AND expires_at > ?2"; | |
| 48 | /// Stand-ins whose jobs' time is up. | |
| 49 | pub(crate) const STAND_INS_EXPIRED: &str = "DELETE FROM brokered_secrets WHERE expires_at <= ?1"; | |
| 50 | ||
| 51 | /// A brokered secret as one reader resolved it: its key, real value and | |
| 52 | /// the hosts it is for. | |
| 53 | #[derive(Clone, Debug, PartialEq)] | |
| 54 | pub(crate) struct Broker { | |
| 55 | pub name: String, | |
| 56 | pub value: String, | |
| 57 | pub hosts: Vec<String>, | |
| 58 | } | |
| 59 | ||
| 60 | /// A stand-in made for one job. | |
| 61 | #[derive(Clone, Debug, PartialEq)] | |
| 62 | pub(crate) struct StandIn { | |
| 63 | pub stand_in: String, | |
| 64 | pub broker: Broker, | |
| 65 | } | |
| 66 | ||
| 67 | /// A new stand-in: the prefix and 48 random hex characters. | |
| 68 | pub(crate) fn stand_in() -> String { | |
| 69 | format!("{STAND_IN_PREFIX}{}", random_hex(24)) | |
| 70 | } | |
| 71 | ||
| 72 | /// Puts a stand-in in place of each brokered value wherever it appears in | |
| 73 | /// `secrets`, as a whole value or inside one a called workflow built from | |
| 74 | /// it (`${{ secrets.USER }}:${{ secrets.TOKEN }}`), and returns the | |
| 75 | /// stand-ins made: one per brokered value found, whatever keys it is under. | |
| 76 | /// Longer values go first, so one that holds another is replaced whole. | |
| 77 | pub(crate) fn substitute(secrets: &mut Map<String, Value>, brokers: &[Broker], mut make: impl FnMut() -> String) -> Vec<StandIn> { | |
| 78 | let mut ordered: Vec<&Broker> = brokers.iter().filter(|b| b.value.chars().count() >= MIN_VALUE_CHARS).collect(); | |
| 79 | ordered.sort_by_key(|b| std::cmp::Reverse(b.value.len())); | |
| 80 | let mut made: Vec<StandIn> = Vec::new(); | |
| 81 | for broker in ordered { | |
| 82 | if made.iter().any(|m| m.broker.value == broker.value) { | |
| 83 | continue; | |
| 84 | } | |
| 85 | let mut stand_in: Option<String> = None; | |
| 86 | for value in secrets.values_mut() { | |
| 87 | let Value::String(text) = value else { continue }; | |
| 88 | if text.contains(&broker.value) { | |
| 89 | let with = stand_in.get_or_insert_with(&mut make); | |
| 90 | *text = text.replace(&broker.value, with); | |
| 91 | } | |
| 92 | } | |
| 93 | if let Some(stand_in) = stand_in { | |
| 94 | made.push(StandIn { stand_in, broker: broker.clone() }); | |
| 95 | } | |
| 96 | } | |
| 97 | made | |
| 98 | } | |
| 99 | ||
| Merge branch 'main' into rm-agents | 100 | /// What a job on a self-hosted runner whose harness cannot broker (a |
| 101 | /// Windows one, or one from before brokering) is told for each brokered | |
| 102 | /// secret it was given. | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 103 | pub(crate) fn self_hosted_note(name: &str) -> String { |
| 104 | format!( | |
| Merge branch 'main' into rm-agents | 105 | "##[warning]{name} is a brokered secret: this job has a stand-in for it, which this runner cannot have filled in (that takes an up-to-date runner whose jobs run on Linux or macOS). Requests that use it will not be signed in." |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 106 | ) |
| 107 | } | |
| 108 | ||
| Merge branch 'main' into rm-agents | 109 | /// What a job on a self-hosted runner that brokers is told for each |
| 110 | /// brokered secret it was given. | |
| 111 | pub(crate) fn brokered_note(name: &str, hosts: &[String]) -> String { | |
| 112 | format!( | |
| 113 | "{name} is a brokered secret: this job's requests to {} go through g1t, which fills it in, so its value never reaches this computer.", | |
| 114 | hosts.join(", ") | |
| 115 | ) | |
| 116 | } | |
| 117 | ||
| 118 | /// What a brokering harness is given for its job's stand-ins: each one, | |
| 119 | /// its secret's key (for the log) and its hosts. Never a value. | |
| 120 | pub(crate) fn spec_entries(stand_ins: &[StandIn]) -> Value { | |
| 121 | Value::Array( | |
| 122 | stand_ins | |
| 123 | .iter() | |
| 124 | .map(|made| serde_json::json!({ "standIn": made.stand_in, "name": made.broker.name, "hosts": made.broker.hosts })) | |
| 125 | .collect(), | |
| 126 | ) | |
| 127 | } | |
| 128 | ||
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 129 | /// A line of a job's log, stamped as the runner stamps its own. |
| 130 | pub(crate) fn stamped(text: &str) -> String { | |
| 131 | let at = rfc3339(now_ms()); | |
| 132 | text.lines().map(|line| format!("{at} {line}\n")).collect() | |
| 133 | } | |
| 134 | ||
| 135 | #[derive(Deserialize)] | |
| 136 | struct StandInRow { | |
| 137 | stand_in: String, | |
| 138 | name: String, | |
| 139 | value: String, | |
| 140 | hosts: String, | |
| 141 | } | |
| 142 | ||
| 143 | impl Actions { | |
| 144 | /// Keeps a job's stand-ins until its time is up, and marks the job so | |
| 145 | /// its log is masked for their values. Stand-ins whose time is up go. | |
| 146 | pub(crate) async fn keep_stand_ins(&self, job_id: &str, timeout_minutes: u32, stand_ins: &[StandIn]) -> Result<()> { | |
| 147 | let Some(sealer) = &self.sealer else { return Ok(()) }; | |
| 148 | let now = now_ms(); | |
| 149 | let expires = now + (u64::from(timeout_minutes) * 60 + 600) * 1000; | |
| 150 | let mut statements = vec![self.db.prepare(STAND_INS_EXPIRED).bind(&[(now as f64).into()])?]; | |
| 151 | for made in stand_ins { | |
| 152 | statements.push(self.db.prepare(STAND_IN_INSERT).bind(&[ | |
| 153 | made.stand_in.as_str().into(), | |
| 154 | job_id.into(), | |
| 155 | made.broker.name.as_str().into(), | |
| 156 | sealer.seal(&made.broker.value, &made.stand_in).into(), | |
| 157 | serde_json::to_string(&made.broker.hosts)?.into(), | |
| 158 | (expires as f64).into(), | |
| 159 | ])?); | |
| 160 | } | |
| 161 | statements.push(self.db.prepare("UPDATE jobs SET brokered = 1 WHERE id = ?").bind(&[job_id.into()])?); | |
| 162 | self.db.batch(statements).await?; | |
| 163 | Ok(()) | |
| 164 | } | |
| 165 | ||
| 166 | /// A job's stand-ins that still work, opened. | |
| 167 | pub(crate) async fn stand_ins_of(&self, job_id: &str) -> Result<Vec<BrokeredSecret>> { | |
| 168 | let Some(sealer) = &self.sealer else { return Ok(Vec::new()) }; | |
| 169 | let rows = self | |
| 170 | .db | |
| 171 | .prepare(STAND_INS_OF_JOB) | |
| 172 | .bind(&[job_id.into(), (now_ms() as f64).into()])? | |
| 173 | .all() | |
| 174 | .await? | |
| 175 | .results::<StandInRow>()?; | |
| 176 | Ok(rows | |
| 177 | .into_iter() | |
| 178 | .filter_map(|row| { | |
| 179 | Some(BrokeredSecret { | |
| 180 | value: sealer.open(&row.value, &row.stand_in)?, | |
| 181 | hosts: serde_json::from_str(&row.hosts).unwrap_or_default(), | |
| 182 | name: row.name, | |
| 183 | stand_in: row.stand_in, | |
| 184 | }) | |
| 185 | }) | |
| 186 | .collect()) | |
| 187 | } | |
| 188 | ||
| 189 | /// Whether a job in the repository could be given a brokered secret: | |
| 190 | /// its project or its workspace has one. Asked as a job starts, so the | |
| 191 | /// runner sends the job's HTTP through the Worker even when its network | |
| 192 | /// is open; a job that turns out to get none just goes the long way. | |
| 193 | pub(crate) async fn may_broker(&self, repo_id: &str, namespace: &str) -> Result<bool> { | |
| 194 | let Some(project) = self.project_of(repo_id).await? else { return Ok(false) }; | |
| 195 | let found = self | |
| 196 | .db | |
| 197 | .prepare("SELECT COUNT(*) AS n FROM settings WHERE kind = 'secret' AND brokered_hosts IS NOT NULL AND owner IN (?, ?)") | |
| 198 | .bind(&[project.id.as_str().into(), namespace.to_lowercase().into()])? | |
| 199 | .first::<Count>(None) | |
| 200 | .await?; | |
| 201 | Ok(found.is_some_and(|count| count.n > 0)) | |
| 202 | } | |
| 203 | ||
| 204 | /// Adds g1t's own lines to a job's log, under `step` (0 for its setup). | |
| 205 | pub(crate) async fn note_log(&self, job_id: &str, step: u32, text: &str) -> Result<()> { | |
| 206 | self.db | |
| 207 | .prepare(LOG_INSERT) | |
| 208 | .bind(&[job_id.into(), step.into(), stamped(text).into(), JsValue::NULL])? | |
| 209 | .run() | |
| 210 | .await?; | |
| 211 | Ok(()) | |
| 212 | } | |
| 213 | ||
| 214 | /// `brokered_secrets`: a running job's stand-ins and what they stand | |
| 215 | /// for, for the runner Worker to fill in on the job's requests. Asked | |
| 216 | /// with the job's own token, which only the runner and the job hold; | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 217 | /// the API never passes this method on, so the job cannot ask it. The |
| 218 | /// Worker holds the token the job started with, so that one still | |
| 219 | /// answers here after the job's harness swapped it for another | |
| 220 | /// (`job_spec` with `rotate`), and nowhere else. | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 221 | pub async fn brokered_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> { |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 222 | let job = check!(self.job_for_started_token(&a).await?); |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 223 | Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?)) |
| 224 | } | |
| Merge branch 'main' into rm-agents | 225 | |
| 226 | /// `relayed_secrets`: a running self-hosted job's stand-ins and what | |
| 227 | /// they stand for, for the runner Worker to fill in on a request the | |
| 228 | /// job's harness passed to g1t (`POST /actions/jobs/{job}/broker`). | |
| 229 | /// Only with the job's current token, which its harness holds: the one | |
| 230 | /// it started with may have been left on the runner's computer. A job | |
| 231 | /// on g1t's machines has its requests filled in as they leave its | |
| 232 | /// sandbox, and is refused here. | |
| 233 | pub async fn relayed_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> { | |
| 234 | let job = check!(self.job_for_token(&a).await?); | |
| 235 | if job.runner_id.is_none() { | |
| 236 | return Ok(fail(FailureCode::Forbidden, "Only a self-hosted runner's job relays its requests.")); | |
| 237 | } | |
| 238 | Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?)) | |
| 239 | } | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 240 | } |
| 241 | ||
| 242 | #[cfg(test)] | |
| 243 | mod tests { | |
| 244 | use rusqlite::{Connection, params}; | |
| 245 | use serde_json::{Map, Value, json}; | |
| 246 | ||
| Merge branch 'main' into rm-agents | 247 | use super::{Broker, STAND_IN_INSERT, STAND_INS_EXPIRED, STAND_INS_OF_JOB, StandIn, brokered_note, self_hosted_note, spec_entries, stand_in, substitute}; |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 248 | |
| 249 | fn broker(name: &str, value: &str, hosts: &[&str]) -> Broker { | |
| 250 | Broker { name: name.into(), value: value.into(), hosts: hosts.iter().map(|h| (*h).to_owned()).collect() } | |
| 251 | } | |
| 252 | ||
| 253 | fn counter() -> impl FnMut() -> String { | |
| 254 | let mut n = 0; | |
| 255 | move || { | |
| 256 | n += 1; | |
| 257 | format!("g1tsec_{n}") | |
| 258 | } | |
| 259 | } | |
| 260 | ||
| 261 | #[test] | |
| 262 | fn stand_ins_are_long_random_tokens() { | |
| 263 | let one = stand_in(); | |
| 264 | assert!(one.starts_with("g1tsec_")); | |
| 265 | assert_eq!(one.len(), "g1tsec_".len() + 48); | |
| 266 | assert!(one["g1tsec_".len()..].chars().all(|c| c.is_ascii_hexdigit())); | |
| 267 | assert_ne!(one, stand_in()); | |
| 268 | } | |
| 269 | ||
| 270 | #[test] | |
| 271 | fn a_brokered_value_never_reaches_the_job() { | |
| 272 | let mut secrets: Map<String, Value> = json!({ | |
| 273 | "NPM_TOKEN": "npm_realvalue123", | |
| 274 | "ALSO_NPM": "npm_realvalue123", | |
| 275 | "BASIC": "me:npm_realvalue123", | |
| 276 | "PLAIN": "not-brokered-value", | |
| 277 | }) | |
| 278 | .as_object() | |
| 279 | .cloned() | |
| 280 | .unwrap(); | |
| 281 | let made = substitute(&mut secrets, &[broker("NPM_TOKEN", "npm_realvalue123", &["registry.npmjs.org"])], counter()); | |
| 282 | assert_eq!(made.len(), 1); | |
| 283 | assert_eq!(made[0].stand_in, "g1tsec_1"); | |
| 284 | assert_eq!(made[0].broker.hosts, ["registry.npmjs.org"]); | |
| 285 | assert_eq!(secrets["NPM_TOKEN"], "g1tsec_1"); | |
| 286 | assert_eq!(secrets["ALSO_NPM"], "g1tsec_1"); | |
| 287 | assert_eq!(secrets["BASIC"], "me:g1tsec_1"); | |
| 288 | assert_eq!(secrets["PLAIN"], "not-brokered-value"); | |
| 289 | assert!(!serde_json::to_string(&secrets).unwrap().contains("npm_realvalue123")); | |
| 290 | } | |
| 291 | ||
| 292 | #[test] | |
| 293 | fn each_secret_gets_its_own_stand_in_and_longer_values_go_first() { | |
| 294 | let mut secrets: Map<String, Value> = | |
| 295 | json!({ "SHORT": "abcdefgh", "LONG": "abcdefgh-and-more" }).as_object().cloned().unwrap(); | |
| 296 | let made = substitute( | |
| 297 | &mut secrets, | |
| 298 | &[broker("SHORT", "abcdefgh", &["a.example.com"]), broker("LONG", "abcdefgh-and-more", &["b.example.com"])], | |
| 299 | counter(), | |
| 300 | ); | |
| 301 | assert_eq!(made.len(), 2); | |
| 302 | assert_eq!(made[0].broker.name, "LONG"); | |
| 303 | assert_eq!(secrets["LONG"], "g1tsec_1"); | |
| 304 | assert_eq!(secrets["SHORT"], "g1tsec_2"); | |
| 305 | } | |
| 306 | ||
| 307 | #[test] | |
| 308 | fn values_too_short_or_not_given_make_no_stand_in() { | |
| 309 | let mut secrets: Map<String, Value> = json!({ "PIN": "1234" }).as_object().cloned().unwrap(); | |
| 310 | assert!(substitute(&mut secrets, &[broker("PIN", "1234", &["x.example.com"])], counter()).is_empty()); | |
| 311 | assert!(substitute(&mut secrets, &[broker("GONE", "absent-value", &["x.example.com"])], counter()).is_empty()); | |
| 312 | assert_eq!(secrets["PIN"], "1234"); | |
| 313 | } | |
| 314 | ||
| 315 | #[test] | |
| 316 | fn the_self_hosted_note_is_a_warning_naming_the_secret() { | |
| 317 | let note = self_hosted_note("NPM_TOKEN"); | |
| 318 | assert!(note.starts_with("##[warning]NPM_TOKEN")); | |
| Merge branch 'main' into rm-agents | 319 | assert!(note.contains("cannot have filled in")); |
| 320 | } | |
| 321 | ||
| 322 | #[test] | |
| 323 | fn a_brokering_harness_gets_hosts_and_stand_ins_but_never_a_value() { | |
| 324 | let made = vec![StandIn { | |
| 325 | stand_in: "g1tsec_1".into(), | |
| 326 | broker: broker("NPM_TOKEN", "npm_realvalue123", &["registry.npmjs.org", "*.npm.example.com"]), | |
| 327 | }]; | |
| 328 | let entries = spec_entries(&made); | |
| 329 | assert_eq!(entries, json!([{ "standIn": "g1tsec_1", "name": "NPM_TOKEN", "hosts": ["registry.npmjs.org", "*.npm.example.com"] }])); | |
| 330 | assert!(!entries.to_string().contains("npm_realvalue123")); | |
| 331 | let note = brokered_note("NPM_TOKEN", &made[0].broker.hosts); | |
| 332 | assert!(!note.starts_with("##["), "not a warning: {note}"); | |
| 333 | assert!(note.contains("registry.npmjs.org, *.npm.example.com") && note.contains("never reaches this computer")); | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 334 | } |
| 335 | ||
| 336 | fn database() -> Connection { | |
| 337 | let db = Connection::open_in_memory().unwrap(); | |
| 338 | for name in ["0001_init.sql", "0019_brokered_secrets.sql"] { | |
| 339 | let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR")); | |
| 340 | db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap(); | |
| 341 | } | |
| 342 | db | |
| 343 | } | |
| 344 | ||
| 345 | #[test] | |
| 346 | fn a_job_finds_only_its_own_stand_ins_while_they_work() { | |
| 347 | let db = database(); | |
| 348 | let keep = |stand_in: &str, job: &str, expires: i64| { | |
| 349 | db.execute(STAND_IN_INSERT, params![stand_in, job, "NPM_TOKEN", "sealed", "[\"registry.npmjs.org\"]", expires]).unwrap() | |
| 350 | }; | |
| 351 | keep("g1tsec_a", "job_1", 2_000); | |
| 352 | keep("g1tsec_b", "job_2", 2_000); | |
| 353 | keep("g1tsec_c", "job_1", 500); | |
| 354 | let of = |job: &str, now: i64| -> Vec<String> { | |
| 355 | let mut rows = db.prepare(STAND_INS_OF_JOB).unwrap(); | |
| 356 | rows.query_map(params![job, now], |row| row.get::<_, String>(0)).unwrap().map(Result::unwrap).collect() | |
| 357 | }; | |
| 358 | assert_eq!(of("job_1", 1_000), ["g1tsec_a"]); | |
| 359 | assert_eq!(of("job_2", 1_000), ["g1tsec_b"]); | |
| 360 | assert!(of("job_1", 3_000).is_empty()); | |
| 361 | assert_eq!(db.execute(STAND_INS_EXPIRED, params![1_000]).unwrap(), 1); | |
| 362 | // Settings rows and jobs carry the new columns. | |
| 363 | db.execute("UPDATE settings SET brokered_hosts = '[]'", []).unwrap(); | |
| 364 | db.execute("UPDATE jobs SET brokered = 1", []).unwrap(); | |
| 365 | } | |
| 366 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.