Merge pull request #10680 from erik-krogh/unsafeRbCmd

RB: add an unsafe-shell-command-construction query
This commit is contained in:
Erik Krogh Kristensen
2022-11-08 09:22:33 +01:00
committed by GitHub
20 changed files with 497 additions and 0 deletions

View File

@@ -147,6 +147,9 @@ class ExprNode extends Node, TExprNode {
class ParameterNode extends Node, TParameterNode instanceof ParameterNodeImpl {
/** Gets the parameter corresponding to this node, if any. */
final Parameter getParameter() { result = super.getParameter() }
/** Gets the name of the parameter, if any. */
final string getName() { result = this.getParameter().(NamedParameter).getName() }
}
/**

View File

@@ -7,6 +7,7 @@ private import codeql.ruby.DataFlow
private import codeql.ruby.dataflow.FlowSummary
import core.BasicObject::BasicObject
import core.Object::Object
import core.Gem::Gem
import core.Kernel::Kernel
import core.Module
import core.Array

View File

@@ -0,0 +1,102 @@
/**
* Provides modeling for the `Gem` module and `.gemspec` files.
*/
private import ruby
private import Ast
private import codeql.ruby.ApiGraphs
/** Provides modeling for the `Gem` module and `.gemspec` files. */
module Gem {
/**
* A .gemspec file that lists properties of a Ruby gem.
* The contents of a .gemspec file generally look like:
* ```Ruby
* Gem::Specification.new do |s|
* s.name = 'library-name'
* s.require_path = "lib"
* # more properties
* end
* ```
*/
class GemSpec instanceof File {
API::Node specCall;
GemSpec() {
this.getExtension() = "gemspec" and
specCall = API::root().getMember("Gem").getMember("Specification").getMethod("new") and
specCall.getLocation().getFile() = this
}
/** Gets the name of this .gemspec file. */
string toString() { result = File.super.getBaseName() }
/**
* Gets a value of the `name` property of this .gemspec file.
* These properties are set using the `Gem::Specification.new` method.
*/
private Expr getSpecProperty(string name) {
exists(Expr rhs |
rhs =
specCall
.getBlock()
.getParameter(0)
.getMethod(name + "=")
.getParameter(0)
.asSink()
.asExpr()
.getExpr()
.(Ast::AssignExpr)
.getRightOperand()
|
result = rhs
or
// some properties are arrays, we just unfold them
result = rhs.(ArrayLiteral).getAnElement()
)
}
/** Gets the name of the gem */
string getName() { result = getSpecProperty("name").getConstantValue().getString() }
/** Gets a path that is loaded when the gem is required */
private string getARequirePath() {
result = getSpecProperty(["require_paths", "require_path"]).getConstantValue().getString()
or
not exists(getSpecProperty(["require_paths", "require_path"]).getConstantValue().getString()) and
result = "lib" // the default is "lib"
}
/** Gets a file that could be loaded when the gem is required. */
private File getAPossiblyRequiredFile() {
result = File.super.getParentContainer().getFolder(getARequirePath()).getAChildContainer*()
}
/** Gets a class/module that is exported by this gem. */
private ModuleBase getAPublicModule() {
result.(Toplevel).getLocation().getFile() = getAPossiblyRequiredFile()
or
result = getAPublicModule().getAModule()
or
result = getAPublicModule().getAClass()
or
result = getAPublicModule().getStmt(_).(SingletonClass)
}
/** Gets a parameter from an exported method, which is an input to this gem. */
DataFlow::ParameterNode getAnInputParameter() {
exists(MethodBase method | method = getAPublicModule().getAMethod() |
result.getParameter() = method.getAParameter() and
method.isPublic()
)
}
}
/** Gets a parameter that is an input to a named gem. */
DataFlow::ParameterNode getALibraryInput() {
exists(GemSpec spec |
exists(spec.getName()) and // we only consider `.gemspec` files that have a name
result = spec.getAnInputParameter()
)
}
}

View File

@@ -49,6 +49,9 @@ module CommandInjection {
class ShellwordsEscapeAsSanitizer extends Sanitizer {
ShellwordsEscapeAsSanitizer() {
this = API::getTopLevelMember("Shellwords").getAMethodCall(["escape", "shellescape"])
or
// The method is also added as `String#shellescape`.
this.(DataFlow::CallNode).getMethodName() = "shellescape"
}
}
}

View File

@@ -0,0 +1,105 @@
/**
* Provides default sources, sinks and sanitizers for reasoning about
* shell command constructed from library input vulnerabilities, as
* well as extension points for adding your own.
*/
private import codeql.ruby.DataFlow
private import codeql.ruby.DataFlow2
private import codeql.ruby.ApiGraphs
private import codeql.ruby.frameworks.core.Gem::Gem as Gem
private import codeql.ruby.AST as Ast
private import codeql.ruby.Concepts as Concepts
/**
* Module containing sources, sinks, and sanitizers for shell command constructed from library input.
*/
module UnsafeShellCommandConstruction {
/** A source for shell command constructed from library input vulnerabilities. */
abstract class Source extends DataFlow::Node { }
/** An input parameter to a gem seen as a source. */
private class LibraryInputAsSource extends Source instanceof DataFlow::ParameterNode {
LibraryInputAsSource() {
this = Gem::getALibraryInput() and
// we exclude arguments named `cmd` or similar, as they seem to execute commands on purpose
not exists(string name | name = super.getName() |
name = ["cmd", "command"]
or
name.regexpMatch(".*(Cmd|Command)$")
)
}
}
/** A sink for shell command constructed from library input vulnerabilities. */
abstract class Sink extends DataFlow::Node {
/** Gets a description of how the string in this sink was constructed. */
abstract string describe();
/** Gets the dataflow node where the string is constructed. */
DataFlow::Node getStringConstruction() { result = this }
/** Gets the dataflow node that executed the string as a shell command. */
abstract DataFlow::Node getCommandExecution();
}
/** Holds if the string constructed at `source` is executed at `shellExec` */
predicate isUsedAsShellCommand(DataFlow::Node source, Concepts::SystemCommandExecution shellExec) {
source = backtrackShellExec(TypeTracker::TypeBackTracker::end(), shellExec)
}
import codeql.ruby.typetracking.TypeTracker as TypeTracker
private DataFlow::LocalSourceNode backtrackShellExec(
TypeTracker::TypeBackTracker t, Concepts::SystemCommandExecution shellExec
) {
t.start() and
result = any(DataFlow::Node n | shellExec.isShellInterpreted(n)).getALocalSource()
or
exists(TypeTracker::TypeBackTracker t2 |
result = backtrackShellExec(t2, shellExec).backtrack(t2, t)
)
}
/**
* A string constructed from a string-literal (e.g. `"foo #{sink}"`),
* where the resulting string ends up being executed as a shell command.
*/
class StringInterpolationAsSink extends Sink {
Concepts::SystemCommandExecution s;
Ast::StringLiteral lit;
StringInterpolationAsSink() {
isUsedAsShellCommand(any(DataFlow::Node n | n.asExpr().getExpr() = lit), s) and
this.asExpr().getExpr() = lit.getComponent(_)
}
override string describe() { result = "string construction" }
override DataFlow::Node getCommandExecution() { result = s }
override DataFlow::Node getStringConstruction() { result.asExpr().getExpr() = lit }
}
import codeql.ruby.security.TaintedFormatStringSpecific as TaintedFormat
/**
* A string constructed from a printf-style call,
* where the resulting string ends up being executed as a shell command.
*/
class TaintedFormatStringAsSink extends Sink {
Concepts::SystemCommandExecution s;
TaintedFormat::PrintfStyleCall call;
TaintedFormatStringAsSink() {
isUsedAsShellCommand(call, s) and
this = [call.getFormatArgument(_), call.getFormatString()]
}
override string describe() { result = "formatted string" }
override DataFlow::Node getCommandExecution() { result = s }
override DataFlow::Node getStringConstruction() { result = call }
}
}

View File

@@ -0,0 +1,35 @@
/**
* Provides a taint tracking configuration for reasoning about shell command
* constructed from library input vulnerabilities
*
* Note, for performance reasons: only import this file if `Configuration` is needed,
* otherwise `UnsafeShellCommandConstructionCustomizations` should be imported instead.
*/
import codeql.ruby.DataFlow
import UnsafeShellCommandConstructionCustomizations::UnsafeShellCommandConstruction
private import codeql.ruby.TaintTracking
private import CommandInjectionCustomizations::CommandInjection as CommandInjection
private import codeql.ruby.dataflow.BarrierGuards
/**
* A taint-tracking configuration for detecting shell command constructed from library input vulnerabilities.
*/
class Configuration extends TaintTracking::Configuration {
Configuration() { this = "UnsafeShellCommandConstruction" }
override predicate isSource(DataFlow::Node source) { source instanceof Source }
override predicate isSink(DataFlow::Node sink) { sink instanceof Sink }
override predicate isSanitizer(DataFlow::Node node) {
node instanceof CommandInjection::Sanitizer or // using all sanitizers from `rb/command-injection`
node instanceof StringConstCompareBarrier or
node instanceof StringConstArrayInclusionCallBarrier
}
// override to require the path doesn't have unmatched return steps
override DataFlow::FlowFeature getAFeature() {
result instanceof DataFlow::FeatureHasSourceCallContext
}
}