Add initial support for Ruby Grape

This commit is contained in:
Chad Bentz
2025-09-12 19:22:05 -04:00
parent e8ddac08b7
commit d295acc3c3
7 changed files with 316 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ private import codeql.ruby.frameworks.Rails
private import codeql.ruby.frameworks.Railties
private import codeql.ruby.frameworks.Stdlib
private import codeql.ruby.frameworks.Files
private import codeql.ruby.frameworks.Grape
private import codeql.ruby.frameworks.HttpClients
private import codeql.ruby.frameworks.XmlParsing
private import codeql.ruby.frameworks.ActionDispatch

View File

@@ -0,0 +1,198 @@
/**
* Provides modeling for the `Grape` API framework.
*/
private import codeql.ruby.AST
private import codeql.ruby.Concepts
private import codeql.ruby.controlflow.CfgNodes
private import codeql.ruby.DataFlow
private import codeql.ruby.dataflow.RemoteFlowSources
private import codeql.ruby.ApiGraphs
private import codeql.ruby.typetracking.TypeTracking
private import codeql.ruby.frameworks.Rails
private import codeql.ruby.frameworks.internal.Rails
private import codeql.ruby.dataflow.internal.DataFlowDispatch
/**
* Provides modeling for Grape, a REST-like API framework for Ruby.
* Grape allows you to build RESTful APIs in Ruby with minimal effort.
*/
module Grape {
/**
* A Grape API class which sits at the top of the class hierarchy.
* In other words, it does not subclass any other Grape API class in source code.
*/
class RootAPI extends GrapeAPIClass {
RootAPI() {
not exists(GrapeAPIClass parent | this != parent and this = parent.getADescendent())
}
}
}
/**
* A class that extends `Grape::API`.
* For example,
*
* ```rb
* class FooAPI < Grape::API
* get '/users' do
* name = params[:name]
* User.where("name = #{name}")
* end
* end
* ```
*/
class GrapeAPIClass extends DataFlow::ClassNode {
GrapeAPIClass() {
this = grapeAPIBaseClass().getADescendentModule() and
not exists(DataFlow::ModuleNode m | m = grapeAPIBaseClass().asModule() | this = m)
}
/**
* Gets a `GrapeEndpoint` defined in this class.
*/
GrapeEndpoint getAnEndpoint() {
result.getAPIClass() = this
}
/**
* Gets a `self` that possibly refers to an instance of this class.
*/
DataFlow::LocalSourceNode getSelf() {
result = this.getAnInstanceSelf()
or
// Include the module-level `self` to recover some cases where a block at the module level
// is invoked with an instance as the `self`.
result = this.getModuleLevelSelf()
}
}
private DataFlow::ConstRef grapeAPIBaseClass() {
result = DataFlow::getConstant("Grape").getConstant("API")
}
private API::Node grapeAPIInstance() {
result = any(GrapeAPIClass cls).getSelf().track()
}
/**
* A Grape API endpoint (get, post, put, delete, etc.) call within a `Grape::API` class.
*/
class GrapeEndpoint extends DataFlow::CallNode {
private GrapeAPIClass apiClass;
GrapeEndpoint() {
this = apiClass.getAModuleLevelCall(["get", "post", "put", "delete", "patch", "head", "options"])
}
/**
* Gets the HTTP method for this endpoint (e.g., "GET", "POST", etc.)
*/
string getHttpMethod() {
result = this.getMethodName().toUpperCase()
}
/**
* Gets the API class containing this endpoint.
*/
GrapeAPIClass getAPIClass() { result = apiClass }
/**
* Gets the block containing the endpoint logic.
*/
DataFlow::BlockNode getBody() { result = this.getBlock() }
/**
* Gets the path pattern for this endpoint, if specified.
*/
string getPath() {
result = this.getArgument(0).getConstantValue().getString()
}
}
/**
* A `RemoteFlowSource::Range` to represent accessing the
* Grape parameters available via the `params` method within an endpoint.
*/
class GrapeParamsSource extends Http::Server::RequestInputAccess::Range {
GrapeParamsSource() {
this.asExpr().getExpr() instanceof GrapeParamsCall
}
override string getSourceType() { result = "Grape::API#params" }
override Http::Server::RequestInputKind getKind() { result = Http::Server::parameterInputKind() }
}
/**
* A call to `params` from within a Grape API endpoint.
*/
private class GrapeParamsCall extends ParamsCallImpl {
GrapeParamsCall() {
exists(GrapeEndpoint endpoint |
this.getParent+() = endpoint.getBody().asCallableAstNode() and
this.getMethodName() = "params"
)
or
// Also handle cases where params is called on an instance of a Grape API class
this = grapeAPIInstance().getAMethodCall("params").asExpr().getExpr()
}
}
/**
* A call to `headers` from within a Grape API endpoint.
* Headers can also be a source of user input.
*/
class GrapeHeadersSource extends Http::Server::RequestInputAccess::Range {
GrapeHeadersSource() {
this.asExpr().getExpr() instanceof GrapeHeadersCall
}
override string getSourceType() { result = "Grape::API#headers" }
override Http::Server::RequestInputKind getKind() { result = Http::Server::headerInputKind() }
}
/**
* A call to `headers` from within a Grape API endpoint.
*/
private class GrapeHeadersCall extends MethodCall {
GrapeHeadersCall() {
exists(GrapeEndpoint endpoint |
this.getParent+() = endpoint.getBody().asCallableAstNode() and
this.getMethodName() = "headers"
)
or
// Also handle cases where headers is called on an instance of a Grape API class
this = grapeAPIInstance().getAMethodCall("headers").asExpr().getExpr()
}
}
/**
* A call to `request` from within a Grape API endpoint.
* The request object can contain user input.
*/
class GrapeRequestSource extends Http::Server::RequestInputAccess::Range {
GrapeRequestSource() {
this.asExpr().getExpr() instanceof GrapeRequestCall
}
override string getSourceType() { result = "Grape::API#request" }
override Http::Server::RequestInputKind getKind() { result = Http::Server::parameterInputKind() }
}
/**
* A call to `request` from within a Grape API endpoint.
*/
private class GrapeRequestCall extends MethodCall {
GrapeRequestCall() {
exists(GrapeEndpoint endpoint |
this.getParent+() = endpoint.getBody().asCallableAstNode() and
this.getMethodName() = "request"
)
or
// Also handle cases where request is called on an instance of a Grape API class
this = grapeAPIInstance().getAMethodCall("request").asExpr().getExpr()
}
}

