Skip to content

fix: stop validation errors reflecting submitted secrets (credential/password echo) + auth foundation - #40

Merged
snad1 merged 6 commits into
mainfrom
feat/auth-foundation
Jul 31, 2026
Merged

fix: stop validation errors reflecting submitted secrets (credential/password echo) + auth foundation#40
snad1 merged 6 commits into
mainfrom
feat/auth-foundation

Conversation

@snad1

@snad1 snad1 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

SECURITY FIX (pre-existing, affects v0.1.1). No RequestValidationError handler existed, so validation errors (422) reflected the submitted request body. POST /engagements/{id}/credential has been echoing submitted scanning credentials in 422 responses since that endpoint shipped (da25679, tagged in v0.1.1). This branch adds password fields to that same surface; the planted-secret discipline is what surfaced it.

Impact (CWE-209): the reflected secret is the caller's own input, not stored server-side state — no path for one party to read another's credentials. The harm is where it comes to rest: reverse-proxy and gateway access logs, error trackers, CI job output, browser devtools. Moderate. See KI-010 for the "what this is, and what it is not" distinction.

  • Global exception handler (main.py, 24 lines) strips input, ctx, and url from every validation error across every endpoint. 10 probe cases + credential + inline-spec confirmed clean. msg retained for usability; a future input-interpolating validator would reintroduce an echo — guarded by the planted-secret scan (KI-010), not by trusting message text.
  • Response shape and the CLI's per-field 422 rendering are unchanged, with a test asserting it.
  • KI-010 records the generalizable lesson: a no-leak suite that only drives the happy path tests the paths you thought of. The error path of every secret-bearing endpoint is now in scope.

