Skip to content

Commit 0f4f3c8

Browse files
committed
Implements multi-version support for extenal actions and workfows
1 parent 974e7cc commit 0f4f3c8

18 files changed

Lines changed: 269 additions & 3 deletions

File tree

actions/extractor/tools/autobuild-impl.ps1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ $DefaultPathFilters = @(
88
'include:.github/workflows/*.yaml',
99
'include:.github/reusable_workflows/**/*.yml',
1010
'include:.github/reusable_workflows/**/*.yaml',
11+
'include:.github/actions/external/mapping.yaml',
12+
'include:.github/workflows/external/mapping.yaml',
1113
'include:**/action.yml',
1214
'include:**/action.yaml'
1315
)

actions/extractor/tools/autobuild.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ include:.github/workflows/*.yml
1212
include:.github/workflows/*.yaml
1313
include:.github/reusable_workflows/**/*.yml
1414
include:.github/reusable_workflows/**/*.yaml
15+
include:.github/actions/external/mapping.yaml
16+
include:.github/workflows/external/mapping.yaml
1517
include:**/action.yml
1618
include:**/action.yaml
1719
END
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Provides predicates for resolving external action and workflow references
3+
* through SHA-based mapping files.
4+
*/
5+
6+
private import codeql.actions.ast.internal.Yaml
7+
8+
/**
9+
* Holds if the external action mapping file maps `ownerRepo` at `ref` to `sha`.
10+
*
11+
* The mapping file is expected at `.github/actions/external/mapping.yaml` and has
12+
* the following structure:
13+
* ```yaml
14+
* owner/repo:
15+
* ref: sha
16+
* ```
17+
*
18+
* This enables SHA-based directory resolution for external composite actions
19+
* stored in the `.github/actions/external/{owner}/{repo}/{sha}/` directory structure.
20+
*/
21+
predicate externalActionRefMapping(string ownerRepo, string ref, string sha) {
22+
exists(YamlMapping mapping, YamlMapping refMap |
23+
(
24+
mapping.getLocation().getFile().getRelativePath().matches("%/.github/actions/external/mapping.yaml")
25+
or
26+
mapping.getLocation().getFile().getRelativePath() = ".github/actions/external/mapping.yaml"
27+
) and
28+
refMap = mapping.lookup(ownerRepo) and
29+
sha = refMap.lookup(ref).(YamlScalar).getValue()
30+
)
31+
}
32+
33+
/**
34+
* Holds if the external workflow mapping file maps `ownerRepo` at `ref` to `sha`.
35+
*
36+
* The mapping file is expected at `.github/workflows/external/mapping.yaml` and has
37+
* the same structure as the action mapping file.
38+
*/
39+
predicate externalWorkflowRefMapping(string ownerRepo, string ref, string sha) {
40+
exists(YamlMapping mapping, YamlMapping refMap |
41+
(
42+
mapping.getLocation().getFile().getRelativePath().matches("%/.github/workflows/external/mapping.yaml")
43+
or
44+
mapping.getLocation().getFile().getRelativePath() = ".github/workflows/external/mapping.yaml"
45+
) and
46+
refMap = mapping.lookup(ownerRepo) and
47+
sha = refMap.lookup(ref).(YamlScalar).getValue()
48+
)
49+
}

actions/ql/lib/codeql/actions/dataflow/internal/DataFlowPrivate.qll

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ private import DataFlowPublic
88
private import codeql.actions.dataflow.ExternalFlow
99
private import codeql.actions.dataflow.FlowSteps
1010
private import codeql.actions.dataflow.FlowSources
11+
private import codeql.actions.MappingHelper
1112

1213
class DataFlowSecondLevelScope = Unit;
1314

@@ -50,7 +51,7 @@ predicate isArgumentNode(ArgumentNode arg, DataFlowCall call, ArgumentPosition p
5051
}
5152

5253
DataFlowCallable nodeGetEnclosingCallable(Node node) {
53-
node = TExprNode(any(DataFlowExpr e | result = e.getScope()))
54+
result = node.(ExprNode).getCfgNode().(DataFlowExpr).getScope()
5455
}
5556