View File

@@ -0,0 +1,25 @@
grapeAPIClasses
| app.rb:1:1:48:3 | MyAPI |
| app.rb:50:1:54:3 | AdminAPI |
grapeEndpoints
| app.rb:1:1:48:3 | MyAPI | app.rb:7:3:11:5 | call to get | GET | /hello/:name |
| app.rb:1:1:48:3 | MyAPI | app.rb:17:3:20:5 | call to post | POST | /messages |
| app.rb:1:1:48:3 | MyAPI | app.rb:23:3:27:5 | call to put | PUT | /update/:id |
| app.rb:1:1:48:3 | MyAPI | app.rb:30:3:32:5 | call to delete | DELETE | /items/:id |
| app.rb:1:1:48:3 | MyAPI | app.rb:35:3:37:5 | call to patch | PATCH | /items/:id |
| app.rb:1:1:48:3 | MyAPI | app.rb:40:3:42:5 | call to head | HEAD | /status |
| app.rb:1:1:48:3 | MyAPI | app.rb:45:3:47:5 | call to options | OPTIONS | /info |
| app.rb:50:1:54:3 | AdminAPI | app.rb:51:3:53:5 | call to get | GET | /admin |
grapeParams
| app.rb:8:12:8:17 | call to params |
| app.rb:14:3:16:5 | call to params |
| app.rb:18:11:18:16 | call to params |
| app.rb:24:10:24:15 | call to params |
| app.rb:31:5:31:10 | call to params |
| app.rb:36:5:36:10 | call to params |
| app.rb:52:5:52:10 | call to params |
grapeHeaders
| app.rb:9:18:9:24 | call to headers |
| app.rb:46:5:46:11 | call to headers |
grapeRequest
| app.rb:25:12:25:18 | call to request |

