Skip to content

Commit 5cb714a

Browse files
authored
[pkl.impl.ghactions] Add validation to prevent template injection vectors (#71)
1 parent 2b64e60 commit 5cb714a

File tree

5 files changed

+96
-15
lines changed

5 files changed

+96
-15
lines changed

.github/PklProject.deps.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
"package://pkg.pkl-lang.org/pkl-project-commons/pkl.impl.ghactions@1": {
1212
"type": "local",
13-
"uri": "projectpackage://pkg.pkl-lang.org/pkl-project-commons/pkl.impl.ghactions@1.4.0",
13+
"uri": "projectpackage://pkg.pkl-lang.org/pkl-project-commons/pkl.impl.ghactions@1.5.0",
1414
"path": "../packages/pkl.impl.ghactions"
1515
},
1616
"package://pkg.pkl-lang.org/pkl-pantry/pkl.experimental.deepToTyped@1": {

packages/pkl.impl.ghactions/PklCI.pkl

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ module pkl.impl.ghactions.PklCI
1717

1818
import "@com.github.actions/actions/checkout/v6/Checkout.pkl"
1919
import "@com.github.actions/context.pkl"
20+
import "@com.github.actions/Job.pkl"
21+
import "@com.github.actions/Step.pkl"
2022
import "@com.github.actions/Workflow.pkl"
2123
import "@com.github.dependabot/v2/Dependabot.pkl"
2224
import "@pkl.github.dependabotManagedActions/DependabotManagedActions.pkl"
@@ -54,7 +56,7 @@ catalog: Catalog
5456
/// * [Workflow.concurrency]
5557
///
5658
/// This turns into a workflow called "Pull Request".
57-
prb: Workflow
59+
prb: Workflow(isValid)
5860

5961
/// The workflow to run for commits that land on the main branch.
6062
///
@@ -65,7 +67,7 @@ prb: Workflow
6567
/// * [Workflow.concurrency]
6668
///
6769
/// This turns into a workflow called "Build (main)".
68-
main: Workflow
70+
main: Workflow(isValid)
6971

7072
/// The workflow to run for commits that land on all branches except for `main` and `release/*`
7173
///
@@ -76,7 +78,7 @@ main: Workflow
7678
/// * [Workflow.concurrency]
7779
///
7880
/// This turns into a workflow called "Build".
79-
build: Workflow
81+
build: Workflow(isValid)
8082

8183
/// The workflow that runs when tags are pushed.
8284
///
@@ -87,7 +89,7 @@ build: Workflow
8789
/// * [Workflow.concurrency]
8890
///
8991
/// This turns into a workflow called "Release".
90-
release: Workflow?
92+
release: Workflow(isValid)?
9193

9294
/// Tag glob patterns to ignore for releases
9395
releaseIgnoreTags: Listing<String>?
@@ -101,7 +103,7 @@ releaseIgnoreTags: Listing<String>?
101103
/// * [Workflow.concurrency]
102104
///
103105
/// This turns into a workflow called "Build (release branch)".
104-
releaseBranch: Workflow?
106+
releaseBranch: Workflow(isValid)?
105107

106108
/// Test reports produced by [build] and [release].
107109
///
@@ -133,7 +135,7 @@ triggerDocsBuild: "none" | "release" | "both" = "none"
133135
triggerPackageDocsBuild: "none" | "main" | "release" = "none"
134136

135137
/// Any additional workflows beyond the built-in ones (prb, build, release, etc)
136-
workflows: Mapping<String, Workflow>
138+
workflows: Mapping<String, Workflow(isValid)>
137139

138140
/// Any additional dependabot configuration beyond GitHub Actions
139141
dependabot: Dependabot
@@ -332,14 +334,14 @@ local testReportWorkflow: Workflow = new {
332334
["path"] = "artifacts"
333335
["name"] = "test-results-.*"
334336
["name_is_regexp"] = true
335-
["run_id"] = "${{ github.event.workflow_run.id }}"
337+
["run_id"] = context.github.event("workflow_run.id")
336338
}
337339
}
338340
new PublishUnitTestResult {
339341
name = "Publish test results"
340342
with {
341-
commit = "${{ github.event.workflow_run.head_sha }}"
342-
event_name = "${{ github.event.workflow_run.event }}"
343+
commit = context.github.event("workflow_run.head_sha")
344+
event_name = context.github.event("workflow_run.event")
343345
event_file = "artifacts/\(TEST_RESULT_EVENT_FILE_ARTIFACT_NAME)/event.json"
344346
file_patterns { "artifacts/**/*.xml" }
345347
comment_mode = "off"
@@ -485,6 +487,38 @@ local withPublishTestResults: Mixin<Workflow.Jobs> = (it) ->
485487
}
486488
}
487489

490+
/// Validate a Workflow; for use as a type constraint.
491+
///
492+
/// Validations
493+
/// * Steps must not contain known template injection vectors.
494+
hidden const isValid: (Workflow) -> Boolean = (workflow) ->
495+
let (
496+
errors =
497+
workflow.jobs
498+
.toMap()
499+
.entries
500+
.flatMap((job) ->
501+
if (job.value is Job)
502+
job.value.steps
503+
.toList()
504+
.flatMapIndexed((stepIndex, step) ->
505+
if (step is Step && step.run != null && step.run.contains("${{"))
506+
// there are other actions that have fields susceptible to template injection
507+
// but we don't use them
508+
// the zizmor project has a great tool for identifying these: https://github.com/zizmorcore/zizmor/blob/main/support/codeql-injection-sinks.py
509+
// and a few manually maintained entries: https://github.com/zizmorcore/zizmor/blob/cdc816b480a895144197fab903efc819810aca17/crates/zizmor/src/audit/template_injection.rs#L59-L74
510+
List(
511+
"[job:\(job.key) step:\(stepIndex) field:run] Potential template injection, use expressions via `env` instead"
512+
)
513+
else
514+
List()
515+
)
516+
else
517+
List()
518+
)
519+
)
520+
if (errors.isEmpty) true else throw("Invalid workflow:\n\(errors.join("\n"))")
521+
488522
local function withTriggerWorkflows(triggerType: String): Mixin<Workflow.Jobs> = (it) -> (it) {
489523
when (
490524
triggerType == triggerDocsBuild
@@ -514,14 +548,16 @@ local function withTriggerWorkflows(triggerType: String): Mixin<Workflow.Jobs> =
514548
new {
515549
name = "Trigger pkl-lang.org build"
516550
env {
517-
["GH_TOKEN"] = "${{ steps.app-token.outputs.token }}"
551+
["GH_TOKEN"] = context.steps.outputs("app-token", "token")
552+
["SOURCE_RUN"] =
553+
"\(context.github.serverUrl)/\(context.github.repository)/actions/runs/\(context.github.runId)"
518554
}
519555
run =
520556
#"""
521557
gh workflow run \
522558
--repo apple/pkl-lang.org \
523559
--ref main \
524-
--field source_run="\#(context.github.serverUrl)/\#(context.github.repository)/actions/runs/\#(context.github.runId)" \
560+
--field source_run="${SOURCE_RUN}" \
525561
main.yml
526562
"""#
527563
}
@@ -530,14 +566,16 @@ local function withTriggerWorkflows(triggerType: String): Mixin<Workflow.Jobs> =
530566
new {
531567
name = "Trigger pkl-package-docs build"
532568
env {
533-
["GH_TOKEN"] = "${{ steps.app-token.outputs.token }}"
569+
["GH_TOKEN"] = context.steps.outputs("app-token", "token")
570+
["SOURCE_RUN"] =
571+
"\(context.github.serverUrl)/\(context.github.repository)/actions/runs/\(context.github.runId)"
534572
}
535573
run =
536574
#"""
537575
gh workflow run \
538576
--repo apple/pkl-package-docs \
539577
--ref main \
540-
--field source_run="\#(context.github.serverUrl)/\#(context.github.repository)/actions/runs/\#(context.github.runId)" \
578+
--field source_run="${SOURCE_RUN}" \
541579
main.yml
542580
"""#
543581
}

packages/pkl.impl.ghactions/PklProject

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
amends "../basePklProject.pkl"
1818

1919
package {
20-
version = "1.4.0"
20+
version = "1.5.0"
2121
}
2222

2323
dependencies {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
amends "pkl:test"
17+
18+
import "@com.github.actions/context.pkl"
19+
import "@com.github.actions/Workflow.pkl"
20+
21+
import "../PklCI.pkl"
22+
23+
examples {
24+
["template injection"] {
25+
module.catch(() ->
26+
PklCI.isValid.apply(new Workflow {
27+
jobs {
28+
["foo"] {
29+
`runs-on` = "ubuntu-latest"
30+
steps {
31+
new { run = "echo \(context.github.refName)" }
32+
}
33+
}
34+
}
35+
})
36+
)
37+
}
38+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
examples {
2+
["template injection"] {
3+
"Invalid workflow: [job:foo step:0 field:run] Potential template injection, use expressions via `env` instead"
4+
}
5+
}

0 commit comments

Comments
 (0)