Skip to content

Commit 078d15e

Browse files
committed
add openrouter support
1 parent 6c5c8e1 commit 078d15e

14 files changed

Lines changed: 518 additions & 153 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
extensions:
2+
- addsTo:
3+
pack: codeql/javascript-all
4+
extensible: typeModel
5+
data:
6+
- ["openrouter.Client", "@openrouter/sdk", "Instance"]
7+
- ["openrouter.Client", "@openrouter/sdk", "Member[OpenRouter].Instance"]
8+
- ["openrouter.Agent", "@openrouter/agent", "Member[OpenRouter].Instance"]
9+
10+
- addsTo:
11+
pack: codeql/javascript-all
12+
extensible: sinkModel
13+
data:
14+
- ["@openrouter/agent", "Member[callModel].Argument[0].Member[instructions]", "system-prompt-injection"]
15+
- ["openrouter.Agent", "Member[callModel].Argument[0].Member[instructions]", "system-prompt-injection"]
16+
- ["@openrouter/agent", "Member[tool].Argument[0].Member[description]", "system-prompt-injection"]
17+
- ["openrouter.Client", "Member[embeddings].Member[create].Argument[0].Member[input]", "user-prompt-injection"]
18+
- ["@openrouter/agent", "Member[callModel].Argument[0].Member[input]", "user-prompt-injection"]
19+
- ["openrouter.Agent", "Member[callModel].Argument[0].Member[input]", "user-prompt-injection"]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Provides classes modeling security-relevant aspects of the OpenRouter JS/TS SDKs.
3+
* See https://openrouter.ai/docs/client-sdks/typescript (`@openrouter/sdk`) and
4+
* https://openrouter.ai/docs/agent-sdk/overview (`@openrouter/agent`).
5+
*
6+
* Structurally typed sinks (instructions, input, description, etc.) have been moved to
7+
* Models as Data: javascript/ql/lib/ext/openrouter.model.yml
8+
*
9+
* This file retains only role-filtered sinks that require inspecting a sibling
10+
* `role` property, which MaD cannot express.
11+
*/
12+
13+
private import javascript
14+
15+
/** Holds if `msg` is a message array element with a privileged role. */
16+
private predicate isSystemOrDevMessage(API::Node msg) {
17+
msg.getMember("role").asSink().mayHaveStringValue(["system", "developer", "assistant"])
18+
}
19+
20+
/**
21+
* Provides models for the OpenRouter Client SDK (`@openrouter/sdk`).
22+
*/
23+
module OpenRouter {
24+
/** Gets a reference to an `@openrouter/sdk` client instance. */
25+
private API::Node clientRef() {
26+
// Default export: import OpenRouter from '@openrouter/sdk'; new OpenRouter()
27+
result = API::moduleImport("@openrouter/sdk").getInstance()
28+
or
29+
// Named import: import { OpenRouter } from '@openrouter/sdk'; new OpenRouter()
30+
result = API::moduleImport("@openrouter/sdk").getMember("OpenRouter").getInstance()
31+
}
32+
33+
/** Gets the parameter object of a chat completion call. */
34+
private API::Node chatCreateParams() {
35+
// client.chat.send({ messages: [...] })
36+
result = clientRef().getMember("chat").getMember("send").getParameter(0)
37+
or
38+
// OpenAI-compatible surface: client.chat.completions.create({ messages: [...] })
39+
result =
40+
clientRef().getMember("chat").getMember("completions").getMember("create").getParameter(0)
41+
}
42+
43+
/**
44+
* Gets role-filtered system/developer/assistant message sinks.
45+
* These require checking a sibling `role` property and cannot be expressed in MaD.
46+
*/
47+
API::Node getSystemOrAssistantPromptNode() {
48+
// chat.send/completions.create({ messages: [{ role: "system"/"developer"/"assistant", content: ... }] })
49+
exists(API::Node msg, API::Node content |
50+
msg = chatCreateParams().getMember("messages").getArrayElement() and
51+
isSystemOrDevMessage(msg) and
52+
content = msg.getMember("content")
53+
|
54+
result = content
55+
or
56+
result = content.getArrayElement().getMember("text")
57+
)
58+
}
59+
60+
/**
61+
* Gets role-filtered user message sinks.
62+
* These require checking a sibling `role` property and cannot be expressed in MaD.
63+
*/
64+
API::Node getUserPromptNode() {
65+
// chat.send/completions.create({ messages: [{ role: "user", content: ... }] })
66+
exists(API::Node msg, API::Node content |
67+
msg = chatCreateParams().getMember("messages").getArrayElement() and
68+
not isSystemOrDevMessage(msg) and
69+
content = msg.getMember("content")
70+
|
71+
result = content
72+
or
73+
result = content.getArrayElement().getMember("text")
74+
)
75+
}
76+
}
77+
78+
/**
79+
* Provides models for the OpenRouter Agent SDK (`@openrouter/agent`).
80+
*
81+
* Structurally typed sinks have been moved to openrouter.model.yml.
82+
* This module retains only role-filtered sinks that MaD cannot express.
83+
*/
84+
module OpenRouterAgent {
85+
/** Gets a reference to the `@openrouter/agent` module. */
86+
private API::Node moduleRef() { result = API::moduleImport("@openrouter/agent") }
87+
88+
/** Gets a `callModel` invocation's parameter object (top-level and instance forms). */
89+
private API::Node callModelParams() {
90+
// import { callModel } from '@openrouter/agent'; callModel({ ... })
91+
result = moduleRef().getMember("callModel").getParameter(0)
92+
or
93+
// import { OpenRouter } from '@openrouter/agent'; new OpenRouter(...).callModel({ ... })
94+
result = moduleRef().getMember("OpenRouter").getInstance().getMember("callModel").getParameter(0)
95+
}
96+
97+
/**
98+
* Gets role-filtered system/developer/assistant message sinks.
99+
* These require checking a sibling `role` property and cannot be expressed in MaD.
100+
*/
101+
API::Node getSystemOrAssistantPromptNode() {
102+
// callModel({ messages/input: [{ role: "system"/"developer"/"assistant", content: ... }] })
103+
exists(API::Node msg |
104+
msg = callModelParams().getMember(["messages", "input"]).getArrayElement() and
105+
isSystemOrDevMessage(msg)
106+
|
107+
result = msg.getMember("content")
108+
)
109+
}
110+
111+
/**
112+
* Gets role-filtered user message sinks.
113+
* These require checking a sibling `role` property and cannot be expressed in MaD.
114+
*/
115+
API::Node getUserPromptNode() {
116+
// callModel({ messages/input: [{ role: "user", content: ... }] })
117+
exists(API::Node msg |
118+
msg = callModelParams().getMember(["messages", "input"]).getArrayElement() and
119+
not isSystemOrDevMessage(msg)
120+
|
121+
result = msg.getMember("content")
122+
)
123+
}
124+
}

