Skip to content

Commit b858d71

Browse files
authored
security: add ClusterFuzzLite fuzzing for the SQL-safety kernel (#300)
* security: add ClusterFuzzLite fuzzing for the SQL-safety kernel Closes Scorecard Fuzzing alert #55. Scorecard's Fuzzing check does not recognize Python source patterns at all (only Go/Haskell/JS-TS/Erlang); the only path to real credit for a Python project is OSS-Fuzz or ClusterFuzzLite deployment. Confirmed via the alert's own rule.help text before building this, per the standard OSS-Fuzz Python integration (https://google.github.io/oss-fuzz/getting-started/new-project-guide/python-lang/). Harness (.clusterfuzzlite/sql_safety_fuzzer.py) targets SafeSqlDriver._validate — the pglast parse + AST-walker in mcpg.sql.safety — feeding it arbitrary bytes via Atheris and treating anything but the documented ValueError as a bug. This is the project's actual security-critical surface (see CLAUDE.md's "SQL-safety kernel" section); the existing tests/unit/test_sql_kernel_fuzz.py property tests cover known-shape adversarial inputs, this harness covers unknown-shape ones. Two workflows: cflite_pr.yml (5-minute fuzz run on PRs touching src/mcpg/sql/** or .clusterfuzzlite/**, address+undefined sanitizers) and cflite_batch.yml (1-hour daily batch run to build corpus depth over time). Also adds .gitattributes rules forcing LF for *.sh and .clusterfuzzlite/** — these scripts run inside a Linux container and a CRLF-mangled shebang would break the interpreter on a Windows checkout. Verified: Atheris 3.1.0 ships wheels for cp312/cp313/cp314, matching this project's requires-python >=3.12 — no version-compatibility gap on the fuzzing library itself. Full build/run validated by this PR's own cflite_pr.yml workflow run (Docker-in-Docker local validation wasn't available in this session — see PR checks for the real signal). Entire-Checkpoint: 9d5df8697fd3 * fix: ignore-requires-python in ClusterFuzzLite build (base image is 3.11) PR #300's own CI run caught this: base-builder-python ships Python 3.11.13, below mcpg's declared floor (requires-python >=3.12, set for the wider ~254-tool codebase). pip3 install . failed outright on the version gate before ever reaching the actual code. The fuzz harness only imports mcpg.sql.safety + its direct deps (mcpg.sql.allowlist, mcpg.sql.driver) — verified none of the three use any 3.12-only syntax, so --ignore-requires-python is safe here. Scoped to the fuzzing container only; pyproject.toml's real floor is untouched. Entire-Checkpoint: 3b7a9a8d9b30 * fix: keep-unaffected-fuzz-targets on ClusterFuzzLite PR fuzzing PR #300's own CI run caught another real gap: without this, build_fuzzers tries to prune "unaffected" targets by diffing against a coverage baseline artifact (cifuzz-coverage-latest) that can't exist yet on a brand-new integration — hard-fails with "No fuzz targets found" on literally the first fuzzing PR ever. There's exactly one fuzz target in this repo, so the optimization has no upside; keep it unconditionally. Entire-Checkpoint: c84ca2b91fd2 * fix: restore LLVMFuzzerTestOneInput detection marker in fuzz wrapper Third real gap PR #300's own CI caught: build succeeded (pyinstaller packaged the binary fine, keep-unaffected-fuzz-targets sidestepped the coverage-diff issue), but OSS-Fuzz's bad-build-check still reported "No fuzz targets found". Root cause: the previous commit's execution-wrapper heredoc dropped the "# LLVMFuzzerTestOneInput for fuzzer detection." comment line from the OSS-Fuzz reference example — that line isn't decorative, it's a literal string the detection step greps wrapper scripts for to recognize them as fuzz targets. Restored verbatim. Entire-Checkpoint: 0d81d061fe9a * fix: scope fuzzer discovery to \$SRC root (Gemini review on #300) gemini-code-assist correctly flagged: find "\$SRC" -name '*_fuzzer.py' searches recursively, matching both the Dockerfile's intentional COPY .clusterfuzzlite/sql_safety_fuzzer.py \$SRC/ and the incidental copy that COPY . \$SRC/mcpg (the whole repo) also pulls in at \$SRC/mcpg/.clusterfuzzlite/sql_safety_fuzzer.py. Confirmed in the prior CI run's own log: two full pyinstaller passes for the same fuzzer, silently overwriting the \$OUT wrapper the second time. -maxdepth 1 scopes discovery to the intentional copy only. Entire-Checkpoint: 2bcc94e8e1a8
1 parent 2be9954 commit b858d71

7 files changed

Lines changed: 184 additions & 0 deletions

File tree

.clusterfuzzlite/Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# ClusterFuzzLite build integration for the SQL-safety kernel
2+
# (mcpg.sql.safety). See docs/reviews/devendor-sql-kernel-security-review.md
3+
# for why this parser+AST-walker is the project's security-critical
4+
# surface. Atheris and the OSS-Fuzz Python toolchain are pre-installed on
5+
# this base image — see
6+
# https://google.github.io/oss-fuzz/getting-started/new-project-guide/python-lang/
7+
FROM gcr.io/oss-fuzz-base/base-builder-python
8+
9+
COPY . $SRC/mcpg
10+
WORKDIR $SRC/mcpg
11+
COPY .clusterfuzzlite/build.sh $SRC/
12+
COPY .clusterfuzzlite/sql_safety_fuzzer.py $SRC/

.clusterfuzzlite/build.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/bin/bash -eu
2+
# Standard OSS-Fuzz Python build script — see
3+
# https://google.github.io/oss-fuzz/getting-started/new-project-guide/python-lang/
4+
# for what each step does and why.
5+
6+
# Build and install mcpg (using current CFLAGS/CXXFLAGS) so its C-extension
7+
# deps (pglast wraps libpg_query; psycopg's C extension) are compiled with
8+
# the sanitizer this run is instrumented with.
9+
#
10+
# --ignore-requires-python: base-builder-python ships Python 3.11, below
11+
# this project's declared floor (requires-python >=3.12 in pyproject.toml,
12+
# set for the wider ~254-tool codebase). The fuzz harness only imports
13+
# mcpg.sql.safety + its direct deps (mcpg.sql.allowlist, mcpg.sql.driver),
14+
# none of which use any 3.12-only syntax — verified before adding this
15+
# flag, not assumed. Loosening pyproject.toml's actual floor for the
16+
# whole package would be the wrong fix; this scopes the exception to the
17+
# fuzzing container only.
18+
pip3 install --ignore-requires-python .
19+
20+
# -maxdepth 1: the Dockerfile COPYs the harness to $SRC/ directly, but
21+
# `COPY . $SRC/mcpg` also copies the whole repo (including
22+
# .clusterfuzzlite/sql_safety_fuzzer.py) underneath it — an unbounded
23+
# find would match both copies of the same file and build the fuzzer
24+
# twice, silently overwriting $OUT/sql_safety_fuzzer the second time
25+
# (both matches share the same basename). Confirmed in a real CI run
26+
# before this fix landed (two full pyinstaller passes in one build log).
27+
for fuzzer in $(find "$SRC" -maxdepth 1 -name '*_fuzzer.py'); do
28+
fuzzer_basename=$(basename -s .py "$fuzzer")
29+
fuzzer_package=${fuzzer_basename}.pkg
30+
31+
# Standalone package via pyinstaller, to avoid Python-version/environment
32+
# drift between build time and whenever ClusterFuzzLite replays this
33+
# binary later.
34+
pyinstaller --distpath "$OUT" --onefile --name "$fuzzer_package" "$fuzzer"
35+
36+
# Execution wrapper: Atheris needs the sanitizer runtime preloaded, and
37+
# this is the file ClusterFuzzLite actually invokes as the fuzz target.
38+
# The "LLVMFuzzerTestOneInput" comment isn't decorative — OSS-Fuzz's
39+
# fuzzer-detection step greps wrapper scripts for that literal string
40+
# to recognize them as fuzz targets; omitting it fails the build with
41+
# "No fuzz targets found" even though the binary built fine.
42+
echo "#!/bin/sh
43+
# LLVMFuzzerTestOneInput for fuzzer detection.
44+
this_dir=\$(dirname \"\$0\")
45+
LD_PRELOAD=\$this_dir/sanitizer_with_fuzzer.so \
46+
ASAN_OPTIONS=\$ASAN_OPTIONS:symbolize=1:external_symbolizer_path=\$this_dir/llvm-symbolizer:detect_leaks=0 \
47+
\$this_dir/$fuzzer_package \$@" > "$OUT/$fuzzer_basename"
48+
chmod +x "$OUT/$fuzzer_basename"
49+
done

.clusterfuzzlite/project.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
language: python3
2+
fuzzing_engines:
3+
- libfuzzer
4+
sanitizers:
5+
- address
6+
- undefined
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Atheris fuzz harness for the SQL-safety kernel's parse+validate path.
2+
3+
Feeds arbitrary bytes to ``SafeSqlDriver._validate`` — the ``pglast``-based
4+
parser + AST-walker in ``mcpg.sql.safety`` — and treats anything other than
5+
the documented ``ValueError`` (malformed or disallowed SQL) as a bug: an
6+
unhandled exception, a native crash inside the C-based ``pglast`` parser, or
7+
a hang. This is the project's actual security-critical surface; see
8+
CLAUDE.md's "SQL-safety kernel" section and
9+
docs/reviews/devendor-sql-kernel-security-review.md for the threat model
10+
this complements (that doc covers the adversarial *unit* test suite —
11+
known-shape attacks; this harness covers unknown-shape inputs).
12+
"""
13+
14+
import sys
15+
16+
import atheris
17+
18+
with atheris.instrument_imports():
19+
from mcpg.sql.safety import SafeSqlDriver
20+
21+
# _validate() only reads the class-level ALLOWED_* policy aliases; the
22+
# wrapped driver is never touched during validation, so a real SqlDriver
23+
# is unnecessary here.
24+
_driver = SafeSqlDriver(sql_driver=None) # type: ignore[arg-type]
25+
26+
27+
def test_one_input(data: bytes) -> None:
28+
fdp = atheris.FuzzedDataProvider(data)
29+
query = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes())
30+
try:
31+
_driver._validate(query) # fuzzing the private validator directly
32+
except ValueError:
33+
pass # expected: malformed or policy-disallowed SQL is rejected
34+
35+
36+
atheris.Setup(sys.argv, test_one_input)
37+
atheris.Fuzz()

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
demo_data/*.sql filter=lfs diff=lfs merge=lfs -text
2+
3+
# Scripts that run inside Linux containers (ClusterFuzzLite build, CI) must
4+
# keep LF endings regardless of the checkout platform's core.autocrlf — a
5+
# stray \r in a shebang line breaks the interpreter.
6+
*.sh text eol=lf
7+
.clusterfuzzlite/** text eol=lf

.github/workflows/cflite_batch.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: ClusterFuzzLite batch fuzzing
2+
on:
3+
schedule:
4+
- cron: "0 3 * * *" # Daily at 03:00 UTC.
5+
permissions: read-all
6+
jobs:
7+
BatchFuzzing:
8+
runs-on: ubuntu-latest
9+
strategy:
10+
fail-fast: false
11+
matrix:
12+
sanitizer:
13+
- address
14+
- undefined
15+
steps:
16+
- name: Build Fuzzers (${{ matrix.sanitizer }})
17+
id: build
18+
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
19+
with:
20+
language: python
21+
sanitizer: ${{ matrix.sanitizer }}
22+
- name: Run Fuzzers (${{ matrix.sanitizer }})
23+
id: run
24+
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
25+
with:
26+
github-token: ${{ secrets.GITHUB_TOKEN }}
27+
fuzz-seconds: 3600
28+
mode: "batch"
29+
sanitizer: ${{ matrix.sanitizer }}
30+
output-sarif: true

.github/workflows/cflite_pr.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: ClusterFuzzLite PR fuzzing
2+
on:
3+
pull_request:
4+
paths:
5+
- "src/mcpg/sql/**"
6+
- ".clusterfuzzlite/**"
7+
permissions: read-all
8+
jobs:
9+
PR:
10+
runs-on: ubuntu-latest
11+
concurrency:
12+
group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }}
13+
cancel-in-progress: true
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
sanitizer:
18+
- address
19+
- undefined
20+
steps:
21+
- name: Build Fuzzers (${{ matrix.sanitizer }})
22+
id: build
23+
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
24+
with:
25+
language: python
26+
github-token: ${{ secrets.GITHUB_TOKEN }}
27+
sanitizer: ${{ matrix.sanitizer }}
28+
# Without this, build_fuzzers tries to diff against a coverage
29+
# baseline (a `cifuzz-coverage-latest` artifact) to prune
30+
# "unaffected" targets before running — which doesn't exist yet
31+
# on a fresh integration (chicken-and-egg on the very first
32+
# fuzzing PR) and fails the build outright. There's only one
33+
# fuzz target in this repo, so the optimization has no upside
34+
# anyway; always keep it.
35+
keep-unaffected-fuzz-targets: true
36+
- name: Run Fuzzers (${{ matrix.sanitizer }})
37+
id: run
38+
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
39+
with:
40+
github-token: ${{ secrets.GITHUB_TOKEN }}
41+
fuzz-seconds: 300
42+
mode: "code-change"
43+
sanitizer: ${{ matrix.sanitizer }}
44+
output-sarif: true

0 commit comments

Comments
 (0)