使用类型跟踪进行 API 建模¶
您可以使用 CodeQL 的 JavaScript 类型跟踪库创建模型来跟踪通过 API 的数据。
概述¶
类型跟踪库使您能够跟踪通过属性和函数调用的值,通常用于识别对特定类型的对象进行的调用方法和访问的属性。
这是一个高级主题,适合已经熟悉 SourceNode 类以及 污点跟踪 的读者。对于 TypeScript 分析,还要考虑先阅读有关 静态类型信息 的内容。
识别方法调用的问题¶
我们将从 Firebase API 的简单模型开始,并逐渐构建它以使用类型跟踪。不需要了解 Firebase。
假设我们希望查找将数据写入 Firebase 数据库的位置,如下例所示
var ref = firebase.database().ref("forecast");
ref.set("Rain"); // <-- find this call
一个简单的方法是查找所有名为“set
”的方法调用
import javascript
import DataFlow
MethodCallNode firebaseSetterCall() {
result.getMethodName() = "set"
}
这样做的明显问题是它会找到对所有名为 set
的方法的调用,其中许多与 Firebase 无关。
另一种方法是使用本地数据流来匹配导致此调用的调用链
MethodCallNode firebaseSetterCall() {
result = globalVarRef("firebase")
.getAMethodCall("database")
.getAMethodCall("ref")
.getAMethodCall("set")
}
这将找到示例中的 set
调用,但不会找到任何无关的 set
方法调用。我们可以将其拆分,以便每个步骤都是一个单独的谓词
SourceNode firebase() {
result = globalVarRef("firebase")
}
SourceNode firebaseDatabase() {
result = firebase().getAMethodCall("database")
}
SourceNode firebaseRef() {
result = firebaseDatabase().getAMethodCall("ref");
}
MethodCallNode firebaseSetterCall() {
result = firebaseRef().getAMethodCall("set")
}
上面的代码等效于前面的版本,但更容易修改各个步骤。
缺点是该模型完全依赖于本地数据流,这意味着它不会查看属性和函数调用。例如,firebaseSetterCall()
无法在这个示例中找到任何内容
function getDatabase() {
return firebase.database();
}
var ref = getDatabase().ref("forecast");
ref.set("Rain");
请注意,谓词 firebaseDatabase()
仍然可以找到对 firebase.database()
的调用,但不能找到对 getDatabase()
的调用。这意味着 firebaseRef()
没有结果,进而导致 firebaseSetterCall()
没有结果。
作为一个简单的补救措施,让我们尝试让 firebaseDatabase()
识别 getDatabase()
调用
SourceNode firebaseDatabase() {
result = firebase().getAMethodCall("database")
or
result.(CallNode).getACallee().getAReturn().getALocalSource() = firebaseDatabase()
}
第二个子句确保 firebaseDatabase()
不仅能找到 firebase.database()
调用,还能找到调用返回 firebase.database()
的函数,例如上面的 getDatabase()
。它是递归的,因此它会处理来自任何数量的嵌套函数调用的流。
但是,它仍然只跟踪函数的输出,而不是通过参数或属性跟踪函数的输入。我们不会手动添加这些步骤,而是会使用类型跟踪。
一般的类型跟踪¶
类型跟踪是上述模式的概括,其中一个谓词匹配要跟踪的值,并具有递归子句来跟踪该值的流。但我们不必处理函数调用/返回和属性读/写,所有这些步骤都包含在单个谓词中,SourceNode.track,它与伴随类 TypeTracker 一起使用。
使用类型跟踪的谓词通常符合以下通用模式,我们在下面进行解释
SourceNode myType(TypeTracker t) {
t.start() and
result = /* SourceNode to track */
or
exists(TypeTracker t2 |
result = myType(t2).track(t2, t)
)
}
SourceNode myType() {
result = myType(TypeTracker::end())
}
我们将把该模式应用于我们的示例模型,并使用它来解释发生了什么。
跟踪数据库实例¶
将上面的模式应用于 firebaseDatabase()
谓词,我们得到以下内容
SourceNode firebaseDatabase(TypeTracker t) {
t.start() and
result = firebase().getAMethodCall("database")
or
exists(TypeTracker t2 |
result = firebaseDatabase(t2).track(t2, t)
)
}
SourceNode firebaseDatabase() {
result = firebaseDatabase(TypeTracker::end())
}
现在有两个名为 firebaseDatabase
的谓词。带有 TypeTracker
参数的那个是实际执行全局数据流跟踪的那个 - 另一个谓词以方便的方式公开结果。
新的 TypeTracker t
参数是跟踪到结果数据流节点的感兴趣值的步骤的摘要。
在基本情况下,当匹配 firebase.database()
时,我们使用 t.start()
来指示不需要任何步骤,也就是说,这是类型跟踪的起点
t.start() and
result = firebase().getAMethodCall("database")
在递归情况下,我们对先前找到的 Firebase 数据库节点(例如 firebase.database()
)应用 track
谓词。track
谓词将它映射到该节点的后继节点(例如 getDatabase()
),并将 t
绑定到 t2
的延续,其中包含此附加步骤
exists(TypeTracker t2 |
result = firebaseDatabase(t2).track(t2, t)
)
为了理解 t
在此处的角色,请注意,类型跟踪可以进入属性,这意味着从 track
返回的数据流节点不一定是 Firebase 数据库实例,它可能是包含 Firebase 数据库的另一个对象的属性。
例如,在下面的程序中,firebaseDatabase(t)
谓词在其结果中包含 obj
节点,但 t
记录了实际被跟踪的值位于 DB
属性中的事实
let obj = { DB: firebase.database() };
let db = obj.DB;
这将我们带到了最后一个谓词。它使用 TypeTracker::end()
来过滤掉 Firebase 数据库实例最终位于另一个对象属性中的路径,因此它包含 db
,但不包含 obj
SourceNode firebaseDatabase() {
result = firebaseDatabase(TypeTracker::end())
}
以下是一个例子,说明它现在可以处理什么
class Firebase {
constructor() {
this.db = firebase.database();
}
getDatabase() { return this.db; }
setForecast(value) {
this.getDatabase().ref("forecast").set(value); // found by firebaseSetterCall()
}
}
在整个模型中跟踪¶
我们在上一节中将此模式应用于 firebaseDatabase()
,并且我们可以同样轻松地将该模式应用于其他谓词。此示例查询使用该模型来查找 set 调用。它已被稍微修改以处理更多 API,这超出了本教程的范围。
import javascript
import DataFlow
SourceNode firebase(TypeTracker t) {
t.start() and
(
result = globalVarRef("firebase")
or
result = moduleImport("firebase/app")
)
or
exists(TypeTracker t2 |
result = firebase(t2).track(t2, t)
)
}
SourceNode firebase() {
result = firebase(TypeTracker::end())
}
SourceNode firebaseDatabase(TypeTracker t) {
t.start() and
result = firebase().getAMethodCall("database")
or
exists(TypeTracker t2 |
result = firebaseDatabase(t2).track(t2, t)
)
}
SourceNode firebaseDatabase() {
result = firebaseDatabase(TypeTracker::end())
}
SourceNode firebaseRef(TypeTracker t) {
t.start() and
result = firebaseDatabase().getAMethodCall("ref")
or
exists(TypeTracker t2 |
result = firebaseRef(t2).track(t2, t)
)
}
SourceNode firebaseRef() {
result = firebaseRef(TypeTracker::end())
}
MethodCallNode firebaseSetterCall() {
result = firebaseRef().getAMethodCall("set")
}
select firebaseSetterCall()
跟踪关联数据¶
通过向类型跟踪谓词添加额外的参数,我们可以附带关于结果的额外信息。
例如,以下是对 firebaseRef()
的类型跟踪版本,它跟踪传递给 ref
调用的字符串
SourceNode firebaseRef(string name, TypeTracker t) {
t.start() and
exists(CallNode call |
call = firebaseDatabase().getAMethodCall("ref") and
name = call.getArgument(0).getStringValue() and
result = call
)
or
exists(TypeTracker t2 |
result = firebaseRef(name, t2).track(t2, t)
)
}
SourceNode firebaseRef(string name) {
result = firebaseRef(name, TypeTracker::end())
}
MethodCallNode firebaseSetterCall(string refName) {
result = firebaseRef(refName).getAMethodCall("set")
}
因此,现在我们可以使用 firebaseSetterCall("forecast")
来查找对预报的赋值。
回溯回调¶
我们上面看到的类型跟踪谓词都使用前向跟踪。也就是说,它们都从一些感兴趣的值开始,并询问“它流到哪里?”。
有时向后工作更有用,从想要达到的终点开始,并询问“什么流到此处?”。
作为一个激励示例,我们将扩展我们的模型以查找我们从数据库读取值的位置,而不是写入值。读取是一个异步操作,结果通过回调获得,例如
function fetchForecast(callback) {
firebase.database().ref("forecast").once("value", callback);
}
function updateReminders() {
fetchForecast((snapshot) => {
let forecast = snapshot.val(); // <-- find this call
addReminder(forecast === "Rain" ? "Umbrella" : "Sunscreen");
})
}
实际的预报通过对 snapshot.val()
的调用获得。
查找所有名为 val
的方法调用实际上会找到许多无关的方法,因此我们将再次使用类型跟踪来考虑接收器类型。
接收器 snapshot
是回调函数的参数,该参数最终会逃逸到 once()
调用中。我们将扩展我们上面的模型以使用回溯来查找所有流入 once()
调用的函数。向后类型跟踪与向前类型跟踪没有什么不同。区别在于
TypeTracker
参数的类型是TypeBackTracker
。- 对
.track()
的调用实际上是对.backtrack()
的调用。 - 为了确保初始值是源节点,通常需要调用
getALocalSource()
。
SourceNode firebaseSnapshotCallback(string refName, TypeBackTracker t) {
t.start() and
result = firebaseRef(refName).getAMethodCall("once").getArgument(1).getALocalSource()
or
exists(TypeBackTracker t2 |
result = firebaseSnapshotCallback(refName, t2).backtrack(t2, t)
)
}
FunctionNode firebaseSnapshotCallback(string refName) {
result = firebaseSnapshotCallback(refName, TypeBackTracker::end())
}
现在,firebaseSnapshotCallback("forecast")
找到了传递给 fetchForecast
的函数。基于此,我们可以跟踪 snapshot
值,并找到 val()
调用本身
SourceNode firebaseSnapshot(string refName, TypeTracker t) {
t.start() and
result = firebaseSnapshotCallback(refName).getParameter(0)
or
exists(TypeTracker t2 |
result = firebaseSnapshot(refName, t2).track(t2, t)
)
}
SourceNode firebaseSnapshot(string refName) {
result = firebaseSnapshot(refName, TypeTracker::end())
}
MethodCallNode firebaseDatabaseRead(string refName) {
result = firebaseSnapshot(refName).getAMethodCall("val")
}
有了这个补充,firebaseDatabaseRead("forecast")
找到了包含预报值的 snapshot.val()
调用。
import javascript
import DataFlow
SourceNode firebase(TypeTracker t) {
t.start() and
(
result = globalVarRef("firebase")
or
result = moduleImport("firebase/app")
)
or
exists(TypeTracker t2 |
result = firebase(t2).track(t2, t)
)
}
SourceNode firebase() {
result = firebase(TypeTracker::end())
}
SourceNode firebaseDatabase(TypeTracker t) {
t.start() and
result = firebase().getAMethodCall("database")
or
exists(TypeTracker t2 |
result = firebaseDatabase(t2).track(t2, t)
)
}
SourceNode firebaseDatabase() {
result = firebaseDatabase(TypeTracker::end())
}
SourceNode firebaseRef(Node name, TypeTracker t) {
t.start() and
exists(CallNode call |
call = firebaseDatabase().getAMethodCall("ref") and
name = call.getArgument(0) and
result = call
)
or
exists(TypeTracker t2 |
result = firebaseRef(name, t2).track(t2, t)
)
}
SourceNode firebaseRef(Node name) {
result = firebaseRef(name, TypeTracker::end())
}
MethodCallNode firebaseSetterCall(Node name) {
result = firebaseRef(name).getAMethodCall("set")
}
SourceNode firebaseSnapshotCallback(Node refName, TypeBackTracker t) {
t.start() and
(
result = firebaseRef(refName).getAMethodCall("once").getArgument(1).getALocalSource()
or
result = firebaseRef(refName).getAMethodCall("once").getAMethodCall("then").getArgument(0).getALocalSource()
)
or
exists(TypeBackTracker t2 |
result = firebaseSnapshotCallback(refName, t2).backtrack(t2, t)
)
}
FunctionNode firebaseSnapshotCallback(Node refName) {
result = firebaseSnapshotCallback(refName, TypeBackTracker::end())
}
SourceNode firebaseSnapshot(Node refName, TypeTracker t) {
t.start() and
result = firebaseSnapshotCallback(refName).getParameter(0)
or
exists(TypeTracker t2 |
result = firebaseSnapshot(refName, t2).track(t2, t)
)
}
SourceNode firebaseSnapshot(Node refName) {
result = firebaseSnapshot(refName, TypeTracker::end())
}
MethodCallNode firebaseDatabaseRead(Node refName) {
result = firebaseSnapshot(refName).getAMethodCall("val")
}
from Node name
select name, firebaseDatabaseRead(name)
总结¶
我们已经介绍了如何使用类型跟踪库。回顾一下,使用此模板来定义前向类型跟踪谓词
SourceNode myType(TypeTracker t) {
t.start() and
result = /* SourceNode to track */
or
exists(TypeTracker t2 |
result = myType(t2).track(t2, t)
)
}
SourceNode myType() {
result = myType(TypeTracker::end())
}
使用此模板来定义后向类型跟踪谓词
SourceNode myType(TypeBackTracker t) {
t.start() and
result = (/* argument to track */).getALocalSource()
or
exists(TypeBackTracker t2 |
result = myType(t2).backtrack(t2, t)
)
}
SourceNode myType() {
result = myType(TypeBackTracker::end())
}
请注意,这些谓词都返回 SourceNode
,因此尝试跟踪非源节点(例如标识符或字符串文字)将不起作用。如果这成为问题,请参见 TypeTracker.smallstep。
另请注意,使用 TypeTracker
或 TypeBackTracker
的谓词通常可以设置为 private
,因为它们通常仅用作中间结果来计算其他谓词。
限制¶
如前所述,类型跟踪将跟踪值进出函数调用和属性,但仅在某些限制范围内。
例如,类型跟踪并不总是跟踪通过函数的值。也就是说,如果一个值流入一个参数并从返回值流出,它可能不会被再次跟踪回调用站点。以下是一个示例,本教程中的模型将无法找到该示例。
function wrapDB(database) {
return { db: database }
}
let wrapper = wrapDB(firebase.database())
wrapper.db.ref("forecast"); // <-- not found
这是一个例子,说明 数据流配置 更有力。
何时使用类型跟踪¶
类型跟踪和数据流配置是解决同一问题的不同解决方案,它们各有优缺点。
类型跟踪可以在任意数量的谓词中使用,这些谓词之间可以以相当不受限制的方式相互依赖。一个谓词的结果可能是另一个谓词的起点。类型跟踪谓词可以是相互递归的。类型跟踪谓词可以具有任意数量的额外参数,使得构建源/接收器对成为可能,但并非必须。当源/接收器对的数量非常多时,省略它们可能会有用。
数据流配置的依赖关系更加受限,但在其他方面更强大。出于性能原因,配置的源、接收器和步骤不应取决于是否使用该配置或任何其他配置找到了流路径。从这个意义上说,源、接收器和步骤必须“提前”配置,而不能即时发现。好处是它们以类型跟踪无法实现的一些方式跟踪流经函数和回调的值,这对于安全查询尤其重要。此外,路径查询只能使用数据流配置定义。
在以下情况下优先使用类型跟踪
- 消除通用名称方法或属性的歧义。
- 创建可重用库组件以在查询之间共享。
- 源/接收器对集太大而无法计算或信息不足。
- 需要将信息作为数据流配置的输入。
在以下情况下优先使用数据流配置
- 跟踪用户控制的数据——使用 污点跟踪。
- 区分不同类型的用户控制数据——参见“使用流标签进行精确数据流分析”。
- 跟踪值通过通用实用程序函数的转换。
- 跟踪值通过字符串操作。
- 生成从源到接收器的路径——参见“创建路径查询”。
最后,根据分析的代码库,一些可以考虑的替代方案是
- 使用 静态类型信息,如果分析 TypeScript 代码。
- 依赖于本地数据流。
- 依赖于语法启发式,例如方法、属性或变量的名称。
标准库中的类型跟踪¶
类型跟踪在标准库的几个地方使用
- DOM 谓词,documentRef,locationRef,以及 domValueRef,使用类型跟踪实现。
- HTTP 服务器模型,如 Express,使用类型跟踪来跟踪路由处理程序函数的安装。
- Firebase 和 Socket.io 模型使用类型跟踪来跟踪来自其各自 API 的对象。