@@ -46,6 +46,13 @@ pub struct Config {
4646 /// Container image used when a job declares no container and no known
4747 /// runs-on label.
4848 pub default_job_image : String ,
49+ /// Optional allow-list of images a job's `container:` may run. Empty =
50+ /// allow any syntactically valid reference (default). When non-empty, a
51+ /// `container:` image outside this list (and outside the toolchain
52+ /// catalog) is rejected at plan time and the job falls back to the
53+ /// default image with a notice. Entries support a trailing `*` prefix
54+ /// glob (e.g. `catthehacker/*`, `node:*`).
55+ pub image_allowlist : Vec < String > ,
4956 pub job_timeout_seconds : i32 ,
5057 pub pipeline_timeout_seconds : i32 ,
5158 pub max_log_bytes_per_job : i64 ,
@@ -277,6 +284,12 @@ impl Config {
277284 // no toolchains, so real workflows die on `npm: not found`.
278285 let default_job_image = optional ( "DEFAULT_JOB_IMAGE" , "catthehacker/ubuntu:act-latest" ) ;
279286
287+ // Optional hardening: restrict which images a job `container:` may run.
288+ // Unset = allow any valid reference (the historical behavior). Parsed
289+ // with the same caps as the prepull list so a malformed deployment
290+ // fails loudly at startup.
291+ let image_allowlist = parse_image_list ( "RUNNER_IMAGE_ALLOWLIST" ) ?;
292+
280293 // Hosted-runner provisioning is opt-in: the server needs Docker
281294 // access and a URL that provisioned containers can reach it on.
282295 let runner_provisioner = match optional ( "RUNNER_PROVISIONER" , "" ) . as_str ( ) {
@@ -431,6 +444,7 @@ impl Config {
431444 . context ( "GITHUB_CHECKS_ENABLED must be true or false" ) ?,
432445 runner_job_signing_key,
433446 default_job_image,
447+ image_allowlist,
434448 job_timeout_seconds : optional ( "JOB_TIMEOUT_SECONDS" , "3600" )
435449 . parse ( )
436450 . context ( "JOB_TIMEOUT_SECONDS must be an integer" ) ?,
@@ -556,6 +570,30 @@ fn optional(key: &str, default: &str) -> String {
556570 std:: env:: var ( key) . unwrap_or_else ( |_| default. to_string ( ) )
557571}
558572
573+ /// Parse a comma-separated image-reference env var (unset/empty = empty list),
574+ /// applying the same caps as the prepull list. Entries may end in `*` for a
575+ /// prefix glob, so the length cap is generous enough for a registry path.
576+ fn parse_image_list ( key : & str ) -> anyhow:: Result < Vec < String > > {
577+ let raw = optional ( key, "" ) ;
578+ let mut images: Vec < String > = Vec :: new ( ) ;
579+ for entry in raw. split ( ',' ) {
580+ let entry = entry. trim ( ) ;
581+ if entry. is_empty ( ) {
582+ continue ;
583+ }
584+ if entry. len ( ) > 256 {
585+ anyhow:: bail!( "{key} entries must be at most 256 characters (got one of {} characters)" , entry. len( ) ) ;
586+ }
587+ if !images. iter ( ) . any ( |existing| existing == entry) {
588+ images. push ( entry. to_string ( ) ) ;
589+ }
590+ }
591+ if images. len ( ) > 50 {
592+ anyhow:: bail!( "{key} supports at most 50 entries (got {})" , images. len( ) ) ;
593+ }
594+ Ok ( images)
595+ }
596+
559597/// Parse the comma-separated RUNNER_PREPULL_IMAGES value: trim entries, drop
560598/// empties, dedupe preserving order, and enforce sane caps so a malformed
561599/// deployment fails at startup instead of spraying pull warnings forever.
0 commit comments