View File

@@ -0,0 +1,18 @@
import ruby
import codeql.ruby.frameworks.Grape
import codeql.ruby.Concepts
import codeql.ruby.AST
query predicate grapeAPIClasses(GrapeAPIClass api) { any() }
query predicate grapeEndpoints(GrapeAPIClass api, GrapeEndpoint endpoint, string method, string path) {
endpoint = api.getAnEndpoint() and
method = endpoint.getHttpMethod() and
path = endpoint.getPath()
}
query predicate grapeParams(GrapeParamsSource params) { any() }
query predicate grapeHeaders(GrapeHeadersSource headers) { any() }
query predicate grapeRequest(GrapeRequestSource request) { any() }

View File

@@ -0,0 +1,54 @@
class MyAPI < Grape::API
version 'v1', using: :header, vendor: 'myapi'
format :json
prefix :api
desc 'Simple get endpoint'
get '/hello/:name' do
name = params[:name]
user_agent = headers['User-Agent']
"Hello #{name}!"
end
desc 'Post endpoint with params'
params do
requires :message, type: String
end
post '/messages' do
msg = params[:message]
{ status: 'received', message: msg }
end
desc 'Put endpoint accessing request'
put '/update/:id' do
id = params[:id]
body = request.body.read
{ id: id, body: body }
end
desc 'Delete endpoint'
delete '/items/:id' do
params[:id]
end
desc 'Patch endpoint'
patch '/items/:id' do
params[:id]
end
desc 'Head endpoint'
head '/status' do
# Just return status
end
desc 'Options endpoint'
options '/info' do
headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
end
end
class AdminAPI < Grape::API
get '/admin' do
params[:token]
end
end

View File

@@ -6,4 +6,13 @@ class PotatoController < ActionController::Base
sql = Arel.sql("SELECT * FROM users WHERE name = #{name}")
sql = Arel::Nodes::SqlLiteral.new("SELECT * FROM users WHERE name = #{name}")
end
end
class PotatoAPI < Grape::API
get '/unsafe_endpoint' do
name = params[:user_name]
# BAD: SQL statement constructed from user input
sql = Arel.sql("SELECT * FROM users WHERE name = #{name}")
sql = Arel::Nodes::SqlLiteral.new("SELECT * FROM users WHERE name = #{name}")
end
end

View File

