diff --git a/ql/lib/codeql/actions/Ast.qll b/ql/lib/codeql/actions/Ast.qll index 759bcf3f786..cc29ceffe53 100644 --- a/ql/lib/codeql/actions/Ast.qll +++ b/ql/lib/codeql/actions/Ast.qll @@ -315,6 +315,22 @@ class Run extends Step instanceof RunImpl { } predicate getAWriteToGitHubPath(string value) { super.getAWriteToGitHubPath(value) } + + predicate getAnEnvReachingGitHubOutputWrite(string var, string output_field) { + super.getAnEnvReachingGitHubOutputWrite(var, output_field) + } + + predicate getACmdReachingGitHubOutputWrite(string cmd, string output_field) { + super.getACmdReachingGitHubOutputWrite(cmd, output_field) + } + + predicate getAnEnvReachingGitHubEnvWrite(string var, string output_field) { + super.getAnEnvReachingGitHubEnvWrite(var, output_field) + } + + predicate getACmdReachingGitHubEnvWrite(string cmd, string output_field) { + super.getACmdReachingGitHubEnvWrite(cmd, output_field) + } } abstract class SimpleReferenceExpression extends AstNode instanceof SimpleReferenceExpressionImpl { diff --git a/ql/lib/codeql/actions/Bash.qll b/ql/lib/codeql/actions/Bash.qll new file mode 100644 index 00000000000..5907b601a46 --- /dev/null +++ b/ql/lib/codeql/actions/Bash.qll @@ -0,0 +1,364 @@ +private import codeql.actions.Ast +private import codeql.Locations +import codeql.actions.config.Config +private import codeql.actions.security.ControlChecks + +module Bash { + string stmtSeparator() { result = ";" } + + string commandSeparator() { result = ["&&", "||"] } + + string pipeSeparator() { result = "|" } + + string splitSeparators() { + result = stmtSeparator() or result = commandSeparator() or result = pipeSeparator() + } + + string redirectionSeparator() { result = [">", ">>", "2>", "2>>", ">&", "2>&", "<", "<<<"] } + + string partialFileContentCommand() { result = ["cat", "jq", "yq", "tail", "head"] } + + /** Checks if expr is a bash command substitution */ + bindingset[expr] + predicate isCmdSubstitution(string expr, string cmd) { + exists(string regexp | + // $(cmd) + regexp = "\\$\\(([^)]+)\\)" and + cmd = expr.regexpCapture(regexp, 1) + or + // `cmd` + regexp = "`([^`]+)`" and + cmd = expr.regexpCapture(regexp, 1) + ) + } + + /** Checks if expr is a bash command substitution */ + bindingset[expr] + predicate containsCmdSubstitution(string expr, string cmd) { + exists(string regexp | + // $(cmd) + regexp = ".*\\$\\(([^)]+)\\).*" and + cmd = expr.regexpCapture(regexp, 1) + or + // `cmd` + regexp = ".*`([^`]+)`.*" and + cmd = expr.regexpCapture(regexp, 1) + ) + } + + /** Checks if expr is a bash parameter expansion */ + bindingset[expr] + predicate isParameterExpansion(string expr, string parameter, string operator, string params) { + exists(string regexp | + // $VAR + regexp = "\\$([a-zA-Z_][a-zA-Z0-9_]+)\\b" and + parameter = expr.regexpCapture(regexp, 1) and + operator = "" and + params = "" + or + // ${VAR} + regexp = "\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}" and + parameter = expr.regexpCapture(regexp, 1) and + operator = "" and + params = "" + or + // ${!VAR} + regexp = "\\$\\{([!#])([a-zA-Z_][a-zA-Z0-9_]*)\\}" and + parameter = expr.regexpCapture(regexp, 2) and + operator = expr.regexpCapture(regexp, 1) and + params = "" + or + // ${VAR}, ... + regexp = "\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)([#%/:^,\\-+]{1,2})?(.*?)\\}" and + parameter = expr.regexpCapture(regexp, 1) and + operator = expr.regexpCapture(regexp, 2) and + params = expr.regexpCapture(regexp, 3) + ) + } + + bindingset[expr] + predicate containsParameterExpansion(string expr, string parameter, string operator, string params) { + exists(string regexp | + // $VAR + regexp = ".*\\$([a-zA-Z_][a-zA-Z0-9_]+)\\b.*" and + parameter = expr.regexpCapture(regexp, 1) and + operator = "" and + params = "" + or + // ${VAR} + regexp = ".*\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}.*" and + parameter = expr.regexpCapture(regexp, 1) and + operator = "" and + params = "" + or + // ${!VAR} + regexp = ".*\\$\\{([!#])([a-zA-Z_][a-zA-Z0-9_]*)\\}.*" and + parameter = expr.regexpCapture(regexp, 2) and + operator = expr.regexpCapture(regexp, 1) and + params = "" + or + // ${VAR}, ... + regexp = ".*\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)([#%/:^,\\-+]{1,2})?(.*?)\\}.*" and + parameter = expr.regexpCapture(regexp, 1) and + operator = expr.regexpCapture(regexp, 2) and + params = expr.regexpCapture(regexp, 3) + ) + } + + bindingset[raw_content] + predicate extractVariableAndValue(string raw_content, string key, string value) { + exists(string regexp, string content | content = trimQuotes(raw_content) | + regexp = "(?msi).*^([a-zA-Z_][a-zA-Z0-9_]*)\\s*<<\\s*['\"]?(\\S+)['\"]?\\s*\n(.*?)\n\\2\\s*$" and + key = trimQuotes(content.regexpCapture(regexp, 1)) and + value = trimQuotes(content.regexpCapture(regexp, 3)) + or + exists(string line | + line = content.splitAt("\n") and + regexp = "(?i)^([a-zA-Z_][a-zA-Z0-9_\\-]*)\\s*=\\s*(.*)$" and + key = trimQuotes(line.regexpCapture(regexp, 1)) and + value = trimQuotes(line.regexpCapture(regexp, 2)) + ) + ) + } + + bindingset[script] + predicate singleLineFileWrite( + string script, string cmd, string file, string content, string filters + ) { + exists(string regexp | + regexp = + "(?i)(echo|printf|write-output)\\s*(.*?)\\s*(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+)" and + cmd = script.regexpCapture(regexp, 1) and + file = trimQuotes(script.regexpCapture(regexp, 5)) and + filters = "" and + content = script.regexpCapture(regexp, 2) + ) + } + + bindingset[script] + predicate singleLineWorkflowCmd(string script, string cmd, string key, string value) { + exists(string regexp | + regexp = + "(?i)(echo|printf|write-output)\\s*(['|\"])?::(set-[a-z]+)\\s*name\\s*=\\s*(.*?)::(.*)" and + cmd = script.regexpCapture(regexp, 3) and + key = script.regexpCapture(regexp, 4) and + value = trimQuotes(script.regexpCapture(regexp, 5)) + or + regexp = "(?i)(echo|printf|write-output)\\s*(['|\"])?::(add-[a-z]+)\\s*::(.*)" and + cmd = script.regexpCapture(regexp, 3) and + key = "" and + value = trimQuotes(script.regexpCapture(regexp, 4)) + ) + } + + bindingset[script] + predicate heredocFileWrite(string script, string cmd, string file, string content, string filters) { + exists(string regexp | + regexp = + "(?msi).*^(cat)\\s*(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+)\\s*<<\\s*['\"]?(\\S+)['\"]?\\s*\n(.*?)\n\\4\\s*$.*" and + cmd = script.regexpCapture(regexp, 1) and + file = trimQuotes(script.regexpCapture(regexp, 4)) and + content = script.regexpCapture(regexp, 6) and + filters = "" + or + regexp = + "(?msi).*^(cat)\\s*(<<|<)\\s*[-]?['\"]?(\\S+)['\"]?\\s*([^>]*)(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+)\\s*\n(.*?)\n\\3\\s*$.*" and + cmd = script.regexpCapture(regexp, 1) and + file = trimQuotes(script.regexpCapture(regexp, 7)) and + filters = script.regexpCapture(regexp, 4) and + content = script.regexpCapture(regexp, 8) + ) + } + + bindingset[script] + predicate linesFileWrite(string script, string cmd, string file, string content, string filters) { + exists(string regexp, string var_name | + regexp = + "(?msi).*((echo|printf)\\s+['|\"]?(.*?<<(\\S+))['|\"]?\\s*>>\\s*(\\S+)\\s*[\r\n]+)" + + "(((.*?)\\s*>>\\s*\\S+\\s*[\r\n]+)+)" + + "((echo|printf)\\s+['|\"]?(EOF)['|\"]?\\s*>>\\s*\\S+\\s*[\r\n]*).*" and + var_name = trimQuotes(script.regexpCapture(regexp, 3)).regexpReplaceAll("<<\\s*(\\S+)", "") and + content = + var_name + "=$(" + + trimQuotes(script.regexpCapture(regexp, 6)) + .regexpReplaceAll(">>.*GITHUB_(ENV|OUTPUT)(})?", "") + .trim() + ")" and + cmd = "echo" and + file = trimQuotes(script.regexpCapture(regexp, 5)) and + filters = "" + ) + } + + bindingset[script] + predicate blockFileWrite(string script, string cmd, string file, string content, string filters) { + exists(string regexp, string first_line, string var_name | + regexp = + "(?msi).*^\\s*\\{\\s*[\r\n]" + + // + "(.*?)" + + // + "(\\s*\\}\\s*(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+))\\s*$.*" and + first_line = script.regexpCapture(regexp, 1).splitAt("\n", 0).trim() and + var_name = first_line.regexpCapture("echo\\s+('|\\\")?(.*)<<.*", 2) and + content = var_name + "=$(" + script.regexpCapture(regexp, 1).splitAt("\n").trim() + ")" and + not content.indexOf("EOF") > 0 and + file = trimQuotes(script.regexpCapture(regexp, 5)) and + cmd = "echo" and + filters = "" + ) + } + + bindingset[script] + predicate multiLineFileWrite( + string script, string cmd, string file, string content, string filters + ) { + heredocFileWrite(script, cmd, file, content, filters) + or + linesFileWrite(script, cmd, file, content, filters) + or + blockFileWrite(script, cmd, file, content, filters) + } + + bindingset[script, file_var] + predicate extractFileWrite(string script, string file_var, string content) { + // single line assignment + exists(string file_expr, string raw_content | + isParameterExpansion(file_expr, file_var, _, _) and + singleLineFileWrite(script.splitAt("\n"), _, file_expr, raw_content, _) and + content = trimQuotes(raw_content) + ) + or + // workflow command assignment + exists(string key, string value, string cmd | + ( + file_var = "GITHUB_ENV" and + cmd = "set-env" and + content = key + "=" + value + or + file_var = "GITHUB_OUTPUT" and + cmd = "set-output" and + content = key + "=" + value + or + file_var = "GITHUB_PATH" and + cmd = "add-path" and + content = value + ) and + singleLineWorkflowCmd(script.splitAt("\n"), cmd, key, value) + ) + or + // multiline assignment + exists(string file_expr, string raw_content | + multiLineFileWrite(script, _, file_expr, raw_content, _) and + isParameterExpansion(file_expr, file_var, _, _) and + content = trimQuotes(raw_content) + ) + } + + /** Writes the content of the file specified by `path` into a file pointed to by `file_var` */ + predicate fileToFileWrite(Run run, string file_var, string path) { + exists(string regexp, string stmt, string file_expr | + regexp = + "(?i)(cat)\\s*" + "((?:(?!<<|<<-)[^>\n])+)\\s*" + + "(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*" + "(\\S+)" and + stmt = run.getAStmt() and + file_expr = trimQuotes(stmt.regexpCapture(regexp, 5)) and + path = stmt.regexpCapture(regexp, 2) and + containsParameterExpansion(file_expr, file_var, _, _) + ) + } + + predicate fileToGitHubEnv(Run run, string path) { fileToFileWrite(run, "GITHUB_ENV", path) } + + predicate fileToGitHubOutput(Run run, string path) { fileToFileWrite(run, "GITHUB_OUTPUT", path) } + + predicate fileToGitHubPath(Run run, string path) { fileToFileWrite(run, "GITHUB_PATH", path) } + + bindingset[snippet] + predicate outputsPartialFileContent(Run run, string snippet) { + // e.g. + // echo FOO=`yq '.foo' foo.yml` >> $GITHUB_ENV + // echo "FOO=$(> $GITHUB_ENV + // yq '.foo' foo.yml >> $GITHUB_PATH + // cat foo.txt >> $GITHUB_PATH + exists(int i, string line, string cmd | + run.getStmt(i) = line and + line.indexOf(snippet.regexpReplaceAll("^\\$\\(", "").regexpReplaceAll("\\)$", "")) > -1 and + run.getCommand(i) = cmd and + cmd.indexOf(["<", Bash::partialFileContentCommand() + " "]) = 0 + ) + } + + /** + * Holds if the Run scripts contains an access to an environment variable called `var` + * which value may get appended to the GITHUB_XXX special file + */ + predicate envReachingGitHubFileWrite(Run run, string var, string file_var, string field) { + exists(string file_write_value | + ( + file_var = "GITHUB_ENV" and + run.getAWriteToGitHubEnv(field, file_write_value) + or + file_var = "GITHUB_OUTPUT" and + run.getAWriteToGitHubOutput(field, file_write_value) + or + file_var = "GITHUB_PATH" and + field = "PATH" and + run.getAWriteToGitHubPath(file_write_value) + ) and + envReachingRunExpr(run, var, file_write_value) + ) + } + + /** + * Holds if and environment variable is used, directly or indirectly, in a Run's step expression. + * Where the expression is a string captured from the Run's script. + */ + bindingset[expr] + predicate envReachingRunExpr(Run run, string var, string expr) { + exists(string var2, string value2 | + // VAR2=${VAR:-default} (var2=value2) + // echo "FIELD=${VAR2:-default}" >> $GITHUB_ENV (field, file_write_value) + run.getAnAssignment(var2, value2) and + containsParameterExpansion(value2, var, _, _) and + containsParameterExpansion(expr, var2, _, _) + ) + or + // var reaches the file write directly + // echo "FIELD=${VAR:-default}" >> $GITHUB_ENV (field, file_write_value) + containsParameterExpansion(expr, var, _, _) + } + + /** + * Holds if the Run scripts contains a command substitution (`cmd`) + * which output may get appended to the GITHUB_XXX special file + */ + predicate cmdReachingGitHubFileWrite(Run run, string cmd, string file_var, string field) { + exists(string file_write_value | + ( + file_var = "GITHUB_ENV" and + run.getAWriteToGitHubEnv(field, file_write_value) + or + file_var = "GITHUB_OUTPUT" and + run.getAWriteToGitHubOutput(field, file_write_value) + or + file_var = "GITHUB_PATH" and + field = "PATH" and + run.getAWriteToGitHubPath(file_write_value) + ) and + ( + // cmd output is assigned to a second variable (var2) and var2 reaches the file write + exists(string var2, string value2 | + // VAR2=$(cmd) + // echo "FIELD=${VAR2:-default}" >> $GITHUB_ENV (field, file_write_value) + run.getAnAssignment(var2, value2) and + containsCmdSubstitution(value2, cmd) and + containsParameterExpansion(file_write_value, var2, _, _) + ) + or + // var reaches the file write directly + // echo "FIELD=$(cmd)" >> $GITHUB_ENV (field, file_write_value) + containsCmdSubstitution(file_write_value, cmd) + ) + ) + } +} diff --git a/ql/lib/codeql/actions/Helper.qll b/ql/lib/codeql/actions/Helper.qll index 688d62acbe1..ae4405a185b 100644 --- a/ql/lib/codeql/actions/Helper.qll +++ b/ql/lib/codeql/actions/Helper.qll @@ -1,7 +1,8 @@ private import codeql.actions.Ast private import codeql.Locations -import codeql.actions.config.Config private import codeql.actions.security.ControlChecks +import codeql.actions.config.Config +import codeql.actions.Bash bindingset[expr] string normalizeExpr(string expr) { @@ -82,239 +83,3 @@ string normalizePath(string path) { */ bindingset[subpath, path] predicate isSubpath(string subpath, string path) { subpath.substring(0, path.length()) = path } - -module Bash { - string stmtSeparator() { result = ";" } - - string commandSeparator() { result = ["&&", "||"] } - - string pipeSeparator() { result = "|" } - - string splitSeparators() { - result = stmtSeparator() or result = commandSeparator() or result = pipeSeparator() - } - - string redirectionSeparator() { result = [">", ">>", "2>", "2>>", ">&", "2>&", "<", "<<<"] } - - string partialFileContentCommand() { result = ["cat", "jq", "yq", "tail", "head"] } - - /** Checks if expr is a bash parameter expansion */ - bindingset[expr] - predicate isBashParameterExpansion(string expr, string parameter, string operator, string params) { - exists(string regexp | - // $VAR - regexp = "\\$([a-zA-Z_][a-zA-Z0-9_]+)\\b" and - parameter = expr.regexpCapture(regexp, 1) and - operator = "" and - params = "" - or - // ${VAR} - regexp = "\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}" and - parameter = expr.regexpCapture(regexp, 1) and - operator = "" and - params = "" - or - // ${!VAR} - regexp = "\\$\\{([!#])([a-zA-Z_][a-zA-Z0-9_]*)\\}" and - parameter = expr.regexpCapture(regexp, 2) and - operator = expr.regexpCapture(regexp, 1) and - params = "" - or - // ${VAR}, ... - regexp = "\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)([#%/:^,\\-+]{1,2})?(.*?)\\}" and - parameter = expr.regexpCapture(regexp, 1) and - operator = expr.regexpCapture(regexp, 2) and - params = expr.regexpCapture(regexp, 3) - ) - } - - bindingset[raw_content] - predicate extractVariableAndValue(string raw_content, string key, string value) { - exists(string regexp, string content | content = trimQuotes(raw_content) | - regexp = "(?msi).*^([a-zA-Z_][a-zA-Z0-9_]*)\\s*<<\\s*['\"]?(\\S+)['\"]?\\s*\n(.*?)\n\\2\\s*$" and - key = trimQuotes(content.regexpCapture(regexp, 1)) and - value = trimQuotes(content.regexpCapture(regexp, 3)) - or - exists(string line | - line = content.splitAt("\n") and - regexp = "(?i)^([a-zA-Z_][a-zA-Z0-9_\\-]*)\\s*=\\s*(.*)$" and - key = trimQuotes(line.regexpCapture(regexp, 1)) and - value = trimQuotes(line.regexpCapture(regexp, 2)) - ) - ) - } - - bindingset[script] - predicate singleLineFileWrite( - string script, string cmd, string file, string content, string filters - ) { - exists(string regexp | - regexp = - "(?i)(echo|printf|write-output)\\s*(.*?)\\s*(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+)" and - cmd = script.regexpCapture(regexp, 1) and - file = trimQuotes(script.regexpCapture(regexp, 5)) and - filters = "" and - content = script.regexpCapture(regexp, 2) - ) - } - - bindingset[script] - predicate singleLineWorkflowCmd(string script, string cmd, string key, string value) { - exists(string regexp | - regexp = - "(?i)(echo|printf|write-output)\\s*(['|\"])?::(set-[a-z]+)\\s*name\\s*=\\s*(.*?)::(.*)" and - cmd = script.regexpCapture(regexp, 3) and - key = script.regexpCapture(regexp, 4) and - value = trimQuotes(script.regexpCapture(regexp, 5)) - or - regexp = "(?i)(echo|printf|write-output)\\s*(['|\"])?::(add-[a-z]+)\\s*::(.*)" and - cmd = script.regexpCapture(regexp, 3) and - key = "" and - value = trimQuotes(script.regexpCapture(regexp, 4)) - ) - } - - bindingset[script] - predicate heredocFileWrite(string script, string cmd, string file, string content, string filters) { - exists(string regexp | - regexp = - "(?msi).*^(cat)\\s*(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+)\\s*<<\\s*['\"]?(\\S+)['\"]?\\s*\n(.*?)\n\\4\\s*$.*" and - cmd = script.regexpCapture(regexp, 1) and - file = trimQuotes(script.regexpCapture(regexp, 4)) and - content = script.regexpCapture(regexp, 6) and - filters = "" - or - regexp = - "(?msi).*^(cat)\\s*(<<|<)\\s*[-]?['\"]?(\\S+)['\"]?\\s*([^>]*)(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+)\\s*\n(.*?)\n\\3\\s*$.*" and - cmd = script.regexpCapture(regexp, 1) and - file = trimQuotes(script.regexpCapture(regexp, 7)) and - filters = script.regexpCapture(regexp, 4) and - content = script.regexpCapture(regexp, 8) - ) - } - - bindingset[script] - predicate linesFileWrite(string script, string cmd, string file, string content, string filters) { - exists(string regexp | - regexp = - "(?msi).*((echo|printf)\\s+['|\"]?(.*?<<(\\S+))['|\"]?\\s*>>\\s*(\\S+)\\s*[\r\n]+)" + - "(((.*?)\\s*>>\\s*\\S+\\s*[\r\n]+)+)" + - "((echo|printf)\\s+['|\"]?(EOF)['|\"]?\\s*>>\\s*\\S+\\s*[\r\n]*).*" and - content = - trimQuotes(script.regexpCapture(regexp, 3)) + "\n" + - trimQuotes(script.regexpCapture(regexp, 6)) + "\n" + - trimQuotes(script.regexpCapture(regexp, 4)) and - cmd = "echo" and - file = trimQuotes(script.regexpCapture(regexp, 5)) and - filters = "" - ) - } - - bindingset[script] - predicate blockFileWrite(string script, string cmd, string file, string content, string filters) { - exists(string regexp | - regexp = - "(?msi).*^\\s*\\{\\s*[\r\n]" + - // - "(.*?)" + - // - "(\\s*\\}\\s*(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*(\\S+))\\s*$.*" and - content = - script - .regexpCapture(regexp, 1) - .regexpReplaceAll("(?m)^\\s*(echo|printf|write-output)\\s*['\"](.*?)['\"]", "$2") - .regexpReplaceAll("(?m)^\\s*(echo|printf|write-output)\\s*", "") and - file = trimQuotes(script.regexpCapture(regexp, 5)) and - cmd = "echo" and - filters = "" - ) - } - - bindingset[script] - predicate multiLineFileWrite( - string script, string cmd, string file, string content, string filters - ) { - heredocFileWrite(script, cmd, file, content, filters) - or - linesFileWrite(script, cmd, file, content, filters) - or - blockFileWrite(script, cmd, file, content, filters) - } - - bindingset[script, file_var] - predicate extractFileWrite(string script, string file_var, string content) { - // single line assignment - exists(string file_expr, string raw_content | - isBashParameterExpansion(file_expr, file_var, _, _) and - singleLineFileWrite(script.splitAt("\n"), _, file_expr, raw_content, _) and - content = trimQuotes(raw_content) - ) - or - // workflow command assignment - exists(string key, string value, string cmd | - ( - file_var = "GITHUB_ENV" and - cmd = "set-env" and - content = key + "=" + value - or - file_var = "GITHUB_OUTPUT" and - cmd = "set-output" and - content = key + "=" + value - or - file_var = "GITHUB_PATH" and - cmd = "add-path" and - content = value - ) and - singleLineWorkflowCmd(script.splitAt("\n"), cmd, key, value) - ) - or - // multiline assignment - exists(string file_expr, string raw_content | - multiLineFileWrite(script, _, file_expr, raw_content, _) and - isBashParameterExpansion(file_expr, file_var, _, _) and - content = trimQuotes(raw_content) - ) - } - - /** Writes the content of the file specified by `path` into a file pointed to by `file_var` */ - bindingset[script, file_var] - predicate fileToFileWrite(string script, string file_var, string path) { - exists(string regexp, string line, string file_expr | - isBashParameterExpansion(file_expr, file_var, _, _) and - regexp = - "(?i)(cat)\\s*" + "((?:(?!<<|<<-)[^>\n])+)\\s*" + - "(>>|>|\\s*\\|\\s*tee\\s*(-a|--append)?)\\s*" + "(\\S+)" and - line = script.splitAt("\n") and - path = line.regexpCapture(regexp, 2) and - file_expr = trimQuotes(line.regexpCapture(regexp, 5)) - ) - } - - predicate fileToGitHubEnv(Run run, string path) { - fileToFileWrite(run.getScript(), "GITHUB_ENV", path) - } - - predicate fileToGitHubOutput(Run run, string path) { - fileToFileWrite(run.getScript(), "GITHUB_OUTPUT", path) - } - - predicate fileToGitHubPath(Run run, string path) { - fileToFileWrite(run.getScript(), "GITHUB_PATH", path) - } - - bindingset[snippet] - predicate outputsPartialFileContent(Run run, string snippet) { - // e.g. - // echo FOO=`yq '.foo' foo.yml` >> $GITHUB_ENV - // echo "FOO=$(> $GITHUB_ENV - // yq '.foo' foo.yml >> $GITHUB_PATH - // cat foo.txt >> $GITHUB_PATH - // Bash::getACommand(snippet).indexOf(["<", Bash::partialFileContentCommand() + " "]) = 0 - exists(int i, string line, string cmd | - run.getStmt(i) = line and - line.matches("%" + snippet + "%") and - run.getCommand(i) = cmd and - cmd.indexOf(["<", Bash::partialFileContentCommand() + " "]) = 0 - ) - } -} diff --git a/ql/lib/codeql/actions/ast/internal/Ast.qll b/ql/lib/codeql/actions/ast/internal/Ast.qll index 30b57e361ab..a4b5778246a 100644 --- a/ql/lib/codeql/actions/ast/internal/Ast.qll +++ b/ql/lib/codeql/actions/ast/internal/Ast.qll @@ -1526,6 +1526,22 @@ class RunImpl extends StepImpl { predicate getAWriteToGitHubPath(string value) { Bash::extractFileWrite(this.getScript(), "GITHUB_PATH", value) } + + predicate getAnEnvReachingGitHubOutputWrite(string var, string output_field) { + Bash::envReachingGitHubFileWrite(this, var, "GITHUB_OUTPUT", output_field) + } + + predicate getACmdReachingGitHubOutputWrite(string cmd, string output_field) { + Bash::cmdReachingGitHubFileWrite(this, cmd, "GITHUB_OUTPUT", output_field) + } + + predicate getAnEnvReachingGitHubEnvWrite(string var, string output_field) { + Bash::envReachingGitHubFileWrite(this, var, "GITHUB_ENV", output_field) + } + + predicate getACmdReachingGitHubEnvWrite(string cmd, string output_field) { + Bash::cmdReachingGitHubFileWrite(this, cmd, "GITHUB_ENV", output_field) + } } /** diff --git a/ql/lib/codeql/actions/security/ArgumentInjectionQuery.qll b/ql/lib/codeql/actions/security/ArgumentInjectionQuery.qll index 6e1a5c0f229..18ff398ebab 100644 --- a/ql/lib/codeql/actions/security/ArgumentInjectionQuery.qll +++ b/ql/lib/codeql/actions/security/ArgumentInjectionQuery.qll @@ -9,6 +9,23 @@ abstract class ArgumentInjectionSink extends DataFlow::Node { abstract string getCommand(); } +/** + * Holds if an environment variable is used, directly or indirectly, as an argument to a dangerous command + * in a Run step. + * Where the command is a string captured from the Run's script. + */ +bindingset[var] +predicate envToArgInjSink(string var, Run run, string command) { + exists(string argument, string cmd, string regexp, int command_group, int argument_group | + run.getACommand() = cmd and + argumentInjectionSinksDataModel(regexp, command_group, argument_group) and + command = cmd.regexpCapture(regexp, command_group) and + argument = cmd.regexpCapture(regexp, argument_group) and + Bash::envReachingRunExpr(run, var, argument) and + exists(run.getInScopeEnvVarExpr(var)) + ) +} + /** * Holds if a Run step declares an environment variable, uses it as the argument to a command vulnerable to argument injection. * e.g. @@ -21,10 +38,10 @@ class ArgumentInjectionFromEnvVarSink extends ArgumentInjectionSink { string command; ArgumentInjectionFromEnvVarSink() { - exists(Run run, string var_name | - envToArgInjSink(var_name, run, command) and + exists(Run run, string var | + envToArgInjSink(var, run, command) and run.getScriptScalar() = this.asExpr() and - exists(run.getInScopeEnvVarExpr(var_name)) + exists(run.getInScopeEnvVarExpr(var)) ) or exists( @@ -42,6 +59,33 @@ class ArgumentInjectionFromEnvVarSink extends ArgumentInjectionSink { override string getCommand() { result = command } } +/** + * Holds if a Run step executes a command that returns untrusted data which flows to an unsafe argument + * e.g. + * run: | + * BODY=$(git log --format=%s) + * sed "s/FOO/$BODY/g" > /tmp/foo + */ +class ArgumentInjectionFromCommandSink extends ArgumentInjectionSink { + string command; + + ArgumentInjectionFromCommandSink() { + exists( + CommandSource source, Run run, string cmd, string argument, string regexp, int argument_group, + int command_group + | + run = source.getEnclosingRun() and + this.asExpr() = run.getScriptScalar() and + cmd = run.getACommand() and + argumentInjectionSinksDataModel(regexp, command_group, argument_group) and + argument = cmd.regexpCapture(regexp, argument_group) and + command = cmd.regexpCapture(regexp, command_group) + ) + } + + override string getCommand() { result = command } +} + /** * Holds if a Run step declares an environment variable, uses it as the argument to a command vulnerable to argument injection. */ @@ -71,6 +115,14 @@ private module ArgumentInjectionConfig implements DataFlow::ConfigSig { } predicate isSink(DataFlow::Node sink) { sink instanceof ArgumentInjectionSink } + + predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(Run run, string var | + run.getInScopeEnvVarExpr(var) = pred.asExpr() and + succ.asExpr() = run.getScriptScalar() and + envToArgInjSink(var, run, _) + ) + } } /** Tracks flow of unsafe user input that is used to construct and evaluate a code script. */ diff --git a/ql/lib/codeql/actions/security/ArtifactPoisoningQuery.qll b/ql/lib/codeql/actions/security/ArtifactPoisoningQuery.qll index b7015590614..31a9edd03b3 100644 --- a/ql/lib/codeql/actions/security/ArtifactPoisoningQuery.qll +++ b/ql/lib/codeql/actions/security/ArtifactPoisoningQuery.qll @@ -274,6 +274,24 @@ private module ArtifactPoisoningConfig implements DataFlow::ConfigSig { predicate isSource(DataFlow::Node source) { source instanceof ArtifactSource } predicate isSink(DataFlow::Node sink) { sink instanceof ArtifactPoisoningSink } + + predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(PoisonableStep step | + pred instanceof ArtifactSource and + pred.asExpr().(Step).getAFollowingStep() = step and + ( + succ.asExpr() = step.(Run).getScriptScalar() or + succ.asExpr() = step.(UsesStep) + ) + ) + or + exists(Run run | + pred instanceof ArtifactSource and + pred.asExpr().(Step).getAFollowingStep() = run and + succ.asExpr() = run.getScriptScalar() and + Bash::outputsPartialFileContent(run, run.getACommand()) + ) + } } /** Tracks flow of unsafe artifacts that is used in an insecure way. */ diff --git a/ql/lib/codeql/actions/security/CodeInjectionQuery.qll b/ql/lib/codeql/actions/security/CodeInjectionQuery.qll index 8cd589fa9f8..ca72fe00d16 100644 --- a/ql/lib/codeql/actions/security/CodeInjectionQuery.qll +++ b/ql/lib/codeql/actions/security/CodeInjectionQuery.qll @@ -19,6 +19,22 @@ private module CodeInjectionConfig implements DataFlow::ConfigSig { predicate isSource(DataFlow::Node source) { source instanceof RemoteFlowSource } predicate isSink(DataFlow::Node sink) { sink instanceof CodeInjectionSink } + + predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(Uses step | + pred instanceof FileSource and + pred.asExpr().(Step).getAFollowingStep() = step and + succ.asExpr() = step and + madSink(succ, "code-injection") + ) + or + exists(Run run | + pred instanceof FileSource and + pred.asExpr().(Step).getAFollowingStep() = run and + succ.asExpr() = run.getScriptScalar() and + Bash::outputsPartialFileContent(run, run.getACommand()) + ) + } } /** Tracks flow of unsafe user input that is used to construct and evaluate a code script. */ diff --git a/ql/lib/codeql/actions/security/EnvPathInjectionQuery.qll b/ql/lib/codeql/actions/security/EnvPathInjectionQuery.qll index a80032de320..1f53c938436 100644 --- a/ql/lib/codeql/actions/security/EnvPathInjectionQuery.qll +++ b/ql/lib/codeql/actions/security/EnvPathInjectionQuery.qll @@ -14,6 +14,9 @@ abstract class EnvPathInjectionSink extends DataFlow::Node { } * e.g. * run: | * cat foo.txt >> $GITHUB_PATH + * echo "$(cat foo.txt)" >> $GITHUB_PATH + * FOO=$(cat foo.txt) + * echo "$FOO" >> $GITHUB_PATH */ class EnvPathInjectionFromFileReadSink extends EnvPathInjectionSink { EnvPathInjectionFromFileReadSink() { @@ -25,35 +28,34 @@ class EnvPathInjectionFromFileReadSink extends EnvPathInjectionSink { this.asExpr() = run.getScriptScalar() and step.getAFollowingStep() = run and ( - // e.g. - // cat test-results/.env >> $GITHUB_PATH - Bash::fileToGitHubPath(run, _) - or - exists(string value | - run.getAWriteToGitHubPath(value) and - ( - Bash::outputsPartialFileContent(run, value) - or - // e.g. - // FOO=$(cat test-results/sha-number) - // echo "FOO=$FOO" >> $GITHUB_PATH - exists(string var_name, string var_value | - run.getAnAssignment(var_name, var_value) and - Bash::outputsPartialFileContent(run, var_value) and - ( - value.matches("%$" + ["", "{", "ENV{"] + var_name + "%") - or - value.regexpMatch("\\$\\((echo|printf|write-output)\\s+.*") and - value.indexOf(var_name) > 0 - ) - ) - ) + exists(string cmd | + Bash::cmdReachingGitHubFileWrite(run, cmd, "GITHUB_PATH", _) and + Bash::outputsPartialFileContent(run, cmd) ) + or + Bash::fileToGitHubPath(run, _) ) ) } } +/** + * Holds if a Run step executes a command that returns untrusted data which flows to GITHUB_ENV + * e.g. + * run: | + * COMMIT_MESSAGE=$(git log --format=%s) + * echo "${COMMIT_MESSAGE}" >> $GITHUB_PATH + */ +class EnvPathInjectionFromCommandSink extends EnvPathInjectionSink { + EnvPathInjectionFromCommandSink() { + exists(CommandSource source | + this.asExpr() = source.getEnclosingRun().getScriptScalar() and + Bash::cmdReachingGitHubFileWrite(source.getEnclosingRun(), source.getCommand(), "GITHUB_PATH", + _) + ) + } +} + /** * Holds if a Run step declares an environment variable, uses it to declare a PATH env var. * e.g. @@ -65,7 +67,7 @@ class EnvPathInjectionFromFileReadSink extends EnvPathInjectionSink { class EnvPathInjectionFromEnvVarSink extends EnvPathInjectionSink { EnvPathInjectionFromEnvVarSink() { exists(Run run, string var_name | - envToSpecialFile("GITHUB_PATH", var_name, run, _) and + Bash::envReachingGitHubFileWrite(run, var_name, "GITHUB_PATH", _) and exists(run.getInScopeEnvVarExpr(var_name)) and run.getScriptScalar() = this.asExpr() ) @@ -84,6 +86,28 @@ private module EnvPathInjectionConfig implements DataFlow::ConfigSig { predicate isSource(DataFlow::Node source) { source instanceof RemoteFlowSource } predicate isSink(DataFlow::Node sink) { sink instanceof EnvPathInjectionSink } + + predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(Run run, string var | + run.getInScopeEnvVarExpr(var) = pred.asExpr() and + succ.asExpr() = run.getScriptScalar() and + Bash::envReachingGitHubFileWrite(run, var, ["GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH"], _) + ) + or + exists(Uses step | + pred instanceof FileSource and + pred.asExpr().(Step).getAFollowingStep() = step and + succ.asExpr() = step and + madSink(succ, "envpath-injection") + ) + or + exists(Run run | + pred instanceof FileSource and + pred.asExpr().(Step).getAFollowingStep() = run and + succ.asExpr() = run.getScriptScalar() and + Bash::outputsPartialFileContent(run, run.getACommand()) + ) + } } /** Tracks flow of unsafe user input that is used to construct and evaluate the PATH environment variable. */ diff --git a/ql/lib/codeql/actions/security/EnvVarInjectionQuery.qll b/ql/lib/codeql/actions/security/EnvVarInjectionQuery.qll index 65c6938f0a4..dd6b8342185 100644 --- a/ql/lib/codeql/actions/security/EnvVarInjectionQuery.qll +++ b/ql/lib/codeql/actions/security/EnvVarInjectionQuery.qll @@ -14,8 +14,12 @@ abstract class EnvVarInjectionSink extends DataFlow::Node { } * e.g. * run: | * cat test-results/.env >> $GITHUB_ENV + * * echo "sha=$(cat test-results/sha-number)" >> $GITHUB_ENV * echo "sha=$(> $GITHUB_ENV + * + * FOO=$(cat test-results/sha-number) + * echo "FOO=$FOO" >> $GITHUB_ENV */ class EnvVarInjectionFromFileReadSink extends EnvVarInjectionSink { EnvVarInjectionFromFileReadSink() { @@ -27,37 +31,34 @@ class EnvVarInjectionFromFileReadSink extends EnvVarInjectionSink { this.asExpr() = run.getScriptScalar() and step.getAFollowingStep() = run and ( - // e.g. - // cat test-results/.env >> $GITHUB_ENV - Bash::fileToGitHubEnv(run, _) - or - exists(string value | - run.getAWriteToGitHubEnv(_, value) and - ( - // e.g. - // echo "FOO=$(cat test-results/sha-number)" >> $GITHUB_ENV - Bash::outputsPartialFileContent(run, value) - or - // e.g. - // FOO=$(cat test-results/sha-number) - // echo "FOO=$FOO" >> $GITHUB_ENV - exists(string var_name, string var_value | - run.getAnAssignment(var_name, var_value) and - Bash::outputsPartialFileContent(run, var_value) and - ( - value.matches("%$" + ["", "{", "ENV{"] + var_name + "%") - or - value.regexpMatch("\\$\\((echo|printf|write-output)\\s+.*") and - value.indexOf(var_name) > 0 - ) - ) - ) + exists(string cmd | + Bash::cmdReachingGitHubFileWrite(run, cmd, "GITHUB_ENV", _) and + Bash::outputsPartialFileContent(run, cmd) ) + or + Bash::fileToGitHubEnv(run, _) ) ) } } +/** + * Holds if a Run step executes a command that returns untrusted data which flows to GITHUB_ENV + * e.g. + * run: | + * COMMIT_MESSAGE=$(git log --format=%s) + * echo "COMMIT_MESSAGE=${COMMIT_MESSAGE}" >> $GITHUB_ENV + */ +class EnvVarInjectionFromCommandSink extends EnvVarInjectionSink { + EnvVarInjectionFromCommandSink() { + exists(CommandSource source | + this.asExpr() = source.getEnclosingRun().getScriptScalar() and + Bash::cmdReachingGitHubFileWrite(source.getEnclosingRun(), source.getCommand(), "GITHUB_ENV", + _) + ) + } +} + /** * Holds if a Run step declares an environment variable, uses it to declare env var. * e.g. @@ -69,9 +70,9 @@ class EnvVarInjectionFromFileReadSink extends EnvVarInjectionSink { class EnvVarInjectionFromEnvVarSink extends EnvVarInjectionSink { EnvVarInjectionFromEnvVarSink() { exists(Run run, string var_name | - envToSpecialFile("GITHUB_ENV", var_name, run, _) and exists(run.getInScopeEnvVarExpr(var_name)) and - run.getScriptScalar() = this.asExpr() + run.getScriptScalar() = this.asExpr() and + Bash::envReachingGitHubFileWrite(run, var_name, "GITHUB_ENV", _) ) } } @@ -104,6 +105,28 @@ private module EnvVarInjectionConfig implements DataFlow::ConfigSig { } predicate isSink(DataFlow::Node sink) { sink instanceof EnvVarInjectionSink } + + predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(Run run, string var | + run.getInScopeEnvVarExpr(var) = pred.asExpr() and + succ.asExpr() = run.getScriptScalar() and + Bash::envReachingGitHubFileWrite(run, var, ["GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH"], _) + ) + or + exists(Uses step | + pred instanceof FileSource and + pred.asExpr().(Step).getAFollowingStep() = step and + succ.asExpr() = step and + madSink(succ, "envvar-injection") + ) + or + exists(Run run | + pred instanceof FileSource and + pred.asExpr().(Step).getAFollowingStep() = run and + succ.asExpr() = run.getScriptScalar() and + Bash::outputsPartialFileContent(run, run.getACommand()) + ) + } } /** Tracks flow of unsafe user input that is used to construct and evaluate an environment variable. */ diff --git a/ql/lib/codeql/actions/security/OutputClobberingQuery.qll b/ql/lib/codeql/actions/security/OutputClobberingQuery.qll index 8541286f6e1..4f9eeef7579 100644 --- a/ql/lib/codeql/actions/security/OutputClobberingQuery.qll +++ b/ql/lib/codeql/actions/security/OutputClobberingQuery.qll @@ -10,7 +10,7 @@ import codeql.actions.dataflow.FlowSources abstract class OutputClobberingSink extends DataFlow::Node { } /** - * Holds if a Run step declares an environment variable with contents from a local file. + * Holds if a Run step declares a step output variable with contents from a local file. * e.g. * run: | * cat test-results/.vars >> $GITHUB_OUTPUT @@ -21,58 +21,43 @@ class OutputClobberingFromFileReadSink extends OutputClobberingSink { OutputClobberingFromFileReadSink() { exists(Run run, Step step | ( - step instanceof UntrustedArtifactDownloadStep or + step instanceof UntrustedArtifactDownloadStep + or // This shoould be: // artifact instanceof PRHeadCheckoutStep // but PRHeadCheckoutStep uses Taint Tracking anc causes a non-Monolitic Recursion error // so we list all the subclasses of PRHeadCheckoutStep here and use actions/checkout as a workaround // instead of using ActionsMutableRefCheckout and ActionsSHACheckout - step.(Uses).getCallee() = "actions/checkout" or - step instanceof GitMutableRefCheckout or - step instanceof GitSHACheckout or - step instanceof GhMutableRefCheckout or + exists(Uses uses | + step = uses and + uses.getCallee() = "actions/checkout" and + exists(uses.getArgument("ref")) + ) + or + step instanceof GitMutableRefCheckout + or + step instanceof GitSHACheckout + or + step instanceof GhMutableRefCheckout + or step instanceof GhSHACheckout ) and - this.asExpr() = run.getScriptScalar() and step.getAFollowingStep() = run and + this.asExpr() = run.getScriptScalar() and ( - // e.g. - // cat test-results/.vars >> $GITHUB_OUTPUT - Bash::fileToGitHubOutput(run, _) - or - exists(string key, string value | - run.getAWriteToGitHubOutput(key, value) and - // there is a different output variable in the same script - // TODO: key2/value2 should be declared before key/value - exists(string key2 | - run.getAWriteToGitHubOutput(key2, _) and - not key2 = key - ) and - ( - Bash::outputsPartialFileContent(run, value) - or - // e.g. - // FOO=$(cat test-results/sha-number) - // echo "FOO=$FOO" >> $GITHUB_OUTPUT - exists(string var_name, string var_value | - run.getAnAssignment(var_name, var_value) and - Bash::outputsPartialFileContent(run, var_value) and - ( - value.matches("%$" + ["", "{", "ENV{"] + var_name + "%") - or - value.regexpMatch("\\$\\((echo|printf|write-output)\\s+.*") and - value.indexOf(var_name) > 0 - ) - ) - ) + exists(string cmd | + Bash::cmdReachingGitHubFileWrite(run, cmd, "GITHUB_OUTPUT", _) and + Bash::outputsPartialFileContent(run, cmd) ) + or + Bash::fileToGitHubOutput(run, _) ) ) } } /** - * Holds if a Run step declares an environment variable, uses it to declare env var. + * Holds if a Run step declares an environment variable, uses it in a step variable output. * e.g. * env: * BODY: ${{ github.event.comment.body }} @@ -81,15 +66,15 @@ class OutputClobberingFromFileReadSink extends OutputClobberingSink { */ class OutputClobberingFromEnvVarSink extends OutputClobberingSink { OutputClobberingFromEnvVarSink() { - exists(Run run, string var_name, string key | - envToSpecialFile("GITHUB_OUTPUT", var_name, run, key) and + exists(Run run, string var, string field | + Bash::envReachingGitHubFileWrite(run, var, "GITHUB_OUTPUT", field) and // there is a different output variable in the same script // TODO: key2/value2 should be declared before key/value - exists(string key2 | - run.getAWriteToGitHubOutput(key2, _) and - not key2 = key + exists(string field2 | + run.getAWriteToGitHubOutput(field2, _) and + not field2 = field ) and - exists(run.getInScopeEnvVarExpr(var_name)) and + exists(run.getInScopeEnvVarExpr(var)) and run.getScriptScalar() = this.asExpr() ) } @@ -113,10 +98,9 @@ class OutputClobberingFromEnvVarSink extends OutputClobberingSink { */ class WorkflowCommandClobberingFromEnvVarSink extends OutputClobberingSink { WorkflowCommandClobberingFromEnvVarSink() { - exists(Run run, string output_line, string clobbering_line, string var_name | - run.getScript().splitAt("\n") = output_line and - Bash::singleLineWorkflowCmd(output_line, "set-output", _, _) and - run.getScript().splitAt("\n") = clobbering_line and + exists(Run run, string clobbering_line, string var_name | + Bash::singleLineWorkflowCmd(run.getACommand(), "set-output", _, _) and + run.getACommand() = clobbering_line and clobbering_line.regexpMatch(".*echo\\s+(-e\\s+)?(\"|')?\\$(\\{)?" + var_name + ".*") and exists(run.getInScopeEnvVarExpr(var_name)) and run.getScriptScalar() = this.asExpr() @@ -124,13 +108,36 @@ class WorkflowCommandClobberingFromEnvVarSink extends OutputClobberingSink { } } +/** + * - id: clob1 + * run: | + * # VULNERABLE + * PR="$(; @@ -93,6 +102,15 @@ private module ActionsSHACheckoutConfig implements DataFlow::ConfigSig { uses.getArgumentExpr("ref") = sink.asExpr() ) } + + predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(Run run | + pred instanceof FileSource and + pred.asExpr().(Step).getAFollowingStep() = run and + succ.asExpr() = run.getScriptScalar() and + Bash::outputsPartialFileContent(run, run.getACommand()) + ) + } } module ActionsSHACheckoutFlow = TaintTracking::Global; @@ -139,7 +157,7 @@ predicate containsHeadSHA(string s) { "\\bgithub\\.event\\.merge_group\\.head_sha\\b", "\\bgithub\\.event\\.merge_group\\.head_commit\\.id\\b", // heuristics - "\\bhead\\.sha\\b", "\\bhead_sha\\b", "\\bpr_head_sha\\b" + "\\bhead\\.sha\\b", "\\bhead_sha\\b", "\\bmerge_sha\\b", "\\bpr_head_sha\\b" ], _, _) ) } @@ -156,7 +174,7 @@ predicate containsHeadRef(string s) { "\\bgithub\\.event\\.check_run\\.pull_requests\\[\\d+\\]\\.head\\.ref\\b", "\\bgithub\\.event\\.merge_group\\.head_ref\\b", // heuristics - "\\bhead\\.ref\\b", "\\bhead_ref\\b", "\\bpr_head_ref\\b", + "\\bhead\\.ref\\b", "\\bhead_ref\\b", "\\bmerge_ref\\b", "\\bpr_head_ref\\b", // env vars "GITHUB_HEAD_REF", ], _, _)