javascript/ql/lib/semmle/javascript/security/dataflow/SystemPromptInjectionCustomizations.qll

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ private import semmle.javascript.frameworks.data.ModelsAsData
1414
private import semmle.javascript.frameworks.OpenAI
1515
private import semmle.javascript.frameworks.Anthropic
1616
private import semmle.javascript.frameworks.GoogleGenAI
17+
private import semmle.javascript.frameworks.OpenRouter
1718

1819
/**
1920
* Provides default sources, sinks and sanitizers for detecting
@@ -64,6 +65,10 @@ module SystemPromptInjection {
6465
this = Anthropic::getSystemOrAssistantPromptNode().asSink()
6566
or
6667
this = GoogleGenAI::getSystemOrAssistantPromptNode().asSink()
68+
or
69+
this = OpenRouter::getSystemOrAssistantPromptNode().asSink()
70+
or
71+
this = OpenRouterAgent::getSystemOrAssistantPromptNode().asSink()
6772
}
6873
}
6974

javascript/ql/src/experimental/semmle/javascript/security/PromptInjection/UserPromptInjectionCustomizations.qll

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ private import semmle.javascript.frameworks.data.ModelsAsData
1414
private import semmle.javascript.frameworks.OpenAI
1515
private import semmle.javascript.frameworks.Anthropic
1616
private import semmle.javascript.frameworks.GoogleGenAI
17+
private import semmle.javascript.frameworks.OpenRouter
1718

1819
/**
1920
* Provides default sources, sinks and sanitizers for detecting
@@ -65,6 +66,10 @@ module UserPromptInjection {
6566
this = GoogleGenAI::getUserPromptNode().asSink()
6667
or
6768
this = AgentSDK::getUserPromptNode().asSink()
69+
or
70+
this = OpenRouter::getUserPromptNode().asSink()
71+
or
72+
this = OpenRouterAgent::getUserPromptNode().asSink()
6873
}
6974
}
7075

javascript/ql/test/experimental/Security/CWE-1427/SystemPromptInjection/SystemPromptInjection.expected

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,25 @@ edges
9797
| openai_test.js:158:52:158:58 | persona | openai_test.js:158:30:158:58 | "Also t ... persona | provenance | |
9898
| openai_test.js:164:31:164:37 | persona | openai_test.js:164:14:164:37 | "Talk l ... persona | provenance | |
9999
| openai_test.js:192:49:192:55 | persona | openai_test.js:192:32:192:55 | "Talk l ... persona | provenance | |
100+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:23:35:23:41 | persona | provenance | |
101+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:38:35:38:41 | persona | provenance | |
102+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:52:36:52:42 | persona | provenance | |
103+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:78:35:78:41 | persona | provenance | |
104+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:88:36:88:42 | persona | provenance | |
105+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:98:35:98:41 | persona | provenance | |
106+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:109:35:109:41 | persona | provenance | |
107+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:118:36:118:42 | persona | provenance | |
108+
| openrouter_test.js:12:9:12:15 | persona | openrouter_test.js:125:35:125:41 | persona | provenance | |
109+
| openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:12:9:12:15 | persona | provenance | |
110+
| openrouter_test.js:23:35:23:41 | persona | openrouter_test.js:23:18:23:41 | "Talk l ... persona | provenance | |
111+
| openrouter_test.js:38:35:38:41 | persona | openrouter_test.js:38:18:38:41 | "Talk l ... persona | provenance | |
112+
| openrouter_test.js:52:36:52:42 | persona | openrouter_test.js:52:19:52:42 | "Talk l ... persona | provenance | |
113+
| openrouter_test.js:78:35:78:41 | persona | openrouter_test.js:78:18:78:41 | "Talk l ... persona | provenance | |
114+
| openrouter_test.js:88:36:88:42 | persona | openrouter_test.js:88:19:88:42 | "Talk l ... persona | provenance | |
115+
| openrouter_test.js:98:35:98:41 | persona | openrouter_test.js:98:18:98:41 | "Talk l ... persona | provenance | |
116+
| openrouter_test.js:109:35:109:41 | persona | openrouter_test.js:109:18:109:41 | "Talk l ... persona | provenance | |
117+
| openrouter_test.js:118:36:118:42 | persona | openrouter_test.js:118:19:118:42 | "Talk l ... persona | provenance | |
118+
| openrouter_test.js:125:35:125:41 | persona | openrouter_test.js:125:18:125:41 | "Talk l ... persona | provenance | |
100119
nodes
101120
| agents_test.js:8:9:8:15 | persona | semmle.label | persona |
102121
| agents_test.js:8:19:8:35 | req.query.persona | semmle.label | req.query.persona |
@@ -195,6 +214,26 @@ nodes
195214
| openai_test.js:164:31:164:37 | persona | semmle.label | persona |
196215
| openai_test.js:192:32:192:55 | "Talk l ... persona | semmle.label | "Talk l ... persona |
197216
| openai_test.js:192:49:192:55 | persona | semmle.label | persona |
217+
| openrouter_test.js:12:9:12:15 | persona | semmle.label | persona |
218+
| openrouter_test.js:12:19:12:35 | req.query.persona | semmle.label | req.query.persona |
219+
| openrouter_test.js:23:18:23:41 | "Talk l ... persona | semmle.label | "Talk l ... persona |
220+
| openrouter_test.js:23:35:23:41 | persona | semmle.label | persona |
221+
| openrouter_test.js:38:18:38:41 | "Talk l ... persona | semmle.label | "Talk l ... persona |
222+
| openrouter_test.js:38:35:38:41 | persona | semmle.label | persona |
223+
| openrouter_test.js:52:19:52:42 | "Talk l ... persona | semmle.label | "Talk l ... persona |
224+
| openrouter_test.js:52:36:52:42 | persona | semmle.label | persona |
225+
| openrouter_test.js:78:18:78:41 | "Talk l ... persona | semmle.label | "Talk l ... persona |
226+
| openrouter_test.js:78:35:78:41 | persona | semmle.label | persona |
227+
| openrouter_test.js:88:19:88:42 | "Talk l ... persona | semmle.label | "Talk l ... persona |
228+
| openrouter_test.js:88:36:88:42 | persona | semmle.label | persona |
229+
| openrouter_test.js:98:18:98:41 | "Talk l ... persona | semmle.label | "Talk l ... persona |
230+
| openrouter_test.js:98:35:98:41 | persona | semmle.label | persona |
231+
| openrouter_test.js:109:18:109:41 | "Talk l ... persona | semmle.label | "Talk l ... persona |
232+
| openrouter_test.js:109:35:109:41 | persona | semmle.label | persona |
233+
| openrouter_test.js:118:19:118:42 | "Talk l ... persona | semmle.label | "Talk l ... persona |
234+
| openrouter_test.js:118:36:118:42 | persona | semmle.label | persona |
235+
| openrouter_test.js:125:18:125:41 | "Talk l ... persona | semmle.label | "Talk l ... persona |
236+
| openrouter_test.js:125:35:125:41 | persona | semmle.label | persona |
198237
subpaths
199238
#select
200239
| agents_test.js:16:19:16:42 | "Talk l ... persona | agents_test.js:8:19:8:35 | req.query.persona | agents_test.js:16:19:16:42 | "Talk l ... persona | This prompt construction depends on a $@. | agents_test.js:8:19:8:35 | req.query.persona | user-provided value |
@@ -236,3 +275,12 @@ subpaths
236275
| openai_test.js:158:30:158:58 | "Also t ... persona | openai_test.js:11:19:11:35 | req.query.persona | openai_test.js:158:30:158:58 | "Also t ... persona | This prompt construction depends on a $@. | openai_test.js:11:19:11:35 | req.query.persona | user-provided value |
237276
| openai_test.js:164:14:164:37 | "Talk l ... persona | openai_test.js:11:19:11:35 | req.query.persona | openai_test.js:164:14:164:37 | "Talk l ... persona | This prompt construction depends on a $@. | openai_test.js:11:19:11:35 | req.query.persona | user-provided value |
238277
| openai_test.js:192:32:192:55 | "Talk l ... persona | openai_test.js:11:19:11:35 | req.query.persona | openai_test.js:192:32:192:55 | "Talk l ... persona | This prompt construction depends on a $@. | openai_test.js:11:19:11:35 | req.query.persona | user-provided value |
278+
| openrouter_test.js:23:18:23:41 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:23:18:23:41 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
279+
| openrouter_test.js:38:18:38:41 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:38:18:38:41 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
280+
| openrouter_test.js:52:19:52:42 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:52:19:52:42 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
281+
| openrouter_test.js:78:18:78:41 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:78:18:78:41 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
282+
| openrouter_test.js:88:19:88:42 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:88:19:88:42 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
283+
| openrouter_test.js:98:18:98:41 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:98:18:98:41 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
284+
| openrouter_test.js:109:18:109:41 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:109:18:109:41 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
285+
| openrouter_test.js:118:19:118:42 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:118:19:118:42 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |
286+
| openrouter_test.js:125:18:125:41 | "Talk l ... persona | openrouter_test.js:12:19:12:35 | req.query.persona | openrouter_test.js:125:18:125:41 | "Talk l ... persona | This prompt construction depends on a $@. | openrouter_test.js:12:19:12:35 | req.query.persona | user-provided value |

javascript/ql/test/experimental/Security/CWE-1427/SystemPromptInjection/agents_test.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ app.get("/agents", async (req, res) => {
1313
// SHOULD ALERT
1414
const agent1 = new Agent({
1515
name: "Assistant",
16-
instructions: "Talk like a " + persona, // $ Alert[js/prompt-injection]
16+
instructions: "Talk like a " + persona, // $ Alert[js/system-prompt-injection]
1717
});
1818

1919
// === Agent constructor: instructions as lambda ===
@@ -22,15 +22,15 @@ app.get("/agents", async (req, res) => {
2222
const agent2 = new Agent({
2323
name: "Dynamic",
2424
instructions: (runContext) => {
25-
return "Talk like a " + persona; // $ Alert[js/prompt-injection]
25+
return "Talk like a " + persona; // $ Alert[js/system-prompt-injection]
2626
},
2727
});
2828

2929
// SHOULD ALERT (async lambda)
3030
const agent3 = new Agent({
3131
name: "AsyncDynamic",
3232
instructions: async (runContext) => {
33-
return "Talk like a " + persona; // $ Alert[js/prompt-injection]
33+
return "Talk like a " + persona; // $ Alert[js/system-prompt-injection]
3434
},
3535
});
3636

@@ -40,23 +40,23 @@ app.get("/agents", async (req, res) => {
4040
const agent4 = new Agent({
4141
name: "Specialist",
4242
instructions: "Help with refunds",
43-
handoffDescription: "Handles " + persona, // $ Alert[js/prompt-injection]
43+
handoffDescription: "Handles " + persona, // $ Alert[js/system-prompt-injection]
4444
});
4545

4646
// === agent.asTool(): toolDescription ===
4747

4848
// SHOULD ALERT
4949
agent1.asTool({
5050
toolName: "helper",
51-
toolDescription: "Ask about " + persona, // $ Alert[js/prompt-injection]
51+
toolDescription: "Ask about " + persona, // $ Alert[js/system-prompt-injection]
5252
});
5353

5454
// === tool(): description ===
5555

5656
// SHOULD ALERT
5757
const myTool = tool({
5858
name: "lookup",
59-
description: "Look up info about " + persona, // $ Alert[js/prompt-injection]
59+
description: "Look up info about " + persona, // $ Alert[js/system-prompt-injection]
6060
parameters: z.object({ query: z.string() }),
6161
execute: async ({ query }) => "result",
6262
});
@@ -70,15 +70,15 @@ app.get("/agents", async (req, res) => {
7070

7171
// SHOULD ALERT
7272
const r2 = await run(agent1, [
73-
{ role: "system", content: "Talk like a " + persona }, // $ Alert[js/prompt-injection]
73+
{ role: "system", content: "Talk like a " + persona }, // $ Alert[js/system-prompt-injection]
7474
{ role: "user", content: query },
7575
]);
7676

7777
// === run() with array input: developer role ===
7878

7979
// SHOULD ALERT
8080
const r3 = await run(agent1, [
81-
{ role: "developer", content: "Talk like a " + persona }, // $ Alert[js/prompt-injection]
81+
{ role: "developer", content: "Talk like a " + persona }, // $ Alert[js/system-prompt-injection]
8282
]);
8383

8484
// === run() with array input: user role ===
@@ -93,7 +93,7 @@ app.get("/agents", async (req, res) => {
9393
// SHOULD ALERT
9494
const runner = new Runner();
9595
const r5 = await runner.run(agent1, [
96-
{ role: "system", content: "Talk like a " + persona }, // $ Alert[js/prompt-injection]
96+
{ role: "system", content: "Talk like a " + persona }, // $ Alert[js/system-prompt-injection]
9797
]);
9898

9999
// === Sanitizer: constant comparison ===

0 commit comments

Comments
 (0)