Skip to content

fix: scrub credentials from malformed Splunk URLs - #75

Open
JacobPEvans-personal wants to merge 9 commits into
mainfrom
chore/scorecard-hardening
Open

fix: scrub credentials from malformed Splunk URLs#75
JacobPEvans-personal wants to merge 9 commits into
mainfrom
chore/scorecard-hardening

Conversation

@JacobPEvans-personal

@JacobPEvans-personal JacobPEvans-personal commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

A fuzz harness written to satisfy the OpenSSF Scorecard's Fuzzing check uncovered a real credential-leak bug in core.redact.safe_target: URLs with malformed hosts would be returned verbatim, leaking any embedded credentials into logs, JSON output, and audit trails. The fix hardens the function to fail closed on any target it cannot parse and rebuild, and applies the same redaction to the path component. The fuzz harness itself required two rounds of fixes after CI runs caught false positives in its own generator logic—a normal part of fuzzing validation that improved the test's reliability.

Changes

  • core.redact.safe_target: Now replaces any unparseable target rather than echoing it, and strips query and fragment components on every path through the function. A credential reached the output through five distinct shapes: a truncated authority, a missing scheme, a ?token=/#token= query or fragment, an unterminated IPv6 literal that raised with the value in the traceback, and a credential landing in the URL path when a stray / ends the authority.
  • Fuzz harness (tests/fuzz/fuzz_redact.py): Generates malformed targets around a marker credential and asserts the marker never leaks and calls never raise. Runs on every PR touching code; Linux-only (atheris ships manylinux wheels alone). Excluded from the dev extra and pytest collection.
  • Hash-pinned CI dependencies (requirements-ci.in/txt, requirements-fuzz.in/txt): Used by the three integration workflows that touch live servers or secrets (enterprise, acs-contract, cloud-read). Installed with --require-hashes; the project installs separately with --no-deps. The contributor install (pip install -e ".[dev]") remains unchanged, resolving dependencies live per PR ci: install with venv and pip instead of a separate installer #72's design.
  • Dependabot automation (.github/dependabot.yml): Weekly updates for Python packages and commit-pinned GitHub Actions (every uses: here is now pinned to a SHA).

Test Plan

  • All 12 CI checks pass on the final commit, including the fuzz job (libFuzzer, ~3M executions, no crashes).
  • 1024 unit tests pass; 7 are new regression cases covering the leak shapes above.
  • Two Copilot review threads resolved and confirmed.
  • Zero CodeQL alerts.
  • /simplify pass applied to the cumulative diff.

Copilot AI lite review requested due to automatic review settings August 5, 2026 12:52
`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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR strengthens URL credential redaction in vct_splunk.core.redact.safe_target so malformed Splunk targets can’t leak user:password@ in prompts/logs/errors, and adds continuous regression coverage via both example-based tests and a CI-run fuzz harness. It also hardens CI dependency installation by switching integration workflows to hash-pinned lockfiles and adds Dependabot coverage for both Python and commit-pinned GitHub Actions.

Changes:

  • Make safe_target fail closed on malformed/unrebuildable targets (and handle urlsplit failures) to prevent credential echoing.
  • Add regression tests plus a Linux-only atheris fuzz harness, wired into CI as a conditional PR job.
  • Introduce hash-pinned lockfiles for CI installs (--require-hashes + --no-deps) and configure weekly Dependabot updates.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/test_secret_redaction.py Adds regression coverage for malformed targets and ensures credential-free targets remain readable.
tests/TESTING.md Documents the new fuzz test group and updates CI group descriptions.
tests/fuzz/fuzz_redact.py Adds atheris fuzz harness asserting safe_target never leaks the sentinel or raises.
src/vct_splunk/core/redact.py Updates safe_target to fail closed and redact path cases where credentials can land outside userinfo.
requirements-fuzz.txt Adds a hash-pinned lock for the fuzz job dependencies.
requirements-fuzz.in Defines the fuzz lock input set (includes project + atheris).
requirements-ci.txt Adds a hash-pinned lock for CI integration workflow dependencies.
requirements-ci.in Defines the CI lock input set (includes project + pytest).
pyproject.toml Excludes tests/fuzz from pyright due to atheris wheel/platform constraints.
flake.nix Adds uv to the dev shell for regenerating the lockfiles.
CONTRIBUTING.md Documents CI’s lockfile-based install and how to regenerate locks after dependency edits.
CHANGELOG.md Records the redaction fix, fuzz coverage, Dependabot, and lockfile-based CI installs.
.github/workflows/cloud-read.yml Switches workflow install to requirements-ci.txt with --require-hashes + project --no-deps.
.github/workflows/ci.yml Switches enterprise job install to the CI lockfile, adds the fuzz job, and wires it into Merge Gate.
.github/workflows/acs-contract.yml Switches workflow install to requirements-ci.txt with --require-hashes + project --no-deps.
.github/dependabot.yml Adds weekly grouped Dependabot updates for GitHub Actions and pip ecosystems.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/vct_splunk/core/redact.py Outdated
Comment thread .github/workflows/ci.yml Outdated
@JacobPEvans-personal
JacobPEvans-personal force-pushed the chore/scorecard-hardening branch from 5138170 to 3441379 Compare August 5, 2026 12:57
Resolves the ci.yml restructure from #72: the fuzz job and Merge Gate now
reference the local `gate` and `test` jobs that replaced the reusable
Python workflow. The hash-pinned lock stays on the three jobs that run
against a live server or Splunk's public API; `gate` and `test` keep
resolving `.[dev]` live, which is what #72 set out to exercise.

Assisted-by: Claude:claude-opus-5[1m]
Claude-Session: https://claude.ai/code/session_01Ji4H9nfoc3zaoegLSMXEdL
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
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
…edential slot

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
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
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
@JacobPEvans-personal JacobPEvans-personal changed the title fix: stop echoing a credential from a malformed Splunk URL fix: scrub credentials from malformed Splunk URLs Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants