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 ac73320..0374dc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,12 +193,21 @@ jobs: python-version: "3.14" cache: pip - # `venv` and `pip` ship with Python, so this is the exact command a - # contributor runs locally. + # 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 + # 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 - .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: @@ -276,6 +285,58 @@ 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: [gate, test, changes] + if: >- + ${{ + !cancelled() && needs.gate.result == 'success' && + needs.test.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 @@ -285,7 +346,7 @@ jobs: # ============================================================================ merge-gate: name: Merge Gate - needs: [dependency-review, workflow-security, gate, test, changes, enterprise] + needs: [dependency-review, workflow-security, gate, test, changes, enterprise, fuzz] if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest permissions: @@ -298,5 +359,5 @@ jobs: # a skip is a legitimate outcome and must not fail the gate. `gate` and # `test` are deliberately absent: they run 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 1671065..1a27c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,9 +37,27 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `pre-commit` directly. The install path users are told to use is now the one that is tested on every pull request, and the project depends on no build tool beyond what ships with Python. Workflow-security analysis stays centralized. +- Install from a hash-pinned lock in the three jobs that run against a live + server or Splunk's public API, so a replaced release on PyPI cannot change + what they run. The lint, type, and test jobs still resolve `.[dev]` live, + because their purpose is to exercise the documented install path. 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. 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 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..631445f 100644 --- a/src/vct_splunk/core/redact.py +++ b/src/vct_splunk/core/redact.py @@ -46,16 +46,47 @@ def redact_secrets(value: Any) -> Any: return value +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 REDACTED if "@" in target else target + + def safe_target(target: str) -> str: """Return *target* with URL credentials and query components removed. 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. + try: + parsed = urlsplit(target) + except ValueError: + return _fallback(target) if not parsed.hostname: - return target + return _fallback(target) host = parsed.hostname if ":" in host and not host.startswith("["): host = f"[{host}]" @@ -65,4 +96,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..a1a3d10 --- /dev/null +++ b/tests/fuzz/fuzz_redact.py @@ -0,0 +1,123 @@ +"""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 _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. + + 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, "") + + +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) + # 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 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"{authority}?token={SENTINEL}" + if place == 3: + return f"{authority}#token={SENTINEL}" + scheme = ("https", "http", _fuzzed(fdp, 12))[shape] + user = _fuzzed(fdp, 12) + return f"{scheme}://{user}:{SENTINEL}@{authority}" + + +# 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..e96c03b 100644 --- a/tests/unit/test_secret_redaction.py +++ b/tests/unit/test_secret_redaction.py @@ -142,6 +142,41 @@ 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", + # 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: + """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.