Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/actions/gh-app-token/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: GitHub App token
description: >-
Mint a GitHub App installation token when GH_APP_ID + GH_APP_PRIVATE_KEY are
configured, otherwise fall back to the workflow GITHUB_TOKEN
(github-actions[bot]) with the same step-summary warning. Replaces the token
block that was copy-pasted across the plugin workflows.

inputs:
app-id:
description: GitHub App client/app id (pass vars.GH_APP_ID)
required: false
default: ""
private-key:
description: >-
GitHub App private key (pass secrets.GH_APP_PRIVATE_KEY). Composite actions
do not inherit secrets, so it must be provided as an input.
required: false
default: ""
owner:
description: Optional owner to scope the installation token to.
required: false
default: ""

outputs:
token:
description: The App installation token, or the fallback github.token.
value: ${{ steps.result.outputs.token }}
app-slug:
description: The App slug (empty when falling back).
value: ${{ steps.app-token.outputs.app-slug }}
use_app:
description: "'true' when an App token was minted, 'false' on fallback."
value: ${{ steps.config.outputs.use_app }}
bot-login:
description: The bot login to attribute actions to.
value: ${{ steps.result.outputs.bot_login }}

runs:
using: composite
steps:
- name: Resolve configuration
id: config
shell: bash
env:
GH_APP_ID: ${{ inputs.app-id }}
GH_APP_PRIVATE_KEY: ${{ inputs.private-key }}
run: |
if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then
echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT"
echo "use_app=true" >> "$GITHUB_OUTPUT"
else
echo "use_app=false" >> "$GITHUB_OUTPUT"
fi

- name: Generate GitHub App token
if: steps.config.outputs.use_app == 'true'
id: app-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ steps.config.outputs.app_id }}
private-key: ${{ inputs.private-key }}
owner: ${{ inputs.owner }}

- name: Log fallback to actions token
if: steps.config.outputs.use_app != 'true'
shell: bash
run: |
printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY"

- name: Expose token + bot login
id: result
shell: bash
env:
USE_APP: ${{ steps.config.outputs.use_app }}
APP_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
FALLBACK_TOKEN: ${{ github.token }}
run: |
if [[ "$USE_APP" == "true" ]]; then
echo "token=$APP_TOKEN" >> "$GITHUB_OUTPUT"
echo "bot_login=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
else
echo "token=$FALLBACK_TOKEN" >> "$GITHUB_OUTPUT"
echo "bot_login=github-actions[bot]" >> "$GITHUB_OUTPUT"
fi
64 changes: 64 additions & 0 deletions .github/actions/trusted-checkout/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Trusted checkout
description: >-
The pull_request_target checkout "dance": check out the base branch's trusted
code, sparse-checkout the untrusted fork's plugins/ as data only, then restore
the trusted code and re-fetch the base refs. Fork content is never executed;
pluginctl always comes from the base branch. Replaces the sequence duplicated
across detect-changes / validate-plugin.

inputs:
base-ref:
description: PR base branch (github.event.pull_request.base.ref)
required: true
head-repo:
description: Fork repo full name (github.event.pull_request.head.repo.full_name)
required: true
head-sha:
description: Fork head SHA (github.event.pull_request.head.sha)
required: true
repository:
description: This repository (github.repository)
required: true

runs:
using: composite
steps:
- name: Checkout base branch code (trusted)
uses: actions/checkout@v6
with:
repository: ${{ inputs.repository }}
ref: ${{ inputs.base-ref }}
fetch-depth: 0
sparse-checkout: .github
sparse-checkout-cone-mode: false

- name: Fetch base branch
shell: bash
run: git fetch origin ${{ inputs.base-ref }}

- name: Save trusted code before fork checkout
shell: bash
run: cp -r .github /tmp/trusted-dotgithub