5657
DataFlowType getNodeType(Node node) { any() }
@@ -80,6 +81,9 @@ class DataFlowCall instanceof Cfg::Node {
8081

8182
string getName() { result = super.getAstNode().(Uses).getCallee() }
8283

84+
/** Gets the version/ref of the action or workflow being called. */
85+
string getVersion() { result = super.getAstNode().(Uses).getVersion() }
86+
8387
DataFlowCallable getEnclosingCallable() { result = super.getScope() }
8488

8589
/** Gets a best-effort total ordering. */
@@ -119,7 +123,40 @@ class NormalReturn extends ReturnKind, TNormalReturn {
119123
}
120124

121125
/** Gets a viable implementation of the target of the given `Call`. */
122-
DataFlowCallable viableCallable(DataFlowCall c) { c.getName() = result.getName() }
126+
DataFlowCallable viableCallable(DataFlowCall c) {
127+
// Direct name match (existing behavior for local actions and backward compatibility)
128+
c.getName() = result.getName()
129+
or
130+
// SHA-based resolution via mapping.yaml for external composite actions.
131+
// Resolves uses: owner/repo[/path]@ref to owner/repo/sha[/path] using the mapping file.
132+
exists(string callee, string version, string ownerRepo, string sha |
133+
callee = c.getName() and
134+
version = c.getVersion() and
135+
ownerRepo = callee.regexpCapture("([^/]+/[^/]+)(?:/.*)?", 1) and
136+
externalActionRefMapping(ownerRepo, version, sha)
137+
|
138+
// With sub-path: e.g. actions/cache/restore@v4 -> actions/cache/{sha}/restore
139+
exists(string subpath |
140+
subpath = callee.regexpCapture("[^/]+/[^/]+/(.*)", 1) and
141+
result.getName() = [ownerRepo + "/" + sha + "/" + subpath, "./" + ownerRepo + "/" + sha + "/" + subpath]
142+
)
143+
or
144+
// No sub-path: e.g. actions/cache@v4 -> actions/cache/{sha}
145+
not callee.regexpMatch("[^/]+/[^/]+/.*") and
146+
result.getName() = [ownerRepo + "/" + sha, "./" + ownerRepo + "/" + sha]
147+
)
148+
or
149+
// SHA-based resolution via mapping.yaml for external callable workflows.
150+
// Resolves uses: owner/repo/path/to/workflow.yml@ref to owner/repo/sha/path/to/workflow.yml.
151+
exists(string callee, string version, string ownerRepo, string sha, string workflowPath |
152+
callee = c.getName() and
153+
version = c.getVersion() and
154+
ownerRepo = callee.regexpCapture("([^/]+/[^/]+)(?:/.*)?", 1) and
155+
workflowPath = callee.regexpCapture("[^/]+/[^/]+/(.*)", 1) and
156+
externalWorkflowRefMapping(ownerRepo, version, sha) and
157+
result.getName() = [ownerRepo + "/" + sha + "/" + workflowPath, "./" + ownerRepo + "/" + sha + "/" + workflowPath]
158+
)
159+
}
123160

124161
/**
125162
* Gets a node that can read the value returned from `call` with return kind

actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.ql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ where
5353
) and
5454
// the job writes to the cache
5555
// (No need to follow the checkout/download step since the cache is normally write after the job completes)
56-
job.getAStep() = step and
56+
// Check both direct job steps and steps inside composite actions called from the job.
57+
step.getEnclosingJob() = job and
5758
step instanceof CacheWritingStep and
5859
(
5960
// we dont know what code can be controlled by the attacker
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
name: Legacy Action (no SHA)
2+
description: An external action stored without SHA directory, for backward compat testing
3+
4+
runs:
5+
using: composite
6+
steps:
7+
- shell: bash
8+
run: echo "Legacy action without SHA-based resolution"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: Test Sub-Path Action
2+
description: A composite action at a sub-path for testing mapping resolution
3+
inputs:
4+
path:
5+
description: Path to restore
6+
required: true
7+
8+
runs:
9+
using: composite
10+
steps:
11+
- shell: bash
12+
run: |
13+
echo "Restoring ${{ inputs.path }}"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: Test Composite Action
2+
description: A simple composite action for testing mapping resolution
3+
inputs:
4+
message:
5+
description: A test message
6+
required: true
7+
8+
runs:
9+
using: composite
10+
steps:
11+
- shell: bash
12+
run: |
13+
echo "${{ inputs.message }}"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: Test Composite Action v2
2+
description: A v2 composite action for testing mapping resolution
3+
inputs:
4+
message:
5+
description: A test message
6+
required: true
7+
8+
runs:
9+
using: composite
10+
steps:
11+
- shell: bash
12+
run: |
13+
echo "v2: ${{ inputs.message }}"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
TestOrg/TestAction:
2+
v1: abc123def456
3+
v2: def789abc012
4+
TestOrg/TestAction-Sub:
5+
main: fedcba987654

0 commit comments

Comments
 (0)