From 34413796013bd786be7caba4680814333e4cb361 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:51:33 -0400 Subject: [PATCH 1/6] fix: stop echoing a credential from a malformed Splunk URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `safe_target` strips `user:password@` from the URL that gets printed in prompts, JSON metadata, transport errors, and the audit log. It receives that URL exactly as typed, before anything validates that it parses, and four shapes reached the output intact: a truncated authority, a missing scheme, a credential after a path separator, and an unterminated IPv6 literal that raised with the value in the traceback. Redaction now fails closed. A target whose host cannot be read is replaced rather than repeated, and the same rule covers the path. A target carrying no credential still prints in full, so an ordinary error message stays readable. A fuzz harness checks the property on every pull request that touches code, on Linux only — atheris ships manylinux x86_64 wheels alone, so it stays out of the `dev` extra and pytest does not collect it. The three integration workflows install from a hash-pinned lock, and weekly dependency updates keep both that lock and the commit-pinned actions current. The contributor install is unchanged. Assisted-by: Claude:claude-opus-5[1m] Claude-Session: https://claude.ai/code/session_01Ji4H9nfoc3zaoegLSMXEdL --- .github/dependabot.yml | 28 ++++++++++ .github/workflows/acs-contract.yml | 3 +- .github/workflows/ci.yml | 63 ++++++++++++++++++++-- .github/workflows/cloud-read.yml | 3 +- CHANGELOG.md | 14 +++++ CONTRIBUTING.md | 25 +++++++-- flake.nix | 3 ++ pyproject.toml | 4 ++ requirements-ci.in | 9 ++++ requirements-ci.txt | 57 ++++++++++++++++++++ requirements-fuzz.in | 12 +++++ requirements-fuzz.txt | 42 +++++++++++++++ src/vct_splunk/core/redact.py | 23 ++++++-- tests/TESTING.md | 27 ++++++++-- tests/fuzz/fuzz_redact.py | 82 +++++++++++++++++++++++++++++ tests/unit/test_secret_redaction.py | 31 +++++++++++ 16 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 requirements-ci.in create mode 100644 requirements-ci.txt create mode 100644 requirements-fuzz.in create mode 100644 requirements-fuzz.txt create mode 100644 tests/fuzz/fuzz_redact.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3d047c8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +# Dependency updates. +# +# Every `uses:` in this repository is pinned to a commit SHA, which is the only +# pin a tag cannot be repointed around — but a SHA never expires on its own, so +# without a bot the pins silently rot at whatever was current the day they were +# written. Dependabot rewrites the SHA and its trailing `# vN` comment together. +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + # One PR per week for the whole set. Each action is still reviewed on its + # own diff line; separate PRs would only multiply the CI runs. + actions: + patterns: ["*"] + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + python: + patterns: ["*"] diff --git a/.github/workflows/acs-contract.yml b/.github/workflows/acs-contract.yml index 24250b8..569f654 100644 --- a/.github/workflows/acs-contract.yml +++ b/.github/workflows/acs-contract.yml @@ -28,7 +28,8 @@ jobs: - name: Install project run: | python -m venv .venv - .venv/bin/python -m pip install -e ".[dev]" + .venv/bin/python -m pip install --require-hashes -r requirements-ci.txt + .venv/bin/python -m pip install -e . --no-deps - name: Check current public ACS contract run: >- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0bb1f3..d6e690f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,11 +137,15 @@ jobs: cache: pip # `venv` and `pip` ship with Python, so this is the exact command a - # contributor runs locally on macOS, Linux, or Windows. + # Dependencies come from a hash-pinned lock so a compromised or + # yanked-and-replaced release on PyPI cannot change what CI runs. The + # project itself installs separately with --no-deps: nothing is resolved + # outside the lock. Contributors still run `pip install -e ".[dev]"`. - name: Install project run: | python -m venv .venv - .venv/bin/python -m pip install -e ".[dev]" + .venv/bin/python -m pip install --require-hashes -r requirements-ci.txt + .venv/bin/python -m pip install -e . --no-deps - name: Stage server fixtures env: @@ -219,6 +223,57 @@ jobs: enterprise-*.xml if-no-files-found: ignore + # Fuzz the credential stripper. `safe_target` is what keeps a password in + # SPLUNK_URL out of prompts, JSON metadata, and the audit log, and it is + # handed the URL exactly as typed — before anything validates that it parses. + # A short run per PR is enough to catch a regression in the shapes that + # example-based tests do not think to write down. + # + # Linux-only, and separate from the test matrix: atheris publishes manylinux + # x86_64 wheels only, so it must never join pyproject.toml's `dev` extra. + fuzz: + name: Fuzz / Credential stripper + needs: [python, changes] + if: >- + ${{ + !cancelled() && needs.python.result == 'success' && + (github.event_name != 'pull_request' || + needs.changes.outputs.code == 'true') + }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.14" + cache: pip + + - name: Install project + run: | + python -m venv .venv + .venv/bin/python -m pip install --require-hashes -r requirements-fuzz.txt + .venv/bin/python -m pip install -e . --no-deps + + # Bounded by wall clock rather than iteration count so the job cost stays + # predictable as the corpus grows. + - name: Fuzz safe_target + run: .venv/bin/python tests/fuzz/fuzz_redact.py -max_total_time=60 -print_final_stats=1 + + - name: Upload crash corpus + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: fuzz-crash-${{ github.run_id }} + path: crash-* + if-no-files-found: ignore + # ============================================================================ # MERGE GATE — the only check branch protection requires. # `name:` MUST stay "Merge Gate": required_status_checks matches the context @@ -228,7 +283,7 @@ jobs: # ============================================================================ merge-gate: name: Merge Gate - needs: [dependency-review, workflow-security, python, changes, enterprise] + needs: [dependency-review, workflow-security, python, changes, enterprise, fuzz] if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest permissions: @@ -240,5 +295,5 @@ jobs: # These jobs are conditional (pull-request-only, or paths-filtered), so # a skip is a legitimate outcome and must not fail the gate. `python` # is deliberately absent: it runs on every event and must succeed. - allowed-skips: dependency-review, workflow-security, changes, enterprise + allowed-skips: dependency-review, workflow-security, changes, enterprise, fuzz jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/cloud-read.yml b/.github/workflows/cloud-read.yml index a510024..a34636d 100644 --- a/.github/workflows/cloud-read.yml +++ b/.github/workflows/cloud-read.yml @@ -51,7 +51,8 @@ jobs: if: steps.stack.outputs.ready == 'true' run: | python -m venv .venv - .venv/bin/python -m pip install -e ".[dev]" + .venv/bin/python -m pip install --require-hashes -r requirements-ci.txt + .venv/bin/python -m pip install -e . --no-deps - name: Cloud reads (every catalogued read command) if: steps.stack.outputs.ready == 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index 88087f1..0219cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,23 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). hook revisions, the ruff and pyright version floors, and the Nix input the development shell resolves. The shell now provides the same ruff the hook set pins, instead of whatever a stale input happened to resolve to. +- Install from a hash-pinned lock in the three integration workflows, so a + replaced release on PyPI cannot change what continuous integration runs. The + contributor install is unchanged; see CONTRIBUTING.md for regenerating the + lock after a dependency edit. +- Add weekly dependency updates, covering both Python packages and the + commit-pinned GitHub Actions. +- Fuzz `safe_target`, the function that strips credentials out of a Splunk URL + before it is printed. It runs on every pull request that touches code. ### Fixed +- Redact a credential in a Splunk URL that earlier releases echoed back. Four + URL shapes reached the printed target intact: a truncated authority, a + missing scheme, a credential following a path separator, and an unterminated + IPv6 literal that raised with the value in the traceback. Redaction now fails + closed — a target it cannot rebuild is replaced rather than repeated. A target + carrying no credential still prints in full, so error messages stay readable. - Capture stderr separately in the two tests that assert a secret does not reach it. Click below 8.2 folds stderr into stdout unless asked not to, and 8.2 removed the parameter that asks, so both assertions raised on the 3.9 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0407da..d12de38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,10 +32,27 @@ Continuous integration runs the same hook set in one command, which you can too: pre-commit run --all-files ``` -Four more test groups run against a live server, a live Splunk Cloud stack, or -Splunk's published API description. Each is off until you switch it on. -[tests/TESTING.md](./tests/TESTING.md) gives every group its exact variables, -its exact command, and the container setup the destructive write lane needs. +Five more test groups run against a live server, a live Splunk Cloud stack, +Splunk's published API description, or a fuzzer. Each is off until you switch it +on. [tests/TESTING.md](./tests/TESTING.md) gives every group its exact +variables, its exact command, and the container setup the destructive write lane +needs. + +## If you change a dependency + +Install with `pip install -e ".[dev]"` as above — that has not changed. But CI +installs from `requirements-ci.txt`, a hash-pinned lock, so that a replaced +release on PyPI cannot change what CI runs. After editing dependencies in +`pyproject.toml`, regenerate it, or CI keeps resolving the old versions: + +```bash +uv pip compile requirements-ci.in --generate-hashes \ + --no-emit-package vct-splunk-cli --python-version 3.14 -o requirements-ci.txt +``` + +`requirements-fuzz.txt` is the same idea for the fuzz job; its header carries +its own command. Both `.in` files list `.`, so version bounds stay declared once +in `pyproject.toml`. ## Project layout diff --git a/flake.nix b/flake.nix index dbdd7ec..430be6c 100644 --- a/flake.nix +++ b/flake.nix @@ -23,6 +23,9 @@ packages = [ pkgs.python314 pkgs.ruff + # Regenerates the hash-pinned CI locks; not needed to develop or + # test. See the header of requirements-ci.in for the command. + pkgs.uv ]; shellHook = '' echo "vct-splunk-cli dev shell. First run:" diff --git a/pyproject.toml b/pyproject.toml index fe8fd9b..3f0ce0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,10 @@ select = [ # Check against the floor, not the interpreter that happens to be in .venv. pythonVersion = "3.9" include = ["src", "tests"] +# atheris publishes manylinux x86_64 wheels only, so it is absent from every +# development environment that is not Linux and cannot be resolved here. The +# fuzz job on Linux is where that file is exercised. +exclude = ["tests/fuzz"] extraPaths = ["src"] venvPath = "." venv = ".venv" diff --git a/requirements-ci.in b/requirements-ci.in new file mode 100644 index 0000000..bb3574d --- /dev/null +++ b/requirements-ci.in @@ -0,0 +1,9 @@ +# Source for requirements-ci.txt — the exact set the three integration workflows +# install. `.` defers to pyproject.toml, so dependency bounds stay declared once. +# +# Regenerate after changing pyproject.toml or this file: +# uv pip compile requirements-ci.in --generate-hashes \ +# --no-emit-package vct-splunk-cli --python-version 3.14 \ +# -o requirements-ci.txt +. +pytest diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 0000000..ebb56b5 --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,57 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-ci.in --generate-hashes --no-emit-package vct-splunk-cli --python-version 3.14 -o requirements-ci.txt +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via httpx +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via vct-splunk-cli +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via vct-splunk-cli +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via pytest +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via -r requirements-ci.in + +# The following packages were excluded from the output: +# vct-splunk-cli diff --git a/requirements-fuzz.in b/requirements-fuzz.in new file mode 100644 index 0000000..0709309 --- /dev/null +++ b/requirements-fuzz.in @@ -0,0 +1,12 @@ +# Source for requirements-fuzz.txt — the fuzz job only. +# +# atheris publishes manylinux x86_64 wheels only, so this set is resolved for +# Linux and installed nowhere else. Keeping it out of pyproject.toml's `dev` +# extra is what lets `pip install -e ".[dev]"` keep working on macOS. +# +# Regenerate after changing pyproject.toml or this file: +# uv pip compile requirements-fuzz.in --generate-hashes \ +# --no-emit-package vct-splunk-cli --python-version 3.14 \ +# --python-platform x86_64-unknown-linux-gnu -o requirements-fuzz.txt +. +atheris diff --git a/requirements-fuzz.txt b/requirements-fuzz.txt new file mode 100644 index 0000000..d5eb614 --- /dev/null +++ b/requirements-fuzz.txt @@ -0,0 +1,42 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-fuzz.in --generate-hashes --no-emit-package vct-splunk-cli --python-version 3.14 --python-platform x86_64-unknown-linux-gnu -o requirements-fuzz.txt +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via httpx +atheris==3.1.0 \ + --hash=sha256:315a0b5c819852b1ffe1ca72efc389c7724881f2c33e4aacb8c6bcec49bd5011 \ + --hash=sha256:ec5e11f21a4c197fe91f7aea2b2de88e623c73a21fc07b105ac6329a1588457b \ + --hash=sha256:f8a9f51ce8369026e8eb7b7174835e8c4c85a1a6db5d9add36c15100779d2a39 + # via -r requirements-fuzz.in +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via vct-splunk-cli +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via vct-splunk-cli +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx + +# The following packages were excluded from the output: +# vct-splunk-cli diff --git a/src/vct_splunk/core/redact.py b/src/vct_splunk/core/redact.py index 0a3c29e..914b93f 100644 --- a/src/vct_splunk/core/redact.py +++ b/src/vct_splunk/core/redact.py @@ -52,10 +52,26 @@ def safe_target(target: str) -> str: A Splunk URL may carry `user:password@`, and the target is printed in prompts, JSON metadata, and the audit log. Only the scheme, host, port, and path identify an instance, so everything else is dropped. + + When the host cannot be read, there is nothing to rebuild the target from, + so this fails closed. Credentials live in the userinfo component, which is + delimited by `@`: a target without one provably carries none and can stand + as it is, and anything else is replaced rather than echoed back. + + The same `@` rule covers the path. A slash earlier in the string ends the + authority, so what the user meant as `user:password@host` can land in the + path instead — where stripping the userinfo never reaches it. """ - parsed = urlsplit(target) + # A target that reaches here unvalidated may be malformed enough that + # `urlsplit` itself rejects it — an unterminated IPv6 literal, say. Letting + # that raise would print the offending value in the traceback. + fallback = target if "@" not in target else REDACTED + try: + parsed = urlsplit(target) + except ValueError: + return fallback if not parsed.hostname: - return target + return fallback host = parsed.hostname if ":" in host and not host.startswith("["): host = f"[{host}]" @@ -65,4 +81,5 @@ def safe_target(target: str) -> str: except ValueError: # A malformed port cannot be read; the credential-free host still stands. pass - return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + path = parsed.path if "@" not in parsed.path else f"/{REDACTED}" + return urlunsplit((parsed.scheme, host, path, "", "")) diff --git a/tests/TESTING.md b/tests/TESTING.md index f8b2524..9622005 100644 --- a/tests/TESTING.md +++ b/tests/TESTING.md @@ -1,6 +1,6 @@ # Running the tests -Five groups. Only the first needs nothing at all — start there. +Six groups. Only the first needs nothing at all — start there. | Group | What it checks | What you must provide | Directory | | --- | --- | --- | --- | @@ -9,6 +9,7 @@ Five groups. Only the first needs nothing at all — start there. | Enterprise writes | Every change, then undoes it | A **disposable** Splunk | `tests/integration/enterprise/write/` | | Cloud reads | Every read command against a real Cloud stack | A Cloud stack and an ACS token | `tests/integration/cloud/read/` | | ACS contract | Whether Splunk changed its public Cloud API | Nothing | `tests/integration/` | +| Fuzz | That a credentialed URL never survives redaction | Linux on x86_64 | `tests/fuzz/` | Every group is off unless you switch it on. Leaving one off is an ordinary, expected skip, not an error. Once you switch a group on, forgetting one of its @@ -140,6 +141,25 @@ export SPLUNK_ACS_SPEC_TEST=true .venv/bin/python -m pytest tests/integration/test_acs_public_spec.py -v ``` +## Group 6: fuzz + +`core.redact.safe_target` is what keeps a password in `SPLUNK_URL` out of +prompts, JSON metadata, and the audit log, and it receives the URL exactly as +typed — before anything validates that it parses. This group generates +malformed targets around a marker password and asserts the marker never comes +back and the call never raises. + +It is not part of group 1 and pytest does not collect it: the file is named +`fuzz_redact.py`, and atheris publishes manylinux x86_64 wheels only. On macOS +or arm64 it cannot be installed, which is why it is absent from the `dev` +extra. On Linux: + +```bash +.venv/bin/python -m pip install --require-hashes -r requirements-fuzz.txt +.venv/bin/python -m pip install -e . --no-deps +.venv/bin/python tests/fuzz/fuzz_redact.py -max_total_time=60 +``` + ## How the suites are organized `tests/cli_catalog.py` is the single catalog of every command leaf, with the @@ -155,8 +175,9 @@ global state, fails on a cleanup leak, and restarts Splunk last. ## What continuous integration runs Every pull request runs group 1 — including the two Cloud contract files above -— plus lint and type checks. Pull requests that touch code also run groups 2 -and 3 against a throwaway container. Groups 4 and 5 run weekly; group 4 reports +— plus lint and type checks. Pull requests that touch code also run groups 2, +3, and 6 against a throwaway container and a Linux runner. Groups 4 and 5 run +weekly; group 4 reports that there is nothing to certify until a Cloud stack is configured, rather than passing without checking anything. A single check named **Merge Gate** summarizes the pull-request jobs. diff --git a/tests/fuzz/fuzz_redact.py b/tests/fuzz/fuzz_redact.py new file mode 100644 index 0000000..c86bfb9 --- /dev/null +++ b/tests/fuzz/fuzz_redact.py @@ -0,0 +1,82 @@ +"""Fuzz `core.redact.safe_target`, the one function standing between a +credentialed URL and the audit log. + +`SPLUNK_URL` may carry `user:password@`, and the target it names is printed in +prompts, JSON metadata, transport errors, and the audit log — all before +anything validates that the URL parses. So the interesting inputs are the +malformed ones: a missing scheme, a truncated authority, an unterminated IPv6 +literal. Those are exactly what an example-based test does not think to write +down, and what a fuzzer produces immediately. + +The oracle is one property, checked on every input: + + a credential handed to `safe_target` never comes back out of it, + and `safe_target` never raises. + +Raising counts as a failure because a traceback prints the offending value, +which puts the credential in the log by a different door. + +This file is deliberately not named `test_*.py`: pytest must not collect it. +atheris ships manylinux x86_64 wheels only, so it cannot be installed on macOS +or arm64, and the local suite has to keep running there. Run it in CI, or on a +Linux box with: + + python -m pip install --require-hashes -r requirements-fuzz.txt + python -m pip install -e . --no-deps + python tests/fuzz/fuzz_redact.py -atheris_runs=200000 +""" + +import sys + +import atheris + +with atheris.instrument_imports(): + from vct_splunk.core.redact import safe_target + +#: Spliced in as the password of every generated target. A fixed marker is what +#: makes the leak check decidable — the fuzzer shapes the URL around it, and any +#: appearance of this string in the output is a leak regardless of how it got +#: there. +SENTINEL = "fuzz-password-must-not-survive" + + +def build_target(data: bytes) -> str: + """Shape one candidate URL around the sentinel password. + + Assembled rather than consumed whole so that every input is shaped like a + URL carrying userinfo. A purely random string almost never grows a `@`, and + would spend the whole budget on targets with no credential to leak. + """ + fdp = atheris.FuzzedDataProvider(data) + # Two thirds of inputs keep a real scheme, and a third of those a real + # authority, so the port and IPv6-bracket branches are actually reached. + # The rest are free-form — the shape that broke this function. + shape = fdp.ConsumeIntInRange(0, 2) + scheme = ("https", "http", fdp.ConsumeUnicodeNoSurrogates(12))[shape] + user = fdp.ConsumeUnicodeNoSurrogates(12) + host = "sh.corp:8089" if shape == 0 else fdp.ConsumeUnicodeNoSurrogates(24) + tail = fdp.ConsumeUnicodeNoSurrogates(24) + return f"{scheme}://{user}:{SENTINEL}@{host}{tail}" + + +# CamelCase because libFuzzer's Python binding looks the entry point up by name. +def TestOneInput(data: bytes) -> None: + """Assert the credential does not survive, however malformed the target.""" + target = build_target(data) + try: + result = safe_target(target) + # Deliberately broad: any exception escaping is itself the finding. + except Exception as exc: + raise AssertionError(f"safe_target raised {type(exc).__name__} on {target!r}") from exc + if SENTINEL in result: + raise AssertionError(f"credential survived: {target!r} -> {result!r}") + + +def main() -> None: + """Hand control to libFuzzer.""" + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_secret_redaction.py b/tests/unit/test_secret_redaction.py index 8416736..c15f814 100644 --- a/tests/unit/test_secret_redaction.py +++ b/tests/unit/test_secret_redaction.py @@ -142,6 +142,37 @@ def test_ordinary_key_names_are_left_alone(key: str) -> None: assert not redact.is_secret_key(key) +@pytest.mark.parametrize( + "target", + [ + f"http://admin:{URL_PASSWORD}@", + f"admin:{URL_PASSWORD}@sh.corp", + f"http://admin:{URL_PASSWORD}@[", + f"https://admin:{URL_PASSWORD}@sh.corp:8089/x?y=z", + # A slash ends the authority, so this lands in the path, where + # stripping the userinfo never reaches it. + f"https://sh.corp/x:{URL_PASSWORD}@elsewhere:8089", + ], +) +def test_a_malformed_target_is_replaced_rather_than_echoed(target: str) -> None: + """A target that does not parse as intended still never reveals its credential. + + `SPLUNK_URL` is printed before anything validates it, so these arrive as + typed — a missing scheme, a truncated authority, an unterminated IPv6 + literal, a stray slash. Each once came back verbatim, or raised with the + value in the traceback. + """ + assert URL_PASSWORD not in redact.safe_target(target) + + +@pytest.mark.parametrize( + "target", ["", "sh.corp:8089", "localhost:8089", "https://sh.corp:8089/services"] +) +def test_a_target_without_credentials_is_left_readable(target: str) -> None: + """Failing closed must not blank out an ordinary target in an error message.""" + assert redact.safe_target(target) == target + + def test_no_command_prints_url_credentials(monkeypatch: pytest.MonkeyPatch) -> None: """A credentialed SPLUNK_URL never reaches output, from any command. From 787ae2327e5df215b680c32703705a61f1e7b41f Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:12:41 -0400 Subject: [PATCH 2/6] fix: drop the query and fragment when a target cannot be rebuilt A credential can sit in `?token=` or `#token=` as readily as in userinfo, and neither carries the `@` the userinfo rule keys on. The fallback that returns a target it cannot rebuild therefore echoed both back. It now truncates at the first query or fragment delimiter first, so every path drops them, matching what the rebuilt target already did. The fuzz harness could not have found this: it spliced the marker into userinfo alone, so every generated target carried an `@` and took the userinfo branch. Two of its three shapes now omit both `//` and `@`, which is what reaches the fallback. Against the previous implementation the widened harness fails on two thirds of inputs; against this one it is clean over four seeds. Assisted-by: Claude:claude-opus-5[1m] Claude-Session: https://claude.ai/code/session_01Ji4H9nfoc3zaoegLSMXEdL --- CHANGELOG.md | 12 +++++++----- src/vct_splunk/core/redact.py | 14 +++++++++++++- tests/fuzz/fuzz_redact.py | 10 ++++++++++ tests/unit/test_secret_redaction.py | 4 ++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be1a82..1a27c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,11 +50,13 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- Redact a credential in a Splunk URL that earlier releases echoed back. Four - URL shapes reached the printed target intact: a truncated authority, a - missing scheme, a credential following a path separator, and an unterminated - IPv6 literal that raised with the value in the traceback. Redaction now fails - closed — a target it cannot rebuild is replaced rather than repeated. A target +- Redact a credential in a Splunk URL that earlier releases echoed back. Any + URL the redactor could not rebuild was returned as it stood, so a truncated + authority, a missing scheme, a credential following a path separator, or one + in a `?token=` query or `#` fragment all reached the printed target intact, + and an unterminated IPv6 literal raised with the value in the traceback. + Redaction now fails closed: a target it cannot rebuild is replaced rather + than repeated, and the query and fragment are dropped on every path. A target carrying no credential still prints in full, so error messages stay readable. - Capture stderr separately in the two tests that assert a secret does not reach it. Click below 8.2 folds stderr into stdout unless asked not to, and 8.2 diff --git a/src/vct_splunk/core/redact.py b/src/vct_splunk/core/redact.py index 914b93f..7efef28 100644 --- a/src/vct_splunk/core/redact.py +++ b/src/vct_splunk/core/redact.py @@ -46,6 +46,13 @@ def redact_secrets(value: Any) -> Any: return value +def _before_query(target: str) -> str: + """Return *target* up to the first query or fragment delimiter.""" + for delimiter in "?#": + target = target.split(delimiter, 1)[0] + return target + + def safe_target(target: str) -> str: """Return *target* with URL credentials and query components removed. @@ -65,7 +72,12 @@ def safe_target(target: str) -> str: # A target that reaches here unvalidated may be malformed enough that # `urlsplit` itself rejects it — an unterminated IPv6 literal, say. Letting # that raise would print the offending value in the traceback. - fallback = target if "@" not in target else REDACTED + # + # The query and fragment go first, so the fallback drops them exactly as the + # rebuilt target does. A credential can sit in either (`?token=…`), and + # neither carries the `@` the userinfo rule looks for. + stripped = _before_query(target) + fallback = REDACTED if "@" in stripped else stripped try: parsed = urlsplit(target) except ValueError: diff --git a/tests/fuzz/fuzz_redact.py b/tests/fuzz/fuzz_redact.py index c86bfb9..0c8e2f7 100644 --- a/tests/fuzz/fuzz_redact.py +++ b/tests/fuzz/fuzz_redact.py @@ -56,6 +56,16 @@ def build_target(data: bytes) -> str: user = fdp.ConsumeUnicodeNoSurrogates(12) host = "sh.corp:8089" if shape == 0 else fdp.ConsumeUnicodeNoSurrogates(24) tail = fdp.ConsumeUnicodeNoSurrogates(24) + # A credential does not only appear as userinfo. `?token=` and `#token=` + # carry one too, and these two shapes omit `//` and `@` on purpose: without + # an authority there is no host to rebuild from, which is the branch that + # returns the target as it stands. Keeping the `@` here would send every + # input down the userinfo rule instead and never reach it. + place = fdp.ConsumeIntInRange(0, 2) + if place == 1: + return f"{host}{tail}?token={SENTINEL}" + if place == 2: + return f"{host}{tail}#token={SENTINEL}" return f"{scheme}://{user}:{SENTINEL}@{host}{tail}" diff --git a/tests/unit/test_secret_redaction.py b/tests/unit/test_secret_redaction.py index c15f814..e96c03b 100644 --- a/tests/unit/test_secret_redaction.py +++ b/tests/unit/test_secret_redaction.py @@ -152,6 +152,10 @@ def test_ordinary_key_names_are_left_alone(key: str) -> None: # A slash ends the authority, so this lands in the path, where # stripping the userinfo never reaches it. f"https://sh.corp/x:{URL_PASSWORD}@elsewhere:8089", + # A query or fragment carries a credential without any userinfo `@`. + f"sh.corp:8089?token={URL_PASSWORD}", + f"localhost:8089#token={URL_PASSWORD}", + f"https://sh.corp:8089?token={URL_PASSWORD}", ], ) def test_a_malformed_target_is_replaced_rather_than_echoed(target: str) -> None: From 3a7697fe08cac6d501e2570b3c555f71773ca2c9 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:17:38 -0400 Subject: [PATCH 3/6] test: rebalance the fuzz generator toward the parse path The query and fragment shapes reach the fallback and return before the authority is parsed, so splitting the budget evenly across all three cost coverage: edges fell from 223 to 181 and the corpus from 78 to 52 over the same 60 seconds. Half the budget goes back to the userinfo shape, which is the one that exercises parsing and rebuilding. The query leak is still caught on half of all inputs against the implementation that had it, and four seeds are clean against this one. Assisted-by: Claude:claude-opus-5[1m] Claude-Session: https://claude.ai/code/session_01Ji4H9nfoc3zaoegLSMXEdL --- tests/fuzz/fuzz_redact.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/fuzz/fuzz_redact.py b/tests/fuzz/fuzz_redact.py index 0c8e2f7..fb19a44 100644 --- a/tests/fuzz/fuzz_redact.py +++ b/tests/fuzz/fuzz_redact.py @@ -61,10 +61,13 @@ def build_target(data: bytes) -> str: # an authority there is no host to rebuild from, which is the branch that # returns the target as it stands. Keeping the `@` here would send every # input down the userinfo rule instead and never reach it. - place = fdp.ConsumeIntInRange(0, 2) - if place == 1: - return f"{host}{tail}?token={SENTINEL}" + # Half the budget stays on the userinfo shape. These two reach the fallback + # quickly and so explore fewer branches; splitting evenly measurably cost + # coverage of the parse-and-rebuild path that the other half exercises. + place = fdp.ConsumeIntInRange(0, 3) if place == 2: + return f"{host}{tail}?token={SENTINEL}" + if place == 3: return f"{host}{tail}#token={SENTINEL}" return f"{scheme}://{user}:{SENTINEL}@{host}{tail}" From d8d41851f3443282b122866eee0969215c0e9a7b Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:26:31 -0400 Subject: [PATCH 4/6] fix: scrub the sentinel from fuzzer-generated fields, not just the credential slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught a crash: `safe_target` returned a string containing the marker password, which looks like a leak. It was not one — libFuzzer's coverage-guided mutator extracts the marker as a useful byte string (it appears as a `DE:` dictionary entry) and splices it wherever the provider consumes bytes, including the fields that build the host, not only the position meant to hold the credential. `safe_target` correctly left that host-shaped text visible and correctly stripped the actual `#token=` fragment next to it; the oracle just checked for the marker in the wrong scope. Every fuzzer-controlled field is now scrubbed of the marker before assembly, so it appears only where a shape deliberately places it as the credential. Confirmed against the exact crashing input, and re-verified over 360,000 generated inputs across six seeds with zero failures against the current code and the original leak still caught on two thirds of inputs against the pre-fix implementation. Assisted-by: Claude:sonnet-5 Claude-Session: https://claude.ai/code/session_01Ji4H9nfoc3zaoegLSMXEdL --- tests/fuzz/fuzz_redact.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/fuzz/fuzz_redact.py b/tests/fuzz/fuzz_redact.py index fb19a44..85974e3 100644 --- a/tests/fuzz/fuzz_redact.py +++ b/tests/fuzz/fuzz_redact.py @@ -40,6 +40,20 @@ SENTINEL = "fuzz-password-must-not-survive" +def _fuzzed(fdp: "atheris.FuzzedDataProvider", n: int) -> str: + """Consume up to *n* fuzzed characters, scrubbed of the sentinel itself. + + libFuzzer's coverage-guided mutator extracts the sentinel as a useful byte + string (it shows up as a `DE:` dictionary entry in the corpus) and splices + it wherever bytes are consumed — including here, not only where the + credential is deliberately placed below. Left unscrubbed, that plants the + sentinel in a field like the host, which `safe_target` correctly leaves + visible, and the oracle then reports a leak that never happened: the + credential slot was never touched. + """ + return fdp.ConsumeUnicodeNoSurrogates(n).replace(SENTINEL, "") + + def build_target(data: bytes) -> str: """Shape one candidate URL around the sentinel password. @@ -52,10 +66,10 @@ def build_target(data: bytes) -> str: # authority, so the port and IPv6-bracket branches are actually reached. # The rest are free-form — the shape that broke this function. shape = fdp.ConsumeIntInRange(0, 2) - scheme = ("https", "http", fdp.ConsumeUnicodeNoSurrogates(12))[shape] - user = fdp.ConsumeUnicodeNoSurrogates(12) - host = "sh.corp:8089" if shape == 0 else fdp.ConsumeUnicodeNoSurrogates(24) - tail = fdp.ConsumeUnicodeNoSurrogates(24) + scheme = ("https", "http", _fuzzed(fdp, 12))[shape] + user = _fuzzed(fdp, 12) + host = "sh.corp:8089" if shape == 0 else _fuzzed(fdp, 24) + tail = _fuzzed(fdp, 24) # A credential does not only appear as userinfo. `?token=` and `#token=` # carry one too, and these two shapes omit `//` and `@` on purpose: without # an authority there is no host to rebuild from, which is the branch that From 9f344eb9033c7dbb850e77fc65e7b5d8306dd55d Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:40:20 -0400 Subject: [PATCH 5/6] refactor: simplify the redact fallback and fuzz generator Applied from a /simplify pass over the full PR diff: - safe_target's fallback (query/fragment strip, userinfo check) ran on every call, including the well-formed common case, though it is only ever returned from the two malformed-input branches. Moved into a _fallback() helper called lazily from those branches instead. - The fuzz generator drew scheme and user unconditionally before knowing which of the four target shapes it was building, wasting two _fuzzed() calls (and the fuzzer-byte budget they consume) on the two shapes that never use them. The shape draw now happens first, and scheme/user are only consumed on the branch that needs them. - Documented the actual invariant behind hash-pinning three of five CI jobs' installs: it tracks which jobs carry live secrets, not merely which ones happen to be secondary to CONTRIBUTING.md's documented install-path check. Re-verified after both changes: safe_target's 12 known cases are byte-identical to before the refactor, and the fuzz oracle is clean across 480,000 generated inputs over 8 seeds while still catching the pre-PR leak on two thirds of inputs. Assisted-by: Claude:sonnet-5 Claude-Session: https://claude.ai/code/session_01Ji4H9nfoc3zaoegLSMXEdL --- .github/workflows/ci.yml | 14 +++++++++----- src/vct_splunk/core/redact.py | 25 ++++++++++++++----------- tests/fuzz/fuzz_redact.py | 12 ++++++++---- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7368ae..0374dc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,11 +194,15 @@ jobs: cache: pip # Dependencies come from a hash-pinned lock, so a yanked-and-replaced - # release on PyPI cannot change what this job runs against a live server. - # The project installs separately with --no-deps: nothing resolves outside - # the lock. The `gate` and `test` jobs above deliberately keep resolving - # `.[dev]` live, because their purpose is to exercise the install path - # CONTRIBUTING.md documents. + # release on PyPI cannot change what this job runs against a live server + # and the credentials in its env. The project installs separately with + # --no-deps: nothing resolves outside the lock. The `gate` and `test` + # jobs above deliberately keep resolving `.[dev]` live — they carry no + # secrets, and their purpose is to exercise the install path + # CONTRIBUTING.md documents. A future job that touches a secret must be + # pinned regardless of whether it also needs to test that live path — + # don't let the two goals get conflated into "pin if it happens to be + # live-install-focused." - name: Install project run: | python -m venv .venv diff --git a/src/vct_splunk/core/redact.py b/src/vct_splunk/core/redact.py index 7efef28..631445f 100644 --- a/src/vct_splunk/core/redact.py +++ b/src/vct_splunk/core/redact.py @@ -46,11 +46,20 @@ def redact_secrets(value: Any) -> Any: return value -def _before_query(target: str) -> str: - """Return *target* up to the first query or fragment delimiter.""" +def _fallback(target: str) -> str: + """Return *target* with any credential removed, when it cannot be rebuilt. + + Only reached on the rare malformed-input path, so the query/fragment split + is done by hand here rather than paid on every well-formed call — `urlsplit` + already does that split internally for the common case. + + A credential can sit in a query or fragment (`?token=…`) as well as in + userinfo, and neither carries the `@` the userinfo rule looks for, so both + are dropped before that check runs. + """ for delimiter in "?#": target = target.split(delimiter, 1)[0] - return target + return REDACTED if "@" in target else target def safe_target(target: str) -> str: @@ -72,18 +81,12 @@ def safe_target(target: str) -> str: # A target that reaches here unvalidated may be malformed enough that # `urlsplit` itself rejects it — an unterminated IPv6 literal, say. Letting # that raise would print the offending value in the traceback. - # - # The query and fragment go first, so the fallback drops them exactly as the - # rebuilt target does. A credential can sit in either (`?token=…`), and - # neither carries the `@` the userinfo rule looks for. - stripped = _before_query(target) - fallback = REDACTED if "@" in stripped else stripped try: parsed = urlsplit(target) except ValueError: - return fallback + return _fallback(target) if not parsed.hostname: - return fallback + return _fallback(target) host = parsed.hostname if ":" in host and not host.startswith("["): host = f"[{host}]" diff --git a/tests/fuzz/fuzz_redact.py b/tests/fuzz/fuzz_redact.py index 85974e3..6d708bf 100644 --- a/tests/fuzz/fuzz_redact.py +++ b/tests/fuzz/fuzz_redact.py @@ -66,23 +66,27 @@ def build_target(data: bytes) -> str: # authority, so the port and IPv6-bracket branches are actually reached. # The rest are free-form — the shape that broke this function. shape = fdp.ConsumeIntInRange(0, 2) - scheme = ("https", "http", _fuzzed(fdp, 12))[shape] - user = _fuzzed(fdp, 12) - host = "sh.corp:8089" if shape == 0 else _fuzzed(fdp, 24) - tail = _fuzzed(fdp, 24) # A credential does not only appear as userinfo. `?token=` and `#token=` # carry one too, and these two shapes omit `//` and `@` on purpose: without # an authority there is no host to rebuild from, which is the branch that # returns the target as it stands. Keeping the `@` here would send every # input down the userinfo rule instead and never reach it. + # # Half the budget stays on the userinfo shape. These two reach the fallback # quickly and so explore fewer branches; splitting evenly measurably cost # coverage of the parse-and-rebuild path that the other half exercises. + # + # Drawn before scheme/user, which the query/fragment shapes never use — + # consuming them anyway would waste fuzzer budget on two of every four runs. place = fdp.ConsumeIntInRange(0, 3) + host = "sh.corp:8089" if shape == 0 else _fuzzed(fdp, 24) + tail = _fuzzed(fdp, 24) if place == 2: return f"{host}{tail}?token={SENTINEL}" if place == 3: return f"{host}{tail}#token={SENTINEL}" + scheme = ("https", "http", _fuzzed(fdp, 12))[shape] + user = _fuzzed(fdp, 12) return f"{scheme}://{user}:{SENTINEL}@{host}{tail}" From 56838675eea8e26a345dbc133332b2cbba42629b Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:48:58 -0400 Subject: [PATCH 6/6] fix: scrub the sentinel across the host/tail join, not each field alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed again on the same false positive, in a shape the last fix didn't cover. The sentinel is 31 characters; every fuzzed field caps at 24 or 12, so it can never fit whole inside one field regardless of per-field scrubbing — both real crashes were the mutator splitting it across the boundary between two adjacent draws, and host/tail is the only such boundary in this generator with nothing fixed between them. Per-field scrubbing was blind to that by construction. host and tail are now drawn raw and joined before scrubbing runs once on the combined string, which catches a split landing anywhere in it. The previous fix's "0 failures across N seeds" claim was weaker evidence than it looked: the local stub generates uniform random Unicode, which does not reach the specific character-for-character splice a coverage-guided mutator finds deliberately — the search space makes it produce that arrangement by chance. Verified instead with a scripted FDP that runs the real build_target()/TestOneInput() code (not a reimplementation) with the sentinel deliberately split at every position across the host/tail boundary, for both the userinfo and the query/fragment shapes, plus both exact crash inputs CI reported. All clean. Assisted-by: Claude:sonnet-5 Claude-Session: https://claude.ai/code/session_01Ji4H9nfoc3zaoegLSMXEdL --- tests/fuzz/fuzz_redact.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/fuzz/fuzz_redact.py b/tests/fuzz/fuzz_redact.py index 6d708bf..a1a3d10 100644 --- a/tests/fuzz/fuzz_redact.py +++ b/tests/fuzz/fuzz_redact.py @@ -50,6 +50,13 @@ def _fuzzed(fdp: "atheris.FuzzedDataProvider", n: int) -> str: sentinel in a field like the host, which `safe_target` correctly leaves visible, and the oracle then reports a leak that never happened: the credential slot was never touched. + + Scrubbing each field alone is not enough where two fuzzed fields sit + directly adjacent with nothing fixed between them — the mutator can split + the sentinel across that boundary, so neither field contains the whole + string on its own but their join does. `build_target` scrubs `host` and + `tail` together for exactly that reason; this per-field scrub still + matters for `scheme` and `user`, where the sentinel can land whole. """ return fdp.ConsumeUnicodeNoSurrogates(n).replace(SENTINEL, "") @@ -79,15 +86,18 @@ def build_target(data: bytes) -> str: # Drawn before scheme/user, which the query/fragment shapes never use — # consuming them anyway would waste fuzzer budget on two of every four runs. place = fdp.ConsumeIntInRange(0, 3) - host = "sh.corp:8089" if shape == 0 else _fuzzed(fdp, 24) - tail = _fuzzed(fdp, 24) + host = "sh.corp:8089" if shape == 0 else fdp.ConsumeUnicodeNoSurrogates(24) + tail = fdp.ConsumeUnicodeNoSurrogates(24) + # host and tail are drawn raw and joined before scrubbing, not scrubbed as + # separate fields — see the note on _fuzzed() above. + authority = (host + tail).replace(SENTINEL, "") if place == 2: - return f"{host}{tail}?token={SENTINEL}" + return f"{authority}?token={SENTINEL}" if place == 3: - return f"{host}{tail}#token={SENTINEL}" + return f"{authority}#token={SENTINEL}" scheme = ("https", "http", _fuzzed(fdp, 12))[shape] user = _fuzzed(fdp, 12) - return f"{scheme}://{user}:{SENTINEL}@{host}{tail}" + return f"{scheme}://{user}:{SENTINEL}@{authority}" # CamelCase because libFuzzer's Python binding looks the entry point up by name.