Skip to content

Actions: Diff-informed queries: phase 3 (non-trivial locations) #20072

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
private import actions
private import codeql.actions.TaintTracking
private import codeql.actions.dataflow.ExternalFlow
private import codeql.actions.security.ControlChecks
import codeql.actions.dataflow.FlowSources
import codeql.actions.DataFlow

Expand Down Expand Up @@ -65,6 +66,16 @@ class ArgumentInjectionFromMaDSink extends ArgumentInjectionSink {
override string getCommand() { result = "unknown" }
}

/**
* Gets the event that is relevant for the given node in the context of argument injection.
*
* This is used to highlight the event in the query results when an alert is raised.
*/
Event getRelevantEventInPrivilegedContext(DataFlow::Node node) {
inPrivilegedContext(node.asExpr(), result) and
not exists(ControlCheck check | check.protects(node.asExpr(), result, "argument-injection"))
}

/**
* A taint-tracking configuration for unsafe user input
* that is used to construct and evaluate a code script.
Expand All @@ -88,6 +99,16 @@ private module ArgumentInjectionConfig implements DataFlow::ConfigSig {
run.getScript().getAnEnvReachingArgumentInjectionSink(var, _, _)
)
}

predicate observeDiffInformedIncrementalMode() { any() }

Location getASelectedSourceLocation(DataFlow::Node source) { none() }

Location getASelectedSinkLocation(DataFlow::Node sink) {
result = sink.getLocation()
or
result = getRelevantEventInPrivilegedContext(sink).getLocation()
}
}

/** Tracks flow of unsafe user input that is used to construct and evaluate a code script. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import codeql.actions.DataFlow
import codeql.actions.dataflow.FlowSources
import codeql.actions.security.PoisonableSteps
import codeql.actions.security.UntrustedCheckoutQuery
import codeql.actions.security.ControlChecks

string unzipRegexp() { result = "(unzip|tar)\\s+.*" }

Expand Down Expand Up @@ -292,6 +293,16 @@ class ArtifactPoisoningSink extends DataFlow::Node {
string getPath() { result = download.getPath() }
}

/**
* Gets the event that is relevant for the given node in the context of artifact poisoning.
*
* This is used to highlight the event in the query results when an alert is raised.
*/
Event getRelevantEventInPrivilegedContext(DataFlow::Node node) {
inPrivilegedContext(node.asExpr(), result) and
not exists(ControlCheck check | check.protects(node.asExpr(), result, "artifact-poisoning"))
}

/**
* A taint-tracking configuration for unsafe artifacts
* that is used may lead to artifact poisoning
Expand All @@ -318,6 +329,16 @@ private module ArtifactPoisoningConfig implements DataFlow::ConfigSig {
exists(run.getScript().getAFileReadCommand())
)
}

predicate observeDiffInformedIncrementalMode() { any() }

Location getASelectedSourceLocation(DataFlow::Node source) { none() }

Location getASelectedSinkLocation(DataFlow::Node sink) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above.

result = sink.getLocation()
or
result = getRelevantEventInPrivilegedContext(sink).getLocation()
}
}

/** Tracks flow of unsafe artifacts that is used in an insecure way. */
Expand Down
54 changes: 54 additions & 0 deletions actions/ql/lib/codeql/actions/security/CodeInjectionQuery.qll
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ private import codeql.actions.TaintTracking
private import codeql.actions.dataflow.ExternalFlow
import codeql.actions.dataflow.FlowSources
import codeql.actions.DataFlow
import codeql.actions.security.ControlChecks
import codeql.actions.security.CachePoisoningQuery

class CodeInjectionSink extends DataFlow::Node {
CodeInjectionSink() {
Expand All @@ -11,6 +13,46 @@ class CodeInjectionSink extends DataFlow::Node {
}
}

/**
* Get the relevant event for the sink in CodeInjectionCritical.ql.
*/
Event getRelevantCriticalEventForSink(DataFlow::Node sink) {
inPrivilegedContext(sink.asExpr(), result) and
not exists(ControlCheck check | check.protects(sink.asExpr(), result, "code-injection")) and
// exclude cases where the sink is a JS script and the expression uses toJson
not exists(UsesStep script |
script.getCallee() = "actions/github-script" and
script.getArgumentExpr("script") = sink.asExpr() and
exists(getAToJsonReferenceExpression(sink.asExpr().(Expression).getExpression(), _))
)
}

/**
* Get the relevant event for the sink in CachePoisoningViaCodeInjection.ql.
*/
Event getRelevantCachePoisoningEventForSink(DataFlow::Node sink) {
exists(LocalJob job |
job = sink.asExpr().getEnclosingJob() and
job.getATriggerEvent() = result and
// job can be triggered by an external user
result.isExternallyTriggerable() and
// excluding privileged workflows since they can be exploited in easier circumstances
// which is covered by `actions/code-injection/critical`
not job.isPrivilegedExternallyTriggerable(result) and
(
// the workflow runs in the context of the default branch
runsOnDefaultBranch(result)
or
// the workflow caller runs in the context of the default branch
result.getName() = "workflow_call" and
exists(ExternalJob caller |
caller.getCallee() = job.getLocation().getFile().getRelativePath() and
runsOnDefaultBranch(caller.getATriggerEvent())
)
)
)
}

/**
* A taint-tracking configuration for unsafe user input
* that is used to construct and evaluate a code script.
Expand All @@ -35,6 +77,18 @@ private module CodeInjectionConfig implements DataFlow::ConfigSig {
exists(run.getScript().getAFileReadCommand())
)
}

predicate observeDiffInformedIncrementalMode() { any() }

Location getASelectedSourceLocation(DataFlow::Node source) { none() }

Location getASelectedSinkLocation(DataFlow::Node sink) {
result = sink.getLocation()
or
result = getRelevantCriticalEventForSink(sink).getLocation()
or
result = getRelevantCachePoisoningEventForSink(sink).getLocation()
}
}

/** Tracks flow of unsafe user input that is used to construct and evaluate a code script. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@ private import codeql.actions.TaintTracking
private import codeql.actions.dataflow.ExternalFlow
import codeql.actions.dataflow.FlowSources
import codeql.actions.DataFlow
import codeql.actions.security.ControlChecks

private class CommandInjectionSink extends DataFlow::Node {
CommandInjectionSink() { madSink(this, "command-injection") }
}

/** Get the relevant event for the sink in CommandInjectionCritical.ql. */
Event getRelevantEventInPrivilegedContext(DataFlow::Node sink) {
inPrivilegedContext(sink.asExpr(), result) and
not exists(ControlCheck check |
check.protects(sink.asExpr(), result, ["command-injection", "code-injection"])
)
}

/**
* A taint-tracking configuration for unsafe user input
* that is used to construct and evaluate a system command.
Expand All @@ -16,6 +25,16 @@ private module CommandInjectionConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) { source instanceof RemoteFlowSource }

predicate isSink(DataFlow::Node sink) { sink instanceof CommandInjectionSink }

predicate observeDiffInformedIncrementalMode() { any() }

Location getASelectedSourceLocation(DataFlow::Node source) { none() }

Location getASelectedSinkLocation(DataFlow::Node sink) {
result = sink.getLocation()
or
result = getRelevantEventInPrivilegedContext(sink).getLocation()
}
}

/** Tracks flow of unsafe user input that is used to construct and evaluate a system command. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,25 @@ class EnvPathInjectionFromMaDSink extends EnvPathInjectionSink {
EnvPathInjectionFromMaDSink() { madSink(this, "envpath-injection") }
}

/**
* Get the relevant event for a sink in EnvPathInjectionCritical.ql where the source type is "artifact".
*/
Event getRelevantArtifactEventInPrivilegedContext(DataFlow::Node sink) {
inPrivilegedContext(sink.asExpr(), result) and
not exists(ControlCheck check |
check.protects(sink.asExpr(), result, ["untrusted-checkout", "artifact-poisoning"])
) and
sink instanceof EnvPathInjectionFromFileReadSink
}

/**
* Get the relevant event for a sink in EnvPathInjectionCritical.ql where the source type is not "artifact".
*/
Event getRelevantNonArtifactEventInPrivilegedContext(DataFlow::Node sink) {
inPrivilegedContext(sink.asExpr(), result) and
not exists(ControlCheck check | check.protects(sink.asExpr(), result, "code-injection"))
}

/**
* A taint-tracking configuration for unsafe user input
* that is used to construct and evaluate an environment variable.
Expand Down Expand Up @@ -108,6 +127,18 @@ private module EnvPathInjectionConfig implements DataFlow::ConfigSig {
exists(run.getScript().getAFileReadCommand())
)
}

predicate observeDiffInformedIncrementalMode() { any() }

Location getASelectedSourceLocation(DataFlow::Node source) { none() }

Location getASelectedSinkLocation(DataFlow::Node sink) {
result = sink.getLocation()
or
result = getRelevantArtifactEventInPrivilegedContext(sink).getLocation()
or
result = getRelevantNonArtifactEventInPrivilegedContext(sink).getLocation()
}
}

/** Tracks flow of unsafe user input that is used to construct and evaluate the PATH environment variable. */
Expand Down
38 changes: 38 additions & 0 deletions actions/ql/lib/codeql/actions/security/EnvVarInjectionQuery.qll
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ class EnvVarInjectionFromMaDSink extends EnvVarInjectionSink {
EnvVarInjectionFromMaDSink() { madSink(this, "envvar-injection") }
}

/**
* Get the relevant event for a sink in EnvVarInjectionCritical.ql where the source type is "artifact".
*/
Event getRelevantArtifactEventInPrivilegedContext(DataFlow::Node sink) {
inPrivilegedContext(sink.asExpr(), result) and
not exists(ControlCheck check |
check
.protects(sink.asExpr(), result,
["envvar-injection", "untrusted-checkout", "artifact-poisoning"])
) and
(
sink instanceof EnvVarInjectionFromFileReadSink or
madSink(sink, "envvar-injection")
)
}

/**
* Get the relevant event for a sink in EnvVarInjectionCritical.ql where the source type is not "artifact".
*/
Event getRelevantNonArtifactEventInPrivilegedContext(DataFlow::Node sink) {
inPrivilegedContext(sink.asExpr(), result) and
not exists(ControlCheck check |
check.protects(sink.asExpr(), result, ["envvar-injection", "code-injection"])
)
}

/**
* A taint-tracking configuration for unsafe user input
* that is used to construct and evaluate an environment variable.
Expand Down Expand Up @@ -163,6 +189,18 @@ private module EnvVarInjectionConfig implements DataFlow::ConfigSig {
exists(run.getScript().getAFileReadCommand())
)
}

predicate observeDiffInformedIncrementalMode() { any() }

Location getASelectedSourceLocation(DataFlow::Node source) { none() }

Location getASelectedSinkLocation(DataFlow::Node sink) {
result = sink.getLocation()
or
result = getRelevantArtifactEventInPrivilegedContext(sink).getLocation()
or
result = getRelevantNonArtifactEventInPrivilegedContext(sink).getLocation()
}
}

/** Tracks flow of unsafe user input that is used to construct and evaluate an environment variable. */
Expand Down
10 changes: 2 additions & 8 deletions actions/ql/src/Security/CWE-077/EnvPathInjectionCritical.ql
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,12 @@ import codeql.actions.security.ControlChecks
from EnvPathInjectionFlow::PathNode source, EnvPathInjectionFlow::PathNode sink, Event event
where
EnvPathInjectionFlow::flowPath(source, sink) and
inPrivilegedContext(sink.getNode().asExpr(), event) and
(
not source.getNode().(RemoteFlowSource).getSourceType() = "artifact" and
not exists(ControlCheck check |
check.protects(sink.getNode().asExpr(), event, "code-injection")
)
event = getRelevantNonArtifactEventInPrivilegedContext(sink.getNode())
or
source.getNode().(RemoteFlowSource).getSourceType() = "artifact" and
not exists(ControlCheck check |
check.protects(sink.getNode().asExpr(), event, ["untrusted-checkout", "artifact-poisoning"])
) and
sink.getNode() instanceof EnvPathInjectionFromFileReadSink
event = getRelevantArtifactEventInPrivilegedContext(sink.getNode())
)
select sink.getNode(), source, sink,
"Potential PATH environment variable injection in $@, which may be controlled by an external user ($@).",
Expand Down
15 changes: 2 additions & 13 deletions actions/ql/src/Security/CWE-077/EnvVarInjectionCritical.ql
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,15 @@ import codeql.actions.security.ControlChecks
from EnvVarInjectionFlow::PathNode source, EnvVarInjectionFlow::PathNode sink, Event event
where
EnvVarInjectionFlow::flowPath(source, sink) and
inPrivilegedContext(sink.getNode().asExpr(), event) and
// exclude paths to file read sinks from non-artifact sources
(
// source is text
not source.getNode().(RemoteFlowSource).getSourceType() = "artifact" and
not exists(ControlCheck check |
check.protects(sink.getNode().asExpr(), event, ["envvar-injection", "code-injection"])
)
event = getRelevantNonArtifactEventInPrivilegedContext(sink.getNode())
or
// source is an artifact or a file from an untrusted checkout
source.getNode().(RemoteFlowSource).getSourceType() = "artifact" and
not exists(ControlCheck check |
check
.protects(sink.getNode().asExpr(), event,
["envvar-injection", "untrusted-checkout", "artifact-poisoning"])
) and
(
sink.getNode() instanceof EnvVarInjectionFromFileReadSink or
madSink(sink.getNode(), "envvar-injection")
)
event = getRelevantArtifactEventInPrivilegedContext(sink.getNode())
)
select sink.getNode(), source, sink,
"Potential environment variable injection in $@, which may be controlled by an external user ($@).",
Expand Down
11 changes: 2 additions & 9 deletions actions/ql/src/Security/CWE-094/CodeInjectionCritical.ql
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,8 @@ import codeql.actions.security.ControlChecks
from CodeInjectionFlow::PathNode source, CodeInjectionFlow::PathNode sink, Event event
where
CodeInjectionFlow::flowPath(source, sink) and
inPrivilegedContext(sink.getNode().asExpr(), event) and
source.getNode().(RemoteFlowSource).getEventName() = event.getName() and
not exists(ControlCheck check | check.protects(sink.getNode().asExpr(), event, "code-injection")) and
// exclude cases where the sink is a JS script and the expression uses toJson
not exists(UsesStep script |
script.getCallee() = "actions/github-script" and
script.getArgumentExpr("script") = sink.getNode().asExpr() and
exists(getAToJsonReferenceExpression(sink.getNode().asExpr().(Expression).getExpression(), _))
)
event = getRelevantCriticalEventForSink(sink.getNode()) and
source.getNode().(RemoteFlowSource).getEventName() = event.getName()
select sink.getNode(), source, sink,
"Potential code injection in $@, which may be controlled by an external user ($@).", sink,
sink.getNode().asExpr().(Expression).getRawExpression(), event, event.getName()
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,13 @@ import codeql.actions.security.CachePoisoningQuery
import CodeInjectionFlow::PathGraph
import codeql.actions.security.ControlChecks

from CodeInjectionFlow::PathNode source, CodeInjectionFlow::PathNode sink, LocalJob job, Event event
from CodeInjectionFlow::PathNode source, CodeInjectionFlow::PathNode sink, Event event
where
CodeInjectionFlow::flowPath(source, sink) and
job = sink.getNode().asExpr().getEnclosingJob() and
job.getATriggerEvent() = event and
// job can be triggered by an external user
event.isExternallyTriggerable() and
event = getRelevantCachePoisoningEventForSink(sink.getNode()) and
// the checkout is not controlled by an access check
not exists(ControlCheck check |
check.protects(source.getNode().asExpr(), event, "code-injection")
) and
// excluding privileged workflows since they can be exploited in easier circumstances
// which is covered by `actions/code-injection/critical`
not job.isPrivilegedExternallyTriggerable(event) and
(
// the workflow runs in the context of the default branch
runsOnDefaultBranch(event)
or
// the workflow caller runs in the context of the default branch
event.getName() = "workflow_call" and
exists(ExternalJob caller |
caller.getCallee() = job.getLocation().getFile().getRelativePath() and
runsOnDefaultBranch(caller.getATriggerEvent())
)
)
select sink.getNode(), source, sink,
"Unprivileged code injection in $@, which may lead to cache poisoning ($@).", sink,
Expand Down
Loading