AUTH FOUNDATION (the branch's original purpose) — argon2id (one-way; not the reversible evidence-crypto layer, boundary documented at the site); tokens stored SHA-256 so a stolen row can't be replayed; roles admin/operator/viewer with require_role/require_capability and a 403 attributed by an isolating control test; append-only auth_event; one-shot admin bootstrap (#39 tracks a marker-vs-admin invariant hardening); provx login / logout / admin create with an on-disk token store and no password flag anywhere in the command tree.

Enforced on /auth/me, /auth/logout, /users only — engagement/scan endpoints UNCHANGED (enforcement is the next PR; STATUS enumerates every still-open route). test_api_engagements.py is untouched and passes unmodified — proof the scan surface didn't move. Four pre-existing test files were extended additively (conftest.py, test_integration_cli.py, test_migrations.py, test_cli_secrets.py).

Password-never-recoverable is MUTATION-PROVEN: two planted leaks in the real login handler each failed the assertion, an anti-vacuity guard confirms the probe leaked first, and a permanent CI control keeps it proven. Zero stubs on the backend auth path.

Gates: ruff, mypy --strict (62), pytest 631 (+11), DCO. Accuracy runs in CI (no scanning path touched). Advisory for v0.1.1 filed alongside.


What & why

Closes #39 (bootstrap invariant hardening, filed from this work). The validation-echo disclosure has no tracking issue: it is pre-existing, found here, and fixed here. The auth foundation is the branch's original purpose; the disclosure fix rides along because "a password is never recoverable" is the branch's headline promise and a 422 that hands it back is a direct failure of it.

Definition of Done

  • Safetypassive (read-only, safe in test/production). No adapter or detection logic changed.
  • Signal quality — (n/a — no findings emitted by this change).
  • Fixture test added — (n/a — no adapter changed).
  • Accuracy gate passes — (n/a — no scanning path touched; make accuracy runs in CI unchanged).
  • Docs / manifest updated — KI-010, STATUS, CHANGELOG, SECURITY.md, QUICKSTART, CLI README.
  • Plan-of-record updateddocs/STATUS.md gains an Authentication section and a validation-echo row.
  • Changelog### Fixed and ### Added entries under ## [Unreleased].
  • Platform security — no secret logged; the validation handler is itself the fix for a secret-echo path; state-changing actions (bootstrap, login, logout, user-create) are audit-logged in auth_event.
  • DCO — every commit signed off.

Safety classification

  • passive (read-only, safe in test/production)
  • intrusive (Active mode only)

How I tested

No lab target is relevant — no scanning path is touched. The auth and validation suites drive the real app on a real SQLite database with zero stubs on the backend auth path. The validation-echo fix is proven by sending bodies containing a planted secret to every secret-bearing endpoint and asserting absence. The password-never-recoverable claim is mutation-proven (two planted leaks in the real login handler each failed the assertion; a permanent CI control keeps it proven). make accuracy runs in CI; locally, Docker is unavailable so it was not run here, and no scanning-path file changed.

Closes #39.

snad1 added 6 commits July 31, 2026 07:56
…, not enforcement)

Builds the authentication machinery and proves it on a deliberately small set of
endpoints. Every /engagements route is untouched and still open; applying the gate
across them is the next change, and docs/STATUS.md lists which routes are which.

The deliverable is that a password and a raw token are one-way and never recoverable.
Passwords are argon2id (argon2-cffi, MIT, PX-FREE clean) at OWASP's 64 MiB/t=3/p=4
baseline, deliberately not built on the reversible evidence-crypto layer next door.
Session tokens are 256 random bits stored as SHA-256 only, so a stolen auth_token row
cannot be replayed as a bearer token. UserRead has no password_hash field at all, so
there is no serializer that could publish it (B-FA-07).

Bootstrap is one-shot and closes permanently: the guard is a fixed-primary-key marker
row rather than a COUNT(admins)==0 check, which loses a concurrent first-run race
safely and survives deletion of the admin it created.

Roles are admin/operator/viewer with the whole policy encoded as a capability table
in app/api/deps.py, plus require_role and require_capability dependencies (B-FA-03).
Enforced on GET /auth/me, POST /auth/logout, and the admin-only /users routes only.

auth_event is append-only and records every action including a failed login, with no
password, digest, or token value in it (PX-SECRETS, PX-EVIDENCE, D-05).

Tests: the no-leak detector searches response bodies, response headers, and log
records through record.__dict__ rather than getMessage(), closing the gap an extra=
payload slips through - and the detector is itself proven to fire on planted leaks.
The 403 is attributed by mounting the same handler behind a no-op dependency. Three
by-hand mutations (leak the hash, no-op require_role, store the raw token) were each
run and each failed the suite. Migration up and down, plus model-vs-migration column
comparison for all four tables (W-03).

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
A first-run path and a session the CLI can keep. New provx_cli/store.py is stdlib
only, so test_cli_no_bypass.py's assertion that httpx lives in exactly two modules
still holds. Tokens land in $XDG_CONFIG_HOME/provx/credentials.json keyed by server
URL, the file created 0600 before it is written rather than chmod'd after - a chmod
that follows the write leaves a window where the token is world-readable on disk.

api.py:_headers() stays the single place a token becomes a header; precedence is
$PROVX_TOKEN then the stored token, so the pre-existing environment path keeps
working and keeps winning, for CI.

No password flag exists on any command (PX-SECRETS), and the guard was widened to
match: test_cli_secrets.py now walks the entire subparser tree rather than just
credential set, with a control asserting the walk actually reaches login and admin
create - otherwise it would pass vacuously. read_secret gained a prompt argument
rather than being copied (Q-11).

--json login output omits the token: that stream is routinely redirected into a file,
and a token there outlives the shell that made it. Logout clears the local token even
when the server refuses.

Proven unstubbed end to end in test_integration_cli.py: admin create, login, an
authenticated call, a refused second bootstrap, and logout invalidating the token.

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
STATUS.md gains an Authentication section whose load-bearing rows are the two lists:
the four enforced endpoints, and every engagement route that is deliberately still
open, enumerated rather than gestured at. The header, the roadmap phase table, the
issues table, and the testing ladder are updated to match.

KNOWN_ISSUES.md: authentication moves off the declared-scaffolding list in the
established 'was on this list until <branch>' form, and gains a deliberate-limits
section for the things a reader would otherwise assume shipped - no failed-login
lockout (A-06), no refresh rotation (A-05), no password denylist (S-12), no
self-service password change or deactivation endpoint. KI-003 gains an update
paragraph: its urgency trigger is not reached by this change, because enforcement
does not cover the route that accepts a scope and there is no non-admin operator yet.
Both stop being true in the next change, which makes it the top item then.

README and QUICKSTART had a prominent 'the API has no authentication' warning. It is
now half wrong, which is worse than wholly wrong, so both say precisely which routes
are gated and which are not, and still say do not expose this server. QUICKSTART
gains the first-run walkthrough; the CLI README documents the token store and the
$PROVX_TOKEN precedence.

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
…redential

FastAPI's default RequestValidationError handler returns Pydantic's error list verbatim,
and a Pydantic error carries the value that failed in `input`. For a `missing` error that
value is the whole request object, so a malformed request to any endpoint accepting a
secret handed the secret straight back:

  POST /auth/login  {"password": "<SECRET>"}               -> 422 input.password
  POST /engagements/{id}/credential  {"value": "<SECRET>"}  -> 422 input.value

api_spec_inline echoed the same way. Three shapes leaked: a sibling field missing, a
non-object body, a list body.

This was live and predates the auth work. No RequestValidationError handler existed on
main either, so the credential case has been reflecting submitted scanning credentials for
as long as the endpoint has existed (PX-SECRETS). What comes back is the caller's own
input, not stored state - validation runs before the handler, so nothing is read from the
database. It still matters: a secret in a response body ends up in proxy logs, error
trackers, and CI output (CWE-209). The auth branch added three more
password-bearing fields to an already-leaking surface; its planted-secret discipline is
what found it.

One handler on the app, not per-router, because every endpoint taking a password,
credential, or token had the same exposure. type/loc/msg survive - they are what makes a
422 actionable and none carries caller input. input and ctx are dropped because both can;
url is a docs link. FastAPI's {"detail": [...]} shape is kept rather than moved to
ErrorResponse: the CLI branches on the envelope first, so changing shape would discard the
per-field message it renders, and a fix that degrades the error UX invites being reverted.

Tests send bodies CONTAINING a planted secret across all five secret-bearing request
models and assert absence through the detector the auth tests already use, with a control
proving that detector fires against the pre-fix shape. The malformed shapes also ride
along in the no-leak walkthrough, so its existing absence assertions cover them.

Also closes the one gap a pre-merge audit found: the password needle had no demonstrated
mutation. A permanent control now mounts a deliberately leaky handler behind the
production 500 handler and asserts the detector refuses it, and two by-hand mutations
against the real login handler (log the password via extra=, echo it in a response header)
each failed the assertion before being reverted.

Residual: msg is retained for usability. No validator interpolates its input into a
message today; the guard against a future one is the planted-secret scan, not the text.

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
KI-010: what leaked, for how long, why the existing credential no-leak test did not catch
it (it drives the happy path, and nobody had asked what a malformed request returns), and
what closed it. Marked RESOLVED with the residual stated.

STATUS leads with the finding rather than with the feature, because a live
password-and-credential disclosure found during a pre-merge audit is the more important
fact about this branch. The mutation tally goes from three to five, and now says which
were run against production code.

CHANGELOG gets a Fixed entry ahead of the Added one.

Also files the bootstrap-guard precision gap as issue #39 (marker row vs 'admin exists' -
a re-bootstrap path that is unreachable today only because /users requires an admin) with
a one-line pointer from the auth deliberate-limits section.

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
The first write-up of KI-010 said the credential endpoint 'returned the stored scanning
credential'. That is wrong, and it overstates the flaw. Validation runs before the route
handler, so nothing is read from the database: what comes back is the caller's own
submitted value, reflected. There is no path here for one party to read another's stored
credentials.