- name: Checkout PR plugins (untrusted content only)
uses: actions/checkout@v6
with:
repository: ${{ inputs.head-repo }}
ref: ${{ inputs.head-sha }}
fetch-depth: 0
sparse-checkout: plugins
sparse-checkout-cone-mode: false
clean: false
# Fork content is only read as data (sparse plugins/), never executed;
# trusted code is saved/restored around this step.
allow-unsafe-pr-checkout: true

- name: Restore trusted code
shell: bash
run: |
rm -rf .github
cp -r /tmp/trusted-dotgithub .github

- name: Re-fetch base branch refs
shell: bash
run: git fetch https://github.com/${{ inputs.repository }} +${{ inputs.base-ref }}:refs/remotes/origin/${{ inputs.base-ref }}
3 changes: 2 additions & 1 deletion .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Extra CodeQL query applied alongside the `security-extended` suite used by
# validate-plugin.yml. `packs:` is required here (rather than the simpler
# _codeql-scan.yml. `packs:` is required here (rather than the simpler
# `packs:` input on codeql-action/init) because that input only works for
# single-language analyses, and our workflow can initialize multiple
# languages at once.
Expand All @@ -17,6 +17,7 @@ queries:
packs:
python:
- codeql/python-queries:AlertSuppression.ql
- .github/codeql/queries/python
javascript:
- codeql/javascript-queries:AlertSuppression.ql
go:
Expand Down
34 changes: 34 additions & 0 deletions .github/codeql/queries/python/BuiltinsMutation.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @name Plugin mutation of builtins
* @description Mutating builtins can alter import and identity behavior outside a plugin sandbox.
* @kind problem
* @problem.severity warning
* @precision high
* @id plugin/sandbox-bypass/builtins-mutation
* @tags security external/cwe/cwe-693
*/

import python

predicate builtinsObject(Expr expression) {
exists(Name builtins | expression = builtins and builtins.getId() = "__builtins__")
}

from Expr result
where
exists(AssignStmt assignment, Expr target |
assignment.getTarget() = target and
(
exists(Attribute attribute | target = attribute and builtinsObject(attribute.getObject())) or
exists(Subscript subscript | target = subscript and builtinsObject(subscript.getObject()))
) and
result = target
)
or
exists(Call call, Name setattr |
call.getFunc() = setattr and
setattr.getId() = "setattr" and
builtinsObject(call.getArg(0)) and
result = call
)
select result, "Mutating __builtins__ can bypass a Python-level plugin sandbox."
15 changes: 15 additions & 0 deletions .github/codeql/queries/python/CtypesUsage.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @name Plugin use of ctypes
* @description Native ctypes access can bypass Python-level plugin sandboxing.
* @kind problem
* @problem.severity warning
* @precision high
* @id plugin/sandbox-bypass/ctypes-usage
* @tags security external/cwe/cwe-693
*/

import python

from Import imp
where imp.getAnImportedModuleName() = "ctypes"
select imp, "ctypes can load native code outside the plugin sandbox."
17 changes: 17 additions & 0 deletions .github/codeql/queries/python/FrameGlobalsWrite.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @name Plugin writes frame globals or locals
* @description Writing another frame's globals or locals can spoof a frame-based plugin identity check.
* @kind problem
* @problem.severity warning
* @precision medium
* @id plugin/sandbox-bypass/frame-globals-write
* @tags security external/cwe/cwe-693
*/

import python

from AssignStmt assignment, Subscript target, Attribute frameNamespace
where assignment.getTarget() = target and
target.getObject() = frameNamespace and
frameNamespace.getName() in ["f_globals", "f_locals"]
select target, "Writing frame globals or locals can bypass a frame-based plugin sandbox check."
16 changes: 16 additions & 0 deletions .github/codeql/queries/python/ObfuscatedDynamicResolution.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @name Plugin dynamic code resolution
* @description Dynamic evaluation can obscure module access that bypasses a plugin sandbox.
* @kind problem
* @problem.severity warning
* @precision medium
* @id plugin/sandbox-bypass/obfuscated-dynamic-resolution
* @tags security external/cwe/cwe-693
*/

import python

