mirror of
https://github.com/github/codeql.git
synced 2026-06-23 21:57:01 +02:00
move system prompt injection to non-experimental
This commit is contained in:
55
javascript/ql/lib/semmle/javascript/frameworks/Anthropic.qll
Normal file
55
javascript/ql/lib/semmle/javascript/frameworks/Anthropic.qll
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Provides classes modeling security-relevant aspects of the `@anthropic-ai/sdk` package.
|
||||
* See https://github.com/anthropics/anthropic-sdk-typescript
|
||||
*
|
||||
* Structurally typed sinks (system, beta.agents) have been moved to
|
||||
* Models as Data: javascript/ql/lib/ext/anthropic.model.yml
|
||||
*
|
||||
* This file retains only role-filtered message sinks that require inspecting
|
||||
* a sibling `role` property, which MaD cannot express.
|
||||
*/
|
||||
|
||||
private import javascript
|
||||
|
||||
module Anthropic {
|
||||
/** Gets a reference to the `Anthropic` client instance. */
|
||||
private API::Node classRef() {
|
||||
result = API::moduleImport("@anthropic-ai/sdk").getInstance()
|
||||
}
|
||||
|
||||
/** Gets a reference to the messages.create params (both stable and beta). */
|
||||
private API::Node messagesCreateParams() {
|
||||
result = classRef().getMember("messages").getMember("create").getParameter(0)
|
||||
or
|
||||
result =
|
||||
classRef().getMember("beta").getMember("messages").getMember("create").getParameter(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered system/assistant message sinks.
|
||||
* These require checking a sibling `role` property and cannot be expressed in MaD.
|
||||
*/
|
||||
API::Node getSystemOrAssistantPromptNode() {
|
||||
// messages: [{ role: "assistant", content: "..." }]
|
||||
exists(API::Node msg |
|
||||
msg = messagesCreateParams().getMember("messages").getArrayElement() and
|
||||
msg.getMember("role").asSink().mayHaveStringValue(["system", "assistant"])
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered user message sinks.
|
||||
* These require checking a sibling `role` property and cannot be expressed in MaD.
|
||||
*/
|
||||
API::Node getUserPromptNode() {
|
||||
// messages: [{ role: "user", content: "..." }]
|
||||
exists(API::Node msg |
|
||||
msg = messagesCreateParams().getMember("messages").getArrayElement() and
|
||||
not msg.getMember("role").asSink().mayHaveStringValue(["system", "assistant"])
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Provides classes modeling security-relevant aspects of the `@google/genai` package.
|
||||
* See https://github.com/googleapis/js-genai
|
||||
*
|
||||
* Structurally typed sinks (systemInstruction, prompt, message, etc.) have been
|
||||
* moved to Models as Data: javascript/ql/lib/ext/google-genai.model.yml
|
||||
*
|
||||
* This file retains only role-filtered content sinks that require inspecting
|
||||
* a sibling `role` property, which MaD cannot express.
|
||||
*/
|
||||
|
||||
private import javascript
|
||||
|
||||
module GoogleGenAI {
|
||||
/** Gets a reference to the `GoogleGenAI` client instance. */
|
||||
private API::Node clientRef() {
|
||||
result =
|
||||
API::moduleImport("@google/genai").getMember("GoogleGenAI").getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered system/model message sinks.
|
||||
* These require checking a sibling `role` property and cannot be expressed in MaD.
|
||||
*/
|
||||
API::Node getSystemOrAssistantPromptNode() {
|
||||
// contents: [{ role: "model", parts: [{ text: "..." }] }]
|
||||
// Gemini uses "model" role instead of "assistant"
|
||||
exists(API::Node msg |
|
||||
msg =
|
||||
clientRef()
|
||||
.getMember("models")
|
||||
.getMember(["generateContent", "generateContentStream"])
|
||||
.getParameter(0)
|
||||
.getMember("contents")
|
||||
.getArrayElement() and
|
||||
msg.getMember("role").asSink().mayHaveStringValue(["system", "model"])
|
||||
|
|
||||
result = msg.getMember("parts").getArrayElement().getMember("text")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered user message sinks.
|
||||
* These require checking a sibling `role` property and cannot be expressed in MaD.
|
||||
*/
|
||||
API::Node getUserPromptNode() {
|
||||
// contents: [{ role: "user", parts: [{ text: "..." }] }]
|
||||
exists(API::Node msg |
|
||||
msg =
|
||||
clientRef()
|
||||
.getMember("models")
|
||||
.getMember(["generateContent", "generateContentStream"])
|
||||
.getParameter(0)
|
||||
.getMember("contents")
|
||||
.getArrayElement() and
|
||||
not msg.getMember("role").asSink().mayHaveStringValue(["system", "model"])
|
||||
|
|
||||
result = msg.getMember("parts").getArrayElement().getMember("text")
|
||||
)
|
||||
}
|
||||
}
|
||||
287
javascript/ql/lib/semmle/javascript/frameworks/OpenAI.qll
Normal file
287
javascript/ql/lib/semmle/javascript/frameworks/OpenAI.qll
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Provides classes modeling security-relevant aspects of the `openAI-Node` package.
|
||||
* See https://github.com/openai/openai-node
|
||||
*
|
||||
* Structurally typed sinks (instructions, prompt, input, etc.) have been moved to
|
||||
* Models as Data: javascript/ql/lib/ext/openai.model.yml
|
||||
*
|
||||
* This file retains only role-filtered sinks that require inspecting a sibling
|
||||
* `role` property, which MaD cannot express.
|
||||
*/
|
||||
|
||||
private import javascript
|
||||
|
||||
/** Holds if `msg` is a message array element with a privileged role. */
|
||||
private predicate isSystemOrDevMessage(API::Node msg) {
|
||||
msg.getMember("role").asSink().mayHaveStringValue(["system", "developer", "assistant"])
|
||||
}
|
||||
|
||||
module OpenAI {
|
||||
/** Gets a reference to all OpenAI client instances. */
|
||||
private API::Node allClients() {
|
||||
result = API::moduleImport("openai").getInstance()
|
||||
or
|
||||
result = API::moduleImport("openai").getMember(["OpenAI", "AzureOpenAI"]).getInstance()
|
||||
or
|
||||
result =
|
||||
API::moduleImport("@openai/guardrails")
|
||||
.getMember(["GuardrailsOpenAI", "GuardrailsAzureOpenAI"])
|
||||
.getMember("create")
|
||||
.getReturn()
|
||||
.getPromised()
|
||||
}
|
||||
|
||||
/** Gets a guarded client that is clearly configured without input guardrails. */
|
||||
private API::Node unprotectedGuardedClient() {
|
||||
exists(API::Node createCall |
|
||||
createCall =
|
||||
API::moduleImport("@openai/guardrails")
|
||||
.getMember(["GuardrailsOpenAI", "GuardrailsAzureOpenAI"])
|
||||
.getMember("create") and
|
||||
result = createCall.getReturn().getPromised() and
|
||||
exists(createCall.getParameter(0).getMember("version")) and
|
||||
not exists(
|
||||
createCall.getParameter(0).getMember("input").getMember("guardrails").getArrayElement()
|
||||
) and
|
||||
not exists(
|
||||
createCall.getParameter(0).getMember("pre_flight").getMember("guardrails").getArrayElement()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Gets a reference to all clients without input guardrails. */
|
||||
private API::Node clientsNoGuardrails() {
|
||||
result = API::moduleImport("openai").getInstance()
|
||||
or
|
||||
result = API::moduleImport("openai").getMember(["OpenAI", "AzureOpenAI"]).getInstance()
|
||||
or
|
||||
result = unprotectedGuardedClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered system/developer/assistant message sinks.
|
||||
* These require checking a sibling `role` property and cannot be expressed in MaD.
|
||||
*/
|
||||
API::Node getSystemOrAssistantPromptNode() {
|
||||
// responses.create({ input: [{ role: "system"/"developer", content: "..." }] })
|
||||
exists(API::Node msg |
|
||||
msg =
|
||||
allClients()
|
||||
.getMember("responses")
|
||||
.getMember("create")
|
||||
.getParameter(0)
|
||||
.getMember("input")
|
||||
.getArrayElement() and
|
||||
isSystemOrDevMessage(msg)
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
or
|
||||
// chat.completions.create({ messages: [{ role: "system"/"developer", content: ... }] })
|
||||
exists(API::Node msg, API::Node content |
|
||||
msg =
|
||||
allClients()
|
||||
.getMember("chat")
|
||||
.getMember("completions")
|
||||
.getMember("create")
|
||||
.getParameter(0)
|
||||
.getMember("messages")
|
||||
.getArrayElement() and
|
||||
isSystemOrDevMessage(msg) and
|
||||
content = msg.getMember("content")
|
||||
|
|
||||
result = content
|
||||
or
|
||||
result = content.getArrayElement().getMember("text")
|
||||
)
|
||||
or
|
||||
// beta.threads.messages.create(threadId, { role: "system"/"developer", content: ... })
|
||||
exists(API::Node msg |
|
||||
msg =
|
||||
allClients()
|
||||
.getMember("beta")
|
||||
.getMember("threads")
|
||||
.getMember("messages")
|
||||
.getMember("create")
|
||||
.getParameter(1) and
|
||||
isSystemOrDevMessage(msg)
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered user message sinks.
|
||||
* These require checking a sibling `role` property and cannot be expressed in MaD.
|
||||
*/
|
||||
API::Node getUserPromptNode() {
|
||||
// responses.create({ input: "string" })
|
||||
result =
|
||||
clientsNoGuardrails()
|
||||
.getMember("responses")
|
||||
.getMember("create")
|
||||
.getParameter(0)
|
||||
.getMember("input")
|
||||
or
|
||||
// responses.create({ input: [{ role: "user", content: ... }] })
|
||||
exists(API::Node msg |
|
||||
msg =
|
||||
clientsNoGuardrails()
|
||||
.getMember("responses")
|
||||
.getMember("create")
|
||||
.getParameter(0)
|
||||
.getMember("input")
|
||||
.getArrayElement() and
|
||||
not isSystemOrDevMessage(msg)
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
or
|
||||
// chat.completions.create({ messages: [{ role: "user", content: ... }] })
|
||||
exists(API::Node msg, API::Node content |
|
||||
msg =
|
||||
clientsNoGuardrails()
|
||||
.getMember("chat")
|
||||
.getMember("completions")
|
||||
.getMember("create")
|
||||
.getParameter(0)
|
||||
.getMember("messages")
|
||||
.getArrayElement() and
|
||||
not isSystemOrDevMessage(msg) and
|
||||
content = msg.getMember("content")
|
||||
|
|
||||
result = content
|
||||
or
|
||||
result = content.getArrayElement().getMember("text")
|
||||
)
|
||||
or
|
||||
// Legacy completions API: completions.create({ prompt: ... })
|
||||
result =
|
||||
clientsNoGuardrails()
|
||||
.getMember("completions")
|
||||
.getMember("create")
|
||||
.getParameter(0)
|
||||
.getMember("prompt")
|
||||
or
|
||||
// images.generate({ prompt: ... }) and images.edit({ prompt: ... })
|
||||
result =
|
||||
clientsNoGuardrails()
|
||||
.getMember("images")
|
||||
.getMember(["generate", "edit"])
|
||||
.getParameter(0)
|
||||
.getMember("prompt")
|
||||
or
|
||||
// beta.threads.messages.create(threadId, { role: "user", content: ... })
|
||||
exists(API::Node msg |
|
||||
msg =
|
||||
clientsNoGuardrails()
|
||||
.getMember("beta")
|
||||
.getMember("threads")
|
||||
.getMember("messages")
|
||||
.getMember("create")
|
||||
.getParameter(1) and
|
||||
not isSystemOrDevMessage(msg)
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
or
|
||||
// audio.transcriptions/translations.create({ prompt: ... })
|
||||
result =
|
||||
clientsNoGuardrails()
|
||||
.getMember("audio")
|
||||
.getMember(["transcriptions", "translations"])
|
||||
.getMember("create")
|
||||
.getParameter(0)
|
||||
.getMember("prompt")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides models for agents SDK.
|
||||
*
|
||||
* See https://github.com/openai/openai-agents-js and
|
||||
* https://github.com/openai/openai-guardrails-js.
|
||||
*
|
||||
* Structurally typed sinks have been moved to openai.model.yml.
|
||||
* This module retains only role-filtered sinks, callback-based sinks, and
|
||||
* unsafe agent detection that MaD cannot express.
|
||||
*/
|
||||
module AgentSDK {
|
||||
API::Node moduleRef() {
|
||||
result = API::moduleImport("@openai/agents")
|
||||
or
|
||||
result = API::moduleImport("@openai/guardrails")
|
||||
}
|
||||
|
||||
/** Gets a reference to the top-level run() or Runner.run() functions. */
|
||||
private API::Node run() {
|
||||
result = moduleRef().getMember("run")
|
||||
or
|
||||
result = moduleRef().getMember("Runner").getInstance().getMember("run")
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered and callback-based system prompt sinks that MaD cannot express.
|
||||
*/
|
||||
API::Node getSystemOrAssistantPromptNode() {
|
||||
// Agent({ instructions: (runContext) => returnValue }) — callback form
|
||||
result = moduleRef()
|
||||
.getMember("Agent")
|
||||
.getParameter(0)
|
||||
.getMember("instructions")
|
||||
.getReturn()
|
||||
or
|
||||
// run(agent, [{ role: "system"/"developer", content: ... }])
|
||||
exists(API::Node msg |
|
||||
msg = run()
|
||||
.getParameter(1)
|
||||
.getArrayElement() and
|
||||
isSystemOrDevMessage(msg)
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets role-filtered user prompt sinks for run(agent, input).
|
||||
* The string-input case is handled via MaD (openai.model.yml).
|
||||
*/
|
||||
API::Node getUserPromptNode() {
|
||||
// run(agent, [{ role: "user", content: ... }])
|
||||
exists(API::Node msg |
|
||||
msg = run().getParameter(1).getArrayElement() and
|
||||
not isSystemOrDevMessage(msg)
|
||||
|
|
||||
result = msg.getMember("content")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an agent constructor config that visibly lacks input guardrails.
|
||||
* Covers both native Agent({ inputGuardrails: [...] }) and
|
||||
* GuardrailAgent.create({ input: { guardrails: [...] } }, ...).
|
||||
*/
|
||||
API::Node getUnsafeAgentNode() {
|
||||
// new Agent({ name: '...', ... }) without inputGuardrails
|
||||
result = moduleRef().getMember("Agent").getParameter(0) and
|
||||
// Config is an inspectable object literal
|
||||
(exists(result.getMember("name")) or exists(result.getMember("instructions"))) and
|
||||
not exists(result.getMember("inputGuardrails").getArrayElement())
|
||||
or
|
||||
// GuardrailAgent.create(config, ...) without input/pre_flight guardrails
|
||||
exists(API::Node createCall |
|
||||
createCall =
|
||||
moduleRef()
|
||||
.getMember("GuardrailAgent")
|
||||
.getMember("create") and
|
||||
result = createCall.getParameter(0) and
|
||||
exists(result.getMember("version")) and
|
||||
not exists(
|
||||
result.getMember("input").getMember("guardrails").getArrayElement()
|
||||
) and
|
||||
not exists(
|
||||
result.getMember("pre_flight").getMember("guardrails").getArrayElement()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Provides default sources, sinks and sanitizers for detecting
|
||||
* "prompt injection"
|
||||
* vulnerabilities, as well as extension points for adding your own.
|
||||
*/
|
||||
|
||||
import javascript
|
||||
|
||||
private import semmle.javascript.dataflow.DataFlow
|
||||
private import semmle.javascript.Concepts
|
||||
private import semmle.javascript.security.dataflow.RemoteFlowSources
|
||||
private import semmle.javascript.dataflow.internal.BarrierGuards
|
||||
private import semmle.javascript.frameworks.data.ModelsAsData
|
||||
private import semmle.javascript.frameworks.OpenAI
|
||||
private import semmle.javascript.frameworks.Anthropic
|
||||
private import semmle.javascript.frameworks.GoogleGenAI
|
||||
|
||||
/**
|
||||
* Provides default sources, sinks and sanitizers for detecting
|
||||
* "prompt injection"
|
||||
* vulnerabilities, as well as extension points for adding your own.
|
||||
*/
|
||||
module SystemPromptInjection {
|
||||
/**
|
||||
* A data flow source for "prompt injection" vulnerabilities.
|
||||
*/
|
||||
abstract class Source extends DataFlow::Node { }
|
||||
|
||||
/**
|
||||
* A data flow sink for "prompt injection" vulnerabilities.
|
||||
*/
|
||||
abstract class Sink extends DataFlow::Node { }
|
||||
|
||||
/**
|
||||
* A sanitizer for "prompt injection" vulnerabilities.
|
||||
*/
|
||||
abstract class Sanitizer extends DataFlow::Node { }
|
||||
|
||||
/**
|
||||
* An active threat-model source, considered as a flow source.
|
||||
*/
|
||||
private class ActiveThreatModelSourceAsSource extends Source, ActiveThreatModelSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt to an AI model, considered as a flow sink.
|
||||
*/
|
||||
class AIPromptAsSink extends Sink {
|
||||
AIPromptAsSink() { this = any(AIPrompt p).getAPrompt() }
|
||||
}
|
||||
|
||||
private class SinkFromModel extends Sink {
|
||||
SinkFromModel() {
|
||||
this = ModelOutput::getASinkNode("system-prompt-injection").asSink()
|
||||
}
|
||||
}
|
||||
|
||||
private class PromptContentSink extends Sink {
|
||||
PromptContentSink() {
|
||||
this = OpenAI::getSystemOrAssistantPromptNode().asSink()
|
||||
or
|
||||
this = AgentSDK::getSystemOrAssistantPromptNode().asSink()
|
||||
or
|
||||
this = Anthropic::getSystemOrAssistantPromptNode().asSink()
|
||||
or
|
||||
this = GoogleGenAI::getSystemOrAssistantPromptNode().asSink()
|
||||
}
|
||||
}
|
||||
|
||||
private class ConstCompareAsSanitizerGuard extends Sanitizer {
|
||||
ConstCompareAsSanitizerGuard()
|
||||
{
|
||||
this = DataFlow::MakeBarrierGuard<ConstCompareBarrierGuard>::getABarrierNode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content placed in a message with `role: "user"` is not a system prompt
|
||||
* injection vector; it is intended user-role content.
|
||||
*
|
||||
* This prevents false positives when user input and system prompts are
|
||||
* combined in the same message array (e.g. `[{role:"system", content: ...},
|
||||
* {role:"user", content: tainted}]`) and taint would otherwise propagate
|
||||
* through array operations to the system message.
|
||||
*/
|
||||
private class UserRoleMessageContentBarrier extends Sanitizer {
|
||||
UserRoleMessageContentBarrier() {
|
||||
exists(DataFlow::SourceNode obj |
|
||||
obj.getAPropertySource("role").mayHaveStringValue("user") and
|
||||
this = obj.getAPropertyWrite("content").getRhs()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A comparison with a constant, considered as a sanitizer-guard.
|
||||
*/
|
||||
private class ConstCompareBarrierGuard extends DataFlow::ValueNode
|
||||
{
|
||||
override EqualityTest astNode;
|
||||
|
||||
ConstCompareBarrierGuard()
|
||||
{
|
||||
astNode.hasOperands(_, any(ConstantString cs))
|
||||
}
|
||||
|
||||
predicate blocksExpr(boolean outcome, Expr e) {
|
||||
outcome = astNode.getPolarity() and
|
||||
e = astNode.getLeftOperand() and
|
||||
e = astNode.getAnOperand() and
|
||||
not e instanceof ConstantString
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Provides a taint-tracking configuration for detecting "prompt injection" vulnerabilities.
|
||||
*
|
||||
* Note, for performance reasons: only import this file if
|
||||
* `SystemPromptInjectionFlow::Configuration` is needed, otherwise
|
||||
* `SystemPromptInjectionCustomizations` should be imported instead.
|
||||
*/
|
||||
|
||||
private import javascript
|
||||
import semmle.javascript.dataflow.DataFlow
|
||||
import semmle.javascript.dataflow.TaintTracking
|
||||
import SystemPromptInjectionCustomizations::SystemPromptInjection
|
||||
|
||||
private module SystemPromptInjectionConfig implements DataFlow::ConfigSig {
|
||||
predicate isSource(DataFlow::Node node) { node instanceof Source }
|
||||
|
||||
predicate isSink(DataFlow::Node node) { node instanceof Sink }
|
||||
|
||||
predicate isBarrier(DataFlow::Node node) { node instanceof Sanitizer }
|
||||
|
||||
predicate observeDiffInformedIncrementalMode() { any() }
|
||||
}
|
||||
|
||||
/** Global taint-tracking for detecting "prompt injection" vulnerabilities. */
|
||||
module SystemPromptInjectionFlow = TaintTracking::Global<SystemPromptInjectionConfig>;
|
||||
Reference in New Issue
Block a user