@@ -81,6 +81,10 @@ edges
| ArelInjection.rb:4:5:4:8 | name | ArelInjection.rb:7:39:7:80 | "SELECT * FROM users WHERE nam..." | provenance | AdditionalTaintStep |
| ArelInjection.rb:4:12:4:17 | call to params | ArelInjection.rb:4:12:4:29 | ...[...] | provenance | |
| ArelInjection.rb:4:12:4:29 | ...[...] | ArelInjection.rb:4:5:4:8 | name | provenance | |
| ArelInjection.rb:13:5:13:8 | name | ArelInjection.rb:15:20:15:61 | "SELECT * FROM users WHERE nam..." | provenance | AdditionalTaintStep |
| ArelInjection.rb:13:5:13:8 | name | ArelInjection.rb:16:39:16:80 | "SELECT * FROM users WHERE nam..." | provenance | AdditionalTaintStep |
| ArelInjection.rb:13:12:13:17 | call to params | ArelInjection.rb:13:12:13:29 | ...[...] | provenance | |
| ArelInjection.rb:13:12:13:29 | ...[...] | ArelInjection.rb:13:5:13:8 | name | provenance | |
| PgInjection.rb:6:5:6:8 | name | PgInjection.rb:13:5:13:8 | qry1 : String | provenance | AdditionalTaintStep |
| PgInjection.rb:6:5:6:8 | name | PgInjection.rb:19:5:19:8 | qry2 : String | provenance | AdditionalTaintStep |
| PgInjection.rb:6:5:6:8 | name | PgInjection.rb:31:5:31:8 | qry3 : String | provenance | AdditionalTaintStep |
@@ -209,6 +213,11 @@ nodes
| ArelInjection.rb:4:12:4:29 | ...[...] | semmle.label | ...[...] |
| ArelInjection.rb:6:20:6:61 | "SELECT * FROM users WHERE nam..." | semmle.label | "SELECT * FROM users WHERE nam..." |
| ArelInjection.rb:7:39:7:80 | "SELECT * FROM users WHERE nam..." | semmle.label | "SELECT * FROM users WHERE nam..." |
| ArelInjection.rb:13:5:13:8 | name | semmle.label | name |
| ArelInjection.rb:13:12:13:17 | call to params | semmle.label | call to params |
| ArelInjection.rb:13:12:13:29 | ...[...] | semmle.label | ...[...] |
| ArelInjection.rb:15:20:15:61 | "SELECT * FROM users WHERE nam..." | semmle.label | "SELECT * FROM users WHERE nam..." |
| ArelInjection.rb:16:39:16:80 | "SELECT * FROM users WHERE nam..." | semmle.label | "SELECT * FROM users WHERE nam..." |
| PgInjection.rb:6:5:6:8 | name | semmle.label | name |
| PgInjection.rb:6:12:6:17 | call to params | semmle.label | call to params |
| PgInjection.rb:6:12:6:24 | ...[...] | semmle.label | ...[...] |
@@ -266,6 +275,8 @@ subpaths
| ActiveRecordInjection.rb:216:38:216:53 | "role = #{...}" | ActiveRecordInjection.rb:222:29:222:34 | call to params | ActiveRecordInjection.rb:216:38:216:53 | "role = #{...}" | This SQL query depends on a $@. | ActiveRecordInjection.rb:222:29:222:34 | call to params | user-provided value |
| ArelInjection.rb:6:20:6:61 | "SELECT * FROM users WHERE nam..." | ArelInjection.rb:4:12:4:17 | call to params | ArelInjection.rb:6:20:6:61 | "SELECT * FROM users WHERE nam..." | This SQL query depends on a $@. | ArelInjection.rb:4:12:4:17 | call to params | user-provided value |
| ArelInjection.rb:7:39:7:80 | "SELECT * FROM users WHERE nam..." | ArelInjection.rb:4:12:4:17 | call to params | ArelInjection.rb:7:39:7:80 | "SELECT * FROM users WHERE nam..." | This SQL query depends on a $@. | ArelInjection.rb:4:12:4:17 | call to params | user-provided value |
| ArelInjection.rb:15:20:15:61 | "SELECT * FROM users WHERE nam..." | ArelInjection.rb:13:12:13:17 | call to params | ArelInjection.rb:15:20:15:61 | "SELECT * FROM users WHERE nam..." | This SQL query depends on a $@. | ArelInjection.rb:13:12:13:17 | call to params | user-provided value |
| ArelInjection.rb:16:39:16:80 | "SELECT * FROM users WHERE nam..." | ArelInjection.rb:13:12:13:17 | call to params | ArelInjection.rb:16:39:16:80 | "SELECT * FROM users WHERE nam..." | This SQL query depends on a $@. | ArelInjection.rb:13:12:13:17 | call to params | user-provided value |
| PgInjection.rb:14:15:14:18 | qry1 | PgInjection.rb:6:12:6:17 | call to params | PgInjection.rb:14:15:14:18 | qry1 | This SQL query depends on a $@. | PgInjection.rb:6:12:6:17 | call to params | user-provided value |
| PgInjection.rb:15:21:15:24 | qry1 | PgInjection.rb:6:12:6:17 | call to params | PgInjection.rb:15:21:15:24 | qry1 | This SQL query depends on a $@. | PgInjection.rb:6:12:6:17 | call to params | user-provided value |
| PgInjection.rb:20:22:20:25 | qry2 | PgInjection.rb:6:12:6:17 | call to params | PgInjection.rb:20:22:20:25 | qry2 | This SQL query depends on a $@. | PgInjection.rb:6:12:6:17 | call to params | user-provided value |