from Call call, Name function
where call.getFunc() = function and
function.getId() in ["eval", "exec"]
select call, "Dynamic evaluation can obscure an attempted plugin sandbox bypass."
15 changes: 15 additions & 0 deletions .github/codeql/queries/python/SubclassGadgetBroad.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @name Plugin sandbox introspection gadget
* @description Python introspection attributes can expose classes or globals outside a plugin sandbox.
* @kind problem
* @problem.severity warning
* @precision medium
* @id plugin/sandbox-bypass/subclass-gadget-broad
* @tags security external/cwe/cwe-693
*/

import python

from Attribute attribute
where attribute.getName() in ["__subclasses__", "__globals__", "__mro__"]
select attribute, "This introspection attribute can be used to escape a Python-level sandbox."
27 changes: 27 additions & 0 deletions .github/codeql/queries/python/SubclassGadgetExact.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @name Plugin subclass enumeration gadget
* @description Enumerating subclasses is a common Python sandbox escape primitive.
* @kind problem
* @problem.severity warning
* @precision high
* @id plugin/sandbox-bypass/subclass-gadget-exact
* @tags security external/cwe/cwe-693
*/

import python

from Call call, Attribute subclasses
where call.getFunc() = subclasses and
subclasses.getName() = "__subclasses__" and
(
subclasses.getObject().(Name).getId() in ["object", "type"]
or
exists(Subscript index, Attribute bases, Attribute classAttribute |
subclasses.getObject() = index and
index.getObject() = bases and
bases.getName() in ["__bases__", "__mro__"] and
bases.getObject() = classAttribute and
classAttribute.getName() = "__class__"
)
)
select call, "Subclass enumeration can expose objects outside the plugin sandbox."
45 changes: 45 additions & 0 deletions .github/codeql/queries/python/SysModulesTamper.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* @name Plugin tampering with sys.modules
* @description Changing sys.modules can replace or remove sandbox-wrapped modules.
* @kind problem
* @problem.severity warning
* @precision medium
* @id plugin/sandbox-bypass/sys-modules-tamper
* @tags security external/cwe/cwe-693
*/

import python

predicate sensitiveModuleName(Expr expression) {
exists(StringLiteral literal |
expression = literal and
literal.getText() in ["subprocess", "socket", "os", "urllib.request", "requests",
"apps.plugins", "apps.plugins.sandbox"]
)
}

predicate sysModules(Attribute attribute) {
exists(Name sys |
attribute.getObject() = sys and sys.getId() = "sys" and attribute.getName() = "modules"
)
}

from Expr result
where
exists(AssignStmt assignment, Subscript target, Attribute modules |
assignment.getTarget() = target and
target.getObject() = modules and
sysModules(modules) and
sensitiveModuleName(target.getIndex()) and
result = target
)
or
exists(Call call, Attribute pop, Attribute modules |
call.getFunc() = pop and
pop.getName() = "pop" and
pop.getObject() = modules and
sysModules(modules) and
sensitiveModuleName(call.getArg(0)) and
result = call
)
select result, "Changing this sys.modules entry can bypass sandbox-wrapped imports."
4 changes: 4 additions & 0 deletions .github/codeql/queries/python/qlpack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: dispatcharr/plugin-sandbox-bypass-queries
version: 0.1.0
dependencies:
codeql/python-all: "*"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CtypesUsage.ql
3 changes: 3 additions & 0 deletions .github/codeql/queries/python/tests/CtypesUsage/bad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import ctypes

ctypes.CDLL("libexample.so")
3 changes: 3 additions & 0 deletions .github/codeql/queries/python/tests/CtypesUsage/good.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import json

json.dumps({"safe": True})
5 changes: 5 additions & 0 deletions .github/codeql/queries/python/tests/qlpack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: dispatcharr/plugin-sandbox-bypass-query-tests
dependencies:
dispatcharr/plugin-sandbox-bypass-queries: "*"
extractor: python
tests: .
Loading
Loading