Skip to content
Open
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
39 changes: 39 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CodeQL

# Added so the intentionally vulnerable fixture under fixtures/vulnerable-app/
# produces real code-scanning alerts on pull requests. Safe to keep: it only
# analyses code, it never opens PRs.

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1"

permissions:
contents: read
security-events: write

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [python]
steps:
- uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
category: /language:${{ matrix.language }}
38 changes: 38 additions & 0 deletions fixtures/vulnerable-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# ⚠️ INTENTIONALLY VULNERABLE FIXTURE — DO NOT DEPLOY

This directory exists **only** as a test fixture for a security-scanning pipeline
(GitHub Advisory Database / Dependabot / CodeQL / secret scanning).

Every file here is deliberately insecure. It is not imported by `src/redthread`,
is not on any runtime path, and must never be packaged, deployed, or executed
outside a throwaway sandbox.
Comment on lines +6 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the vulnerable fixture from source distributions

When a source distribution is built, Hatch includes tracked repository files by default because the project only restricts the wheel target and has no sdist exclusion. As a result, release builds can contain this executable vulnerable app and its deliberately vulnerable manifests despite the stated packaging boundary. Add an explicit Hatch sdist exclusion for fixtures/vulnerable-app/.

Useful? React with 👍 / 👎.


## What is planted here

### 1. Dependency advisories (GHSA / Dependabot)
`requirements.txt` pins packages to versions with published advisories:

| Package | Pinned | Advisory | CVE |
|---|---|---|---|
| PyYAML | 5.3.1 | GHSA-8q59-q68h-6hv4 | CVE-2020-14343 |
| Jinja2 | 2.10 | GHSA-462w-v97r-4m45 | CVE-2019-10906 |
| requests | 2.19.1 | GHSA-x84v-xcm2-53pg | CVE-2018-18074 |
| urllib3 | 1.24.1 | GHSA-mh33-7rrq-662w | CVE-2019-11324 |
| Flask | 0.12.2 | GHSA-5wv5-4vpf-pj6m | CVE-2018-1000656 |
| Pillow | 8.1.0 | GHSA-8vj2-vgrf-5rv6 | CVE-2021-25287 |
| cryptography | 3.3.2 | GHSA-x4qr-2fvf-3mr5 | CVE-2023-23931 |

`package.json` does the same for the npm ecosystem (lodash prototype pollution,
minimist argument injection).

### 2. Source-level weaknesses (CodeQL / Semgrep)
`app.py` contains SQL injection, OS command injection, unsafe YAML and pickle
deserialization, SSTI, path traversal, SSRF, a weak hash, disabled TLS
verification, and a hardcoded credential. Each is annotated inline with the
secure approach that would have been used in real code.

## Expected pipeline behaviour
Opening this branch as a PR should produce Dependabot alerts for the manifests
above and code-scanning alerts for `app.py`. The follow-up "fix" PR reverses
Comment on lines +35 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Test dependency alerts only after merging to the default branch

Dependabot generates repository alerts from manifests on the default branch, not from manifests added only on a pull-request branch. Therefore, opening this fixture as the promised do-not-merge PR will produce the CodeQL results but not the Dependabot alerts needed for the advisory-to-commit experiment. Use a dependency-review/advisory scan that runs against the PR contents, or test the Dependabot portion in a disposable repository where the fixture can become the default branch.

Useful? React with 👍 / 👎.

all of it, giving the introducing-commit / fixing-commit pair used to exercise
advisory-to-commit correlation.
151 changes: 151 additions & 0 deletions fixtures/vulnerable-app/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""INTENTIONALLY VULNERABLE Flask app — security-pipeline test fixture.

Not imported by ``src/redthread``. Not on any runtime path. Never deploy this.

Each handler below plants one well-known weakness so that code scanning has
something deterministic to flag. Every one carries a ``SECURE APPROACH`` note
describing what the real implementation would do.
"""

import hashlib
import os
import pickle
import sqlite3
import subprocess

import requests
import yaml
from flask import Flask, request, send_file
from jinja2 import Template

app = Flask(__name__)

# DANGEROUS: hardcoded credentials committed to source control.
# SECURE APPROACH: load from the environment or a secret manager (AWS Secrets
# Manager / Vault) at startup, keep the value out of git entirely, and add a
# pre-commit secret scanner so a literal like this can never be committed.
DB_PASSWORD = "sup3rs3cr3t-admin-password"
GITHUB_TOKEN = "ghp_0123456789abcdefghijklmnopqrstuvwxyzAB"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

# DANGEROUS: debug mode also enables the Werkzeug interactive debugger, which is
# a remote code execution primitive if it is ever reachable off-localhost.
# SECURE APPROACH: drive this from an explicit env flag that defaults to off,
# and never let the production config path set it.
app.config["DEBUG"] = True


@app.route("/user")
def get_user():
"""DANGEROUS: SQL injection — user input is concatenated into the query.

SECURE APPROACH: use a parameterised query
(``cur.execute("SELECT ... WHERE username = ?", (username,))``) so the
driver binds the value instead of splicing it into SQL text, or go through
an ORM layer that does this by construction.
"""
username = request.args.get("username", "")
conn = sqlite3.connect("users.db")
cur = conn.cursor()
query = f"SELECT id, email FROM users WHERE username = '{username}'" # noqa: S608
cur.execute(query)
return {"rows": cur.fetchall()}


@app.route("/ping")
def ping():
"""DANGEROUS: OS command injection — ``shell=True`` on attacker input.