It is still a defect worth fixing and worth an advisory - CWE-209, a secret placed into a
response body, which then comes to rest in proxy access logs, error trackers, CI job
output, and browser devtools. But it is Moderate, not critical, and a security tool that
overstates its own vulnerabilities is not being more careful, it is being less accurate.

Corrected at all five sites plus the fix commit's message: KI-010, STATUS, CHANGELOG, the
handle_validation_error docstring, and the validation-test docstrings. KI-010 gains an
explicit 'what this is, and what it is not' paragraph so the next reader does not have to
re-derive the distinction.

SECURITY.md's supported-versions table still claimed only main was supported 'until the
first tagged release' - two tags exist. It now names v0.1.1, which is the version the
advisory has to point at.

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
@snad1
snad1 merged commit 473047e into main Jul 31, 2026
14 checks passed
snad1 added a commit that referenced this pull request Jul 31, 2026
…password echo) + auth foundation (#40)

Closes #39.

SECURITY FIX (pre-existing, affects v0.1.1): no RequestValidationError handler existed, so 422 responses reflected the submitted request body. POST /engagements/{id}/credential has been echoing submitted scanning credentials since it shipped (da25679, v0.1.1); the auth branch's planted-secret discipline surfaced it. The reflected value is the caller's own input, not stored state (CWE-209). Fixed globally by one handler that strips input/ctx/url, keeping type/loc/msg and the CLI's per-field rendering.

AUTH FOUNDATION: argon2id passwords (one-way, not the reversible evidence-crypto layer), tokens stored SHA-256 only, roles admin/operator/viewer with the policy as a capability table, append-only auth_event, one-shot admin bootstrap, provx login/logout/admin create. Enforced on /auth/me, /auth/logout, /users only; every engagement route deliberately unchanged (next PR). Password-never-recoverable is mutation-proven. Zero stubs on the backend auth path.

See KI-010 and GHSA-58x3-ph7q-vfvf.

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
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.

Bootstrap guard keys on marker row, not 'admin exists' — narrower invariant than intended

1 participant