dataflow through reusable workflows

This commit is contained in:
Alvaro Muñoz
2024-02-09 11:57:47 +01:00
parent 9659098ab6
commit 3152ed71ba
13 changed files with 300 additions and 100 deletions

View File

@@ -39,23 +39,21 @@ class WorkflowStmt extends Statement instanceof Actions::Workflow {
}
class ReusableWorkflowStmt extends WorkflowStmt {
YamlMapping parameters;
YamlValue workflow_call;
ReusableWorkflowStmt() {
exists(Actions::On on |
on.getWorkflow() = this and
on.getNode("workflow_call").(YamlMapping).lookup("inputs") = parameters
)
this.(Actions::Workflow).getOn().getNode("workflow_call") = workflow_call
}
ParamsStmt getParams() { result = parameters }
InputsStmt getInputs() { result = workflow_call.(YamlMapping).lookup("inputs") }
OutputsStmt getOutputs() { result = workflow_call.(YamlMapping).lookup("outputs") }
// TODO: implemnt callable name
string getName() { result = this.getLocation().getFile().getRelativePath() }
}
class ParamsStmt extends Statement instanceof YamlMapping {
ParamsStmt() {
class InputsStmt extends Statement instanceof YamlMapping {
InputsStmt() {
exists(Actions::On on | on.getNode("workflow_call").(YamlMapping).lookup("inputs") = this)
}
@@ -72,12 +70,38 @@ class ParamsStmt extends Statement instanceof YamlMapping {
* token:
* required: true
*/
ParamExpr getParamExpr(string name) {
this.(YamlMapping).maps(any(YamlScalar s | s.getValue() = name), result)
InputExpr getInputExpr(string name) {
result.(YamlString).getValue() = name and
this.(YamlMapping).maps(result, _)
}
}
class ParamExpr extends Expression instanceof YamlValue { }
class InputExpr extends Expression instanceof YamlString { }
class OutputsStmt extends Statement instanceof YamlMapping {
OutputsStmt() {
exists(Actions::On on | on.getNode("workflow_call").(YamlMapping).lookup("outputs") = this)
}
/**
* Gets a specific parameter expression (YamlMapping) by name.
* eg:
* on:
* workflow_call:
* outputs:
* firstword:
* description: "The first output string"
* value: ${{ jobs.example_job.outputs.output1 }}
* secondword:
* description: "The second output string"
* value: ${{ jobs.example_job.outputs.output2 }}
*/
OutputExpr getOutputExpr(string name) {
this.(YamlMapping).lookup(name).(YamlMapping).lookup("value") = result
}
}
class OutputExpr extends Expression instanceof YamlString { }
/**
* A Job is a collection of steps that run in an execution environment.
@@ -117,8 +141,13 @@ class JobStmt extends Statement instanceof Actions::Job {
/**
* Reusable workflow jobs may have Uses children
* eg:
* call-job:
* uses: ./.github/workflows/reusable_workflow.yml
* with:
* arg1: value1
*/
JobUsesExpr getUsesExpr() { result = this.(Actions::Job).lookup("uses") }
JobUsesExpr getUsesExpr() { result.getJob() = this }
}
/**
@@ -152,8 +181,11 @@ class StepStmt extends Statement instanceof Actions::Step {
JobStmt getJob() { result = super.getJob() }
}
/**
* Abstract class representing a call to a 3rd party action or reusable workflow.
*/
abstract class UsesExpr extends Expression {
abstract string getTarget();
abstract string getCallee();
abstract string getVersion();
@@ -168,7 +200,7 @@ class StepUsesExpr extends StepStmt, UsesExpr {
StepUsesExpr() { uses.getStep() = this }
override string getTarget() { result = uses.getGitHubRepository() }
override string getCallee() { result = uses.getGitHubRepository() }
override string getVersion() { result = uses.getVersion() }
@@ -183,12 +215,12 @@ class StepUsesExpr extends StepStmt, UsesExpr {
/**
* A Uses step represents a call to an action that is defined in a GitHub repository.
*/
class JobUsesExpr extends UsesExpr instanceof YamlScalar {
JobStmt job;
class JobUsesExpr extends UsesExpr instanceof YamlMapping {
JobUsesExpr() {
this instanceof JobStmt and this.maps(any(YamlString s | s.getValue() = "uses"), _)
}
JobUsesExpr() { job.(YamlMapping).lookup("uses") = this }
JobStmt getJob() { result = job }
JobStmt getJob() { result = this }
/**
* Gets a regular expression that parses an `owner/repo@version` reference within a `uses` field in an Actions job step.
@@ -200,31 +232,31 @@ class JobUsesExpr extends UsesExpr instanceof YamlScalar {
private string pathUsesParser() { result = "\\./(.+)" }
override string getTarget() {
exists(string name |
this.(YamlScalar).getValue() = name and
if name.matches("./%")
then result = name.regexpCapture(this.pathUsesParser(), 1)
override string getCallee() {
exists(YamlString name |
this.(YamlMapping).lookup("uses") = name and
if name.getValue().matches("./%")
then result = name.getValue().regexpCapture(this.pathUsesParser(), 1)
else
result =
name.regexpCapture(this.repoUsesParser(), 1) + "/" +
name.regexpCapture(this.repoUsesParser(), 2) + "/" +
name.regexpCapture(this.repoUsesParser(), 3)
name.getValue().regexpCapture(this.repoUsesParser(), 1) + "/" +
name.getValue().regexpCapture(this.repoUsesParser(), 2) + "/" +
name.getValue().regexpCapture(this.repoUsesParser(), 3)
)
}
/** Gets the version reference used when checking out the Action, e.g. `v2` in `actions/checkout@v2`. */
override string getVersion() {
exists(string name |
this.(YamlScalar).getValue() = name and
if not name.matches("\\.%")
then result = this.(YamlScalar).getValue().regexpCapture(this.repoUsesParser(), 4)
exists(YamlString name |
this.(YamlMapping).lookup("uses") = name and
if not name.getValue().matches("\\.%")
then result = name.getValue().regexpCapture(this.repoUsesParser(), 4)
else none()
)
}
override Expression getArgument(string key) {
job.(YamlMapping).lookup("with").(YamlMapping).lookup(key) = result
this.(YamlMapping).lookup("with").(YamlMapping).lookup(key) = result
}
}
@@ -287,6 +319,7 @@ class StepOutputAccessExpr extends ExprAccessExpr {
/**
* A ExprAccessExpr where the expression evaluated is a job output read.
* eg: `${{ needs.job1.outputs.foo}}`
* eg: `${{ jobs.job1.outputs.foo}}` (for reusable workflows)
*/
class JobOutputAccessExpr extends ExprAccessExpr {
string jobId;
@@ -294,9 +327,11 @@ class JobOutputAccessExpr extends ExprAccessExpr {
JobOutputAccessExpr() {
jobId =
this.getExpression().regexpCapture("needs\\.([A-Za-z0-9_-]+)\\.outputs\\.[A-Za-z0-9_-]+", 1) and
this.getExpression()
.regexpCapture("(needs|jobs)\\.([A-Za-z0-9_-]+)\\.outputs\\.[A-Za-z0-9_-]+", 2) and
varName =
this.getExpression().regexpCapture("needs\\.[A-Za-z0-9_-]+\\.outputs\\.([A-Za-z0-9_-]+)", 1)
this.getExpression()
.regexpCapture("(needs|jobs)\\.[A-Za-z0-9_-]+\\.outputs\\.([A-Za-z0-9_-]+)", 2)
}
string getVarName() { result = varName }
@@ -305,7 +340,35 @@ class JobOutputAccessExpr extends ExprAccessExpr {
exists(JobStmt job |
job.getId() = jobId and
job.getLocation().getFile() = this.getLocation().getFile() and
job.getOutputStmt().getOutputExpr(varName) = result
(
// A Job can have multiple outputs, so we need to check both
// jobs.<job_id>.outputs.<output_name>
job.getOutputStmt().getOutputExpr(varName) = result
or
// jobs.<job_id>.uses (variables returned from the reusable workflow
job.getUsesExpr() = result
)
)
}
}
/**
* A ExprAccessExpr where the expression evaluated is a reusable workflow input read.
* eg: `${{ inputs.foo}}`
*/
class ReusableWorkflowInputAccessExpr extends ExprAccessExpr {
string paramName;
ReusableWorkflowInputAccessExpr() {
paramName = this.getExpression().regexpCapture("inputs\\.([A-Za-z0-9_-]+)", 1)
}
string getParamName() { result = paramName }
Expression getInputExpr() {
exists(ReusableWorkflowStmt w |
w.getLocation().getFile() = this.getLocation().getFile() and
w.getInputs().getInputExpr(paramName) = result
)
}
}

View File

@@ -0,0 +1,3 @@
import DataFlow::DataFlow::Consistency

View File

@@ -7,4 +7,12 @@ module DataFlow {
private import codeql.actions.dataflow.internal.DataFlowImplSpecific
import DataFlowMake<ActionsDataFlow>
import codeql.actions.dataflow.internal.DataFlowPublic
/** debug */
private import codeql.actions.dataflow.internal.TaintTrackingImplSpecific
import codeql.dataflow.internal.DataFlowImplConsistency as DFIC
module ActionsConsistency implements DFIC::InputSig<ActionsDataFlow> { }
module Consistency {
import DFIC::MakeConsistency<ActionsDataFlow, ActionsTaintTracking, ActionsConsistency>
}
}

View File

@@ -87,9 +87,9 @@ module Completion {
module CfgScope {
abstract class CfgScope extends AstNode { }
private class ReusableWorkflowScope extends CfgScope instanceof ReusableWorkflowStmt { }
class ReusableWorkflowScope extends CfgScope instanceof ReusableWorkflowStmt { }
private class JobScope extends CfgScope instanceof JobStmt { }
class JobScope extends CfgScope instanceof JobStmt { }
}
private module Implementation implements CfgShared::InputSig<Location> {
@@ -123,7 +123,7 @@ private module Implementation implements CfgShared::InputSig<Location> {
int maxSplits() { result = 0 }
predicate scopeFirst(CfgScope scope, AstNode e) {
first(scope.(ReusableWorkflowStmt).getParams(), e) or
first(scope.(ReusableWorkflowStmt).getInputs(), e) or
first(scope.(JobStmt), e)
}
@@ -148,14 +148,11 @@ private import Completion
private import CfgScope
private class ReusableWorkflowTree extends StandardPreOrderTree instanceof ReusableWorkflowStmt {
override ControlFlowTree getChildNode(int i) { result = super.getParams() and i = 0 }
}
private class ReusableWorkflowParamsTree extends StandardPreOrderTree instanceof ParamsStmt {
override ControlFlowTree getChildNode(int i) {
result =
rank[i](Expression child, Location l |
child = super.getParamExpr(_) and l = child.getLocation()
(child = super.getInputs() or child = super.getOutputs()) and
l = child.getLocation()
|
child
order by
@@ -164,7 +161,35 @@ private class ReusableWorkflowParamsTree extends StandardPreOrderTree instanceof
}
}
private class ParamExprTree extends LeafTree instanceof ParamExpr { }
private class ReusableWorkflowInputsTree extends StandardPreOrderTree instanceof InputsStmt {
override ControlFlowTree getChildNode(int i) {
result =
rank[i](Expression child, Location l |
child = super.getInputExpr(_) and l = child.getLocation()
|
child
order by
l.getStartLine(), l.getStartColumn(), l.getEndColumn(), l.getEndLine(), child.toString()
)
}
}
private class InputExprTree extends LeafTree instanceof InputExpr { }
private class ReusableWorkflowOutputsTree extends StandardPreOrderTree instanceof OutputsStmt {
override ControlFlowTree getChildNode(int i) {
result =
rank[i](Expression child, Location l |
child = super.getOutputExpr(_) and l = child.getLocation()
|
child
order by
l.getStartLine(), l.getStartColumn(), l.getEndColumn(), l.getEndLine(), child.toString()
)
}
}
private class OutputExprTree extends LeafTree instanceof OutputExpr { }
private class JobTree extends StandardPreOrderTree instanceof JobStmt {
override ControlFlowTree getChildNode(int i) {

View File

@@ -127,7 +127,7 @@ private class EventSource extends RemoteFlowSource {
private class ChangedFilesSource extends RemoteFlowSource {
ChangedFilesSource() {
exists(UsesExpr uses |
uses.getTarget() = "tj-actions/changed-files" and
uses.getCallee() = "tj-actions/changed-files" and
uses.getVersion() = ["v10", "v20", "v30", "v40"] and
uses = this.asExpr()
)

View File

@@ -26,7 +26,7 @@ class AdditionalTaintStep extends Unit {
private class ActionsFindAndReplaceStringStep extends AdditionalTaintStep {
override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
exists(UsesExpr u |
u.getTarget() = "mad9000/actions-find-and-replace-string" and
u.getCallee() = "mad9000/actions-find-and-replace-string" and
pred.asExpr() = u.getArgument(["source", "replace"]) and
succ.asExpr() = u
)

View File

@@ -6,38 +6,7 @@ private import codeql.actions.controlflow.BasicBlocks
private import DataFlowPublic
cached
newtype TNode =
TExprNode(DataFlowExpr e) or
TParameterNode(ParamExpr p) { p = any(ReusableWorkflowStmt w).getParams().getParamExpr(_) } or
TReturningNode(Cfg::Node n) { n.getAstNode() = any(JobStmt j).getOutputStmt().getOutputExpr(_) }
/**
* Reusable workflow input nodes
*/
class ParameterNode extends Node, TParameterNode {
private ParamExpr parameter;
ParameterNode() { this = TParameterNode(parameter) }
predicate isParameterOf(DataFlowCallable c, ParameterPosition pos) {
parameter = c.(ReusableWorkflowStmt).getParams().getParamExpr(pos)
}
override string toString() { result = parameter.toString() }
override Location getLocation() { result = parameter.getLocation() }
ParamExpr getParameter() { result = parameter }
}
/**
* Reusable workflow output nodes
*/
class ReturnNode extends Node {
ReturnNode() { none() }
ReturnKind getKind() { none() }
}
newtype TNode = TExprNode(DataFlowExpr e)
class OutNode extends ExprNode {
private DataFlowCall call;
@@ -76,6 +45,8 @@ predicate isArgumentNode(ArgumentNode arg, DataFlowCall call, ArgumentPosition p
DataFlowCallable nodeGetEnclosingCallable(Node node) {
node = TExprNode(any(DataFlowExpr e | result = e.getScope()))
// node = TReturningNode(any(Cfg::Node n | result = n.getScope()))
// node = TParameterNode(any(InputExpr p | p = result.(ReusableWorkflowStmt).getInputs().getInputExpr(_)))
}
DataFlowType getNodeType(Node node) { any() }
@@ -97,21 +68,27 @@ class DataFlowCall instanceof Cfg::Node {
Location getLocation() { result = super.getLocation() }
string getName() { result = super.getAstNode().(UsesExpr).getTarget() }
string getName() { result = super.getAstNode().(UsesExpr).getCallee() }
DataFlowCallable getEnclosingCallable() { result = super.getScope() }
}
/**
* A Cfg scope that can be called
* ReusableWorkflowStmt
*/
class DataFlowCallable instanceof ReusableWorkflowStmt {
class DataFlowCallable instanceof Cfg::CfgScope {
string toString() { result = super.toString() }
Location getLocation() { result = super.getLocation() }
string getName() { result = super.getName() }
string getName() {
if this instanceof ReusableWorkflowStmt
then result = this.(ReusableWorkflowStmt).getName()
else
if this instanceof JobStmt
then result = this.(JobStmt).getId()
else none()
}
}
newtype TReturnKind = TNormalReturn()
@@ -188,7 +165,7 @@ ContentApprox getContentApprox(Content c) { none() }
* Made a string to match the ArgumentPosition type
*/
class ParameterPosition extends string {
ParameterPosition() { exists(any(ReusableWorkflowStmt w).getParams().getParamExpr(this)) }
ParameterPosition() { exists(any(ReusableWorkflowStmt w).getInputs().getInputExpr(this)) }
}
/**
@@ -231,20 +208,25 @@ predicate jobOutputDefToUse(Node nodeFrom, Node nodeTo) {
)
}
predicate reusableWorkflowInputDefToUse(Node nodeFrom, Node nodeTo) {
// nodeTo is a ReusableWorkflowInputAccessExpr and nodeFrom is the ReusableWorkflowStmt corresponding parameter expression
exists(Expression astFrom, ReusableWorkflowInputAccessExpr astTo |
astFrom = nodeFrom.asExpr() and
astTo = nodeTo.asExpr() and
astTo.getInputExpr() = astFrom
)
}
/**
* Holds if there is a local flow step from `nodeFrom` to `nodeTo`.
* For Actions, we dont need SSA nodes since it should be already in SSA form
* Local flow steps are always between two nodes in the same Cfg scope (job definition).
*/
pragma[nomagic]
predicate localFlowStep(Node nodeFrom, Node nodeTo) {
stepUsesOutputDefToUse(nodeFrom, nodeTo) or
runOutputDefToUse(nodeFrom, nodeTo) or
jobOutputDefToUse(nodeFrom, nodeTo)
}
predicate localFlowStep(Node nodeFrom, Node nodeTo) { none() }
/**
* a simple local flow step
* a simple local flow step that should always preserve the call context (same callable)
*/
predicate simpleLocalFlowStep(Node nodeFrom, Node nodeTo) { localFlowStep(nodeFrom, nodeTo) }
@@ -252,8 +234,16 @@ predicate simpleLocalFlowStep(Node nodeFrom, Node nodeTo) { localFlowStep(nodeFr
* Holds if data can flow from `node1` to `node2` through a non-local step
* that does not follow a call edge. For example, a step through a global
* variable.
* We throw away the call context and let us jump to any location
* AKA teleport steps
* local steps are preferible since they are more predictable and easier to control
*/
predicate jumpStep(Node node1, Node node2) { none() }
predicate jumpStep(Node nodeFrom, Node nodeTo) {
stepUsesOutputDefToUse(nodeFrom, nodeTo) or
runOutputDefToUse(nodeFrom, nodeTo) or
jobOutputDefToUse(nodeFrom, nodeTo) or
reusableWorkflowInputDefToUse(nodeFrom, nodeTo)
}
/**
* Holds if data can flow from `node1` to `node2` via a read of `c`. Thus,

View File

@@ -44,6 +44,28 @@ class ExprNode extends Node, TExprNode {
override AstNode asExpr() { result = expr.getAstNode() }
}
/**
* Reusable workflow input nodes
*/
class ParameterNode extends ExprNode {
private InputExpr parameter;
ParameterNode() {
this.asExpr() = parameter and
parameter = any(ReusableWorkflowStmt w).getInputs().getInputExpr(_)
}
predicate isParameterOf(DataFlowCallable c, ParameterPosition pos) {
parameter = c.(ReusableWorkflowStmt).getInputs().getInputExpr(pos)
}
override string toString() { result = parameter.toString() }
override Location getLocation() { result = parameter.getLocation() }
InputExpr getInputExpr() { result = parameter }
}
/**
* An argument to a Uses step (call)
*/
@@ -51,12 +73,30 @@ class ArgumentNode extends ExprNode {
ArgumentNode() { this.getCfgNode().getAstNode() = any(UsesExpr e).getArgument(_) }
predicate argumentOf(DataFlowCall call, ArgumentPosition pos) {
this.getCfgNode() = call.(Cfg::Node).getAPredecessor+() and
this.getCfgNode() = call.(Cfg::Node).getASuccessor+() and
call.(Cfg::Node).getAstNode() =
any(UsesExpr e | e.getArgument(pos) = this.getCfgNode().getAstNode())
}
}
/**
* Reusable workflow output nodes
*/
class ReturnNode extends ExprNode {
private Cfg::Node node;
ReturnNode() {
this.getCfgNode() = node and
node.getAstNode() = any(ReusableWorkflowStmt w).getOutputs().getOutputExpr(_)
}
ReturnKind getKind() { result = TNormalReturn() }
override string toString() { result = "return " + node.toString() }
override Location getLocation() { result = node.getLocation() }
}
/** Gets the node corresponding to `e`. */
Node exprNode(DataFlowExpr e) { result = TExprNode(e) }

View File

@@ -1,18 +1,38 @@
on: push
name: Call a reusable workflow and use its outputs
on:
workflow_dispatch:
jobs:
call-workflow-1-in-local-repo:
call1:
uses: octo-org/this-repo/.github/workflows/reusable_workflow.yml@172239021f7ba04fe7327647b213799853a9eb89
with:
config-path: ${{ github.event.pull_request.head.ref }}
secrets: inherit
call-workflow-2-in-local-repo:
call2:
uses: ./.github/workflows/reusable_workflow.yml
with:
config-path: ${{ github.event.pull_request.head.ref }}
secrets: inherit
call-workflow-in-another-repo:
call3:
uses: octo-org/another-repo/.github/workflows/workflow.yml@v1
with:
config-path: ${{ github.event.pull_request.head.ref }}
secrets: inherit
job1:
runs-on: ubuntu-latest
needs: call1
steps:
- run: echo ${{ needs.call1.outputs.workflow-output }}
job2:
runs-on: ubuntu-latest
needs: call2
steps:
- run: echo ${{ needs.call2.outputs.workflow-output1 }}
- run: echo ${{ needs.call2.outputs.workflow-output2 }}
job3:
runs-on: ubuntu-latest
needs: call3
steps:
- run: echo ${{ needs.call3.outputs.workflow-output }}

View File

@@ -6,13 +6,28 @@ on:
config-path:
required: true
type: string
outputs:
workflow-output1:
value: ${{ jobs.job1.outputs.job-output1 }}
workflow-output2:
value: ${{ jobs.job1.outputs.job-output2 }}
secrets:
token:
required: true
jobs:
triage:
job1:
runs-on: ubuntu-latest
outputs:
job-output1: ${{ steps.step1.outputs.step-output}}
job-output2: ${{ steps.step2.outputs.all_changed_files}}
steps:
- id: sink
run: echo ${{ inputs.config-path }}
- id: step1
env:
CONFIG_PATH: ${{ inputs.config-path }}
run: |
echo ${{ inputs.config-path }}
echo "::set-output name=step-output:: $CONFIG_PATH"
- name: Get changed files
id: step2
uses: tj-actions/changed-files@v40

View File

@@ -48,7 +48,7 @@ query predicate parentNodes(AstNode child, AstNode parent) { child.getParentNode
query predicate cfgNodes(Cfg::Node n) {
//any()
n.getAstNode() instanceof JobUsesExpr
n.getAstNode() instanceof OutputsStmt
}
query predicate dfNodes(DataFlow::Node e) {
@@ -66,3 +66,5 @@ query predicate usesIds(StepUsesExpr s, string a) { s.getId() = a }
query predicate varIds(StepOutputAccessExpr s, string a) { s.getStepId() = a }
query predicate nodeLocations(DataFlow::Node n, Location l) { n.getLocation() = l }
query predicate scopes(Cfg::CfgScope c) { any() }

View File

@@ -24,6 +24,7 @@ private module MyConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) { source instanceof RemoteFlowSource }
predicate isSink(DataFlow::Node sink) { sink instanceof ExpressionInjectionSink }
//predicate isSink(DataFlow::Node sink) { any() }
//predicate neverSkip(DataFlow::Node node) { any() }
}

33
ql/src/test/partial.ql Normal file
View File

@@ -0,0 +1,33 @@
/**
* @name Forward Partial Dataflow
* @description Forward Partial Dataflow
* @kind path-problem
* @precision low
* @problem.severity error
* @id actions/test-dataflow
*/
import actions
import codeql.actions.TaintTracking
import codeql.actions.dataflow.FlowSources
import PartialFlow::PartialPathGraph
private module MyConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
source instanceof RemoteFlowSource and
source.getLocation().getFile().getBaseName() = "calling_workflow.yml"
}
predicate isSink(DataFlow::Node sink) { none() }
}
private module MyFlow = TaintTracking::Global<MyConfig>; // or DataFlow::Global<..>
int explorationLimit() { result = 10 }
private module PartialFlow = MyFlow::FlowExplorationFwd<explorationLimit/0>;
from PartialFlow::PartialPathNode source, PartialFlow::PartialPathNode sink
where PartialFlow::partialFlow(source, sink, _)
select sink.getNode(), source, sink, "This node receives taint from $@.", source.getNode(),
"this source"