SECURE APPROACH: never build a shell string. Pass an argument list with
``shell=False`` (``subprocess.run(["ping", "-c", "1", host])``) and
validate ``host`` against a strict allowlist or an IP/hostname regex first.
"""
host = request.args.get("host", "127.0.0.1")
output = subprocess.check_output(f"ping -c 1 {host}", shell=True)
return {"output": output.decode()}


@app.route("/config", methods=["POST"])
def load_config():
"""DANGEROUS: ``yaml.load`` with the default loader executes arbitrary
Python tags (this is exactly CVE-2020-14343 in the pinned PyYAML).

SECURE APPROACH: use ``yaml.safe_load``, which refuses object-construction
tags, and validate the resulting dict against a schema (pydantic) before
using any of it.
"""
return {"config": yaml.load(request.data)}


@app.route("/session", methods=["POST"])
def restore_session():
"""DANGEROUS: unpickling untrusted bytes is arbitrary code execution.

SECURE APPROACH: never use pickle as a wire format. Serialise sessions as
JSON, and if the payload must be trusted across a boundary, sign it
(HMAC-SHA256) and verify the signature before parsing.
"""
return {"session": str(pickle.loads(request.data))}


@app.route("/render")
def render():
"""DANGEROUS: server-side template injection — user input compiled as a
Jinja2 template, which reaches Python objects and then the interpreter.

SECURE APPROACH: treat user input as *data*, never as template source:
``Template(FIXED_TEMPLATE).render(name=user_input)``. Autoescaping on, and
no user-controlled template text ever reaches the compiler.
"""
template = request.args.get("template", "hello")
return Template(template).render()


@app.route("/download")
def download():
"""DANGEROUS: path traversal — ``../../etc/passwd`` escapes the base dir.

SECURE APPROACH: resolve the joined path and assert it is still inside the
base directory (``os.path.commonpath``/``Path.resolve().is_relative_to``),
or better, look the file up by an opaque ID in a database instead of
letting the client supply any part of a filesystem path.
"""
filename = request.args.get("file", "readme.txt")
return send_file(os.path.join("/var/app/files", filename))


@app.route("/fetch")
def fetch():
"""DANGEROUS: SSRF plus disabled TLS verification.

``verify=False`` turns every HTTPS call into a trivially interceptable
plaintext-equivalent channel, and the unvalidated URL lets a caller reach
internal services and cloud metadata endpoints (169.254.169.254).

SECURE APPROACH: keep ``verify=True`` (fix the trust store instead of
disabling the check), and put the URL through an allowlist of schemes and
hosts, resolving DNS first and rejecting private/link-local address ranges
before the request goes out.
"""
url = request.args.get("url", "")
resp = requests.get(url, verify=False, timeout=10)
return {"body": resp.text[:500]}


def hash_password(password: str) -> str:
"""DANGEROUS: MD5, unsalted, for password storage.

SECURE APPROACH: use a memory-hard password hash designed for this —
argon2id (or bcrypt/scrypt) with a per-user salt and tuned cost parameters.
MD5 is both broken for collision resistance and far too fast to resist
offline cracking.
"""
return hashlib.md5(password.encode()).hexdigest() # noqa: S324


if __name__ == "__main__":
# DANGEROUS: binds every interface with the interactive debugger enabled.
# SECURE APPROACH: bind 127.0.0.1 behind a reverse proxy, debug off, and
# serve through a production WSGI server (gunicorn/uvicorn) rather than
# Werkzeug's development server.
app.run(host="0.0.0.0", port=5000, debug=True)
13 changes: 13 additions & 0 deletions fixtures/vulnerable-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "vulnerable-app-fixture",
"version": "0.0.0",
"private": true,
"description": "INTENTIONALLY VULNERABLE npm manifest. Test fixture only - do not install or deploy.",
"license": "UNLICENSED",
"dependencies": {
"lodash": "4.17.11",
"minimist": "1.2.0",
"axios": "0.21.0",
"handlebars": "4.0.13"
}
}
23 changes: 23 additions & 0 deletions fixtures/vulnerable-app/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# ⚠️ INTENTIONALLY VULNERABLE — test fixture for advisory-scanning pipeline.
# Every pin below has a published GitHub Security Advisory. Do not copy.

# GHSA-8q59-q68h-6hv4 / CVE-2020-14343 — arbitrary code execution via yaml.full_load
PyYAML==5.3.1

# GHSA-462w-v97r-4m45 / CVE-2019-10906 — sandbox escape in the Jinja2 sandbox
Jinja2==2.10

# GHSA-x84v-xcm2-53pg / CVE-2018-18074 — Authorization header leaked across redirects
requests==2.19.1

# GHSA-mh33-7rrq-662w / CVE-2019-11324 — CA cert handling regression
urllib3==1.24.1

# GHSA-5wv5-4vpf-pj6m / CVE-2018-1000656 — denial of service on malformed JSON
Flask==0.12.2

# GHSA-8vj2-vgrf-5rv6 / CVE-2021-25287 — out-of-bounds read in the JPEG2000 decoder
Pillow==8.1.0

# GHSA-x4qr-2fvf-3mr5 / CVE-2023-23931 — memory corruption via Cipher.update_into
cryptography==3.3.2
Loading