Skip to content

security: make the secret-detection gates actually reject - #45

Merged
lmeyerov merged 2 commits into
mainfrom
security/fix-secret-gates
Jul 25, 2026
Merged

lmeyerov merged 2 commits into
mainfrom
security/fix-secret-gates

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

The problem

Both secret-detection gate modes had been unable to fail. They branched on the exit code of detect-secrets scan --baseline <file>, which is an update command — it rewrites the baseline file, prints nothing to stdout, and exits 0 regardless of what it finds. || print_error was unreachable in both CI and pre-commit mode.

Verified against the pre-fix scripts with a planted test secret staged:

pre-commit gate -> ✅ No secrets detected        exit 0
CI gate         -> ✅ Secret detection passed    exit 0

Neither gate had ever been able to reject anything.

Why nothing caught it. Every guard was tested only in the passing direction. The workflow's entire assertion was that the scripts exit 0 on a clean tree — which a gate hardwired to exit 0 satisfies forever. The one test with should_detect="yes" fixtures was never wired in (the step echoed "Skipping pattern tests") and was itself broken: it scanned an absolute /tmp path while cd'ed to the repo root, so every scan returned empty and all five "unsafe" fixtures silently looked undetected.

Placeholder cleanup

A few docs, docstrings and test fixtures carried credential-shaped example values against a development instance. They are replaced with explicit placeholders (<your-personal-key-id>, <your-org-name>, example.com hosts). The associated development test credentials have been rotated and no longer resolve.

What changed

Gates

  • check_new_secrets.py does the comparison the shell was only assuming.
  • Baseline keyed on (path, hashed_secret) — hash-only matching accepted a value allowlisted in docs/ anywhere, including src/louieai/_client.py.
  • Pre-commit scans the index, not the worktree (git add <secret> then clean/delete used to pass).
  • --no-renames (git mv + edit reported R, dropped by --diff-filter=ACM).
  • NUL-terminate the staged list — joining meant the last staged file was never scanned.
  • Missing baseline fails instead of auto-generating and passing.
  • Concatenated scan documents merged (xargs ARG_MAX previously produced "New secrets detected!" on a clean tree).

Graphistry personal keys — check_credential_literals.py. detect-secrets' entropy plugins miss these (Shannon 3.1–3.8 vs a 4.5 threshold). Matches the shape without requiring quotes, so .ipynb, .env, YAML and Markdown are covered; excludes base64 neighbours so embedded images don't trip it (0 findings across 4 MB of random base64).

Integration fixtures — real_client hardcoded an internal endpoint and discarded the registered Graphistry client. Now resolved from LOUIE_SERVER, HTTPS-required, validated before authenticating. .env is opt-in via LOUIE_TEST_MODE, so a credential-free run stays offline (was 244 s and a hang; now 0.9 s).

Toolchain — CI ignored uv.lock and resolved ruff 0.16 / mypy 2.3 against a lockfile pinning 0.12.5 / 1.17.0. ruff 0.16 also formats Python inside Markdown fences, so it reformats — and echoes — file content the pinned version leaves alone. Now uv sync --frozen, with --frozen on uv run too (it otherwise re-resolves and drops the lockfile's exclude-newer pin).

Review focus

  1. .secrets.baseline — 66 findings across 30 files. Turning the gate on surfaced the debt of a gate that never fired. Each was reviewed individually: test mocks, doc placeholders, and the AWS key in notebook 06, which is public Splunk BOTSv3 demo data. Please spot-check.
  2. scripts/ci/secret-detection.sh — the index materialisation and NUL handling are the subtle parts.
  3. Scope: the toolchain pin is included because CI is red without it on every branch, and because an unpinned formatter can reformat and echo arbitrary file content into logs.

Validation

Lockfile toolchain, repo-wide scope: secret detection, ruff check ., ruff format --check ., mypy . all pass. 565 unit tests, 85.44% coverage vs the 85% gate. Harness 19/0. mkdocs build --strict passes. Credential-free integration run: 29 passed / 25 skipped / 0.9 s.

Two adversarial review passes ran over this diff; their findings are folded in. One of them found a bug in the index-materialisation fix, and the regression test I added for it caught a second (the NUL terminator).

Known / follow-up

  • tests/integration/notebook/test_cursor_new_integration.py fails on pristine main without credentials — pre-existing, fixed by feat: reasoning-aware response API #44, untouched here.
  • uv lock --check exits 1, so uv sync --locked (which would catch lock/pyproject drift) is not yet viable. Regenerate the lock, then switch.
  • Internal hostnames and the org name remain in ~8 other test files. Real but mechanical; deliberately not bundled.
  • feat: reasoning-aware response API #44 will be rebased on top and reduced to reasoning-API changes only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MyiG851jxkDkqB5u9gnpox

lmeyerov added a commit that referenced this pull request Jul 25, 2026
Verifying PR #45's own CI logs showed 72 lines containing credential-shaped
strings. None were real — all four leaked values are absent from the logs,
confirming the toolchain pin closed the original exposure vector — but they
were this suite's own fake fixtures, printed because `pytest -v` composes
parametrize ids from the parameter *values* and `unit-tests` runs with -v.

Harmless for these fakes; the wrong habit for a security suite, since the day
someone parametrizes with a real value it lands in a public log.

Give every parametrize an explicit id list naming the case rather than showing
the value, and add a test that runs the suite under -v and asserts the output
contains no credential shapes.
Both secret-detection gate modes were structurally unable to fail. They branched
on the exit code of

    detect-secrets scan --baseline <file>

which is an *update* command: it rewrites the baseline file in place, prints
nothing to stdout, and exits 0 regardless of findings. So `|| print_error` was
unreachable in both CI and pre-commit mode, and a planted secret passed both.

The meta-test that should have caught this was disconnected: the workflow step
echoed "Skipping pattern tests" instead of running it, and the harness itself
scanned an absolute /tmp path while cd'ed to the repo root, so every scan
returned empty and all five "unsafe" fixtures silently looked undetected.

Root cause of the class: every guard was tested only in the passing direction.
"Script exits 0 on a clean tree" is satisfied forever by a gate hardwired to
exit 0. Nothing planted a secret and asserted rejection.

Gates
- Replace the exit-code assumption with scripts/ci/check_new_secrets.py, which
  scans without --baseline and compares findings to the baseline explicitly.
- Key the baseline on (path, hashed_secret), matching detect-secrets' own
  semantics. Hash-only matching accepted a value allowlisted in one file
  anywhere else in the tree.
- Pre-commit now materialises the git index and scans that. Previously it read
  the worktree, so `git add <secret>` followed by cleaning or deleting the file
  passed the hook.
- Add --no-renames: `git mv` plus an edit reports R, which --diff-filter=ACM
  drops.
- NUL-terminate the staged file list. Joining with NUL instead of terminating
  meant `read -d ''` never emitted the last path, so one staged file per commit
  was never scanned.
- A missing baseline now fails instead of auto-generating and passing, which
  would otherwise whitelist whatever was in the tree.
- Merge concatenated scan documents; xargs splits on ARG_MAX, and the resulting
  "Extra data: line N" surfaced as "New secrets detected!" on a clean tree.

Graphistry personal keys
- Add scripts/ci/check_credential_literals.py. detect-secrets' entropy plugins
  do not reach this key format (Shannon ~3.1-3.8, below the 4.5 threshold).
  Matches the key shape without requiring quotes, so .ipynb, .env, YAML and
  Markdown are covered, and excludes base64 neighbours to avoid firing inside
  embedded images.

Placeholders
- Replace credential-shaped example values in docs, docstrings and test
  fixtures with explicit placeholders, and neutralise development hostnames and
  org names to example.com equivalents. The associated development test
  credentials have been rotated and no longer resolve.

Integration fixtures
- real_client resolved its endpoint from a hardcoded internal URL and discarded
  the registered Graphistry client. Resolve from LOUIE_SERVER, require HTTPS
  except for localhost, and validate the endpoint before authenticating so a
  token is never sent somewhere unintended.
- .env is now opt-in via LOUIE_TEST_MODE, so a run with no credentials stays
  offline instead of dialling a real service.

Toolchain
- CI installed with `uv pip install -e ".[dev]"`, ignoring uv.lock, so it
  resolved ruff 0.16 / mypy 2.3 against a lockfile pinning 0.12.5 / 1.17.0.
  Beyond the formatting churn, an unpinned formatter can reformat and echo file
  content into build logs. Use `uv sync --frozen` and add --frozen to `uv run`,
  which otherwise re-resolves and drops the lockfile's exclude-newer pin. Align
  .pre-commit-config.yaml to the same versions.

Tests
- 60 tests that plant secrets and assert rejection, covering both gate modes,
  the staged-vs-worktree and rename+edit bypasses, filenames with spaces, the
  last-staged-file case, fail-closed behaviour on a corrupt or missing
  baseline, and that the failure output tells you how to fix it.

Baseline regenerated: 66 findings across 30 files, each reviewed individually
as a mock, doc placeholder, or public demo dataset value.
@lmeyerov
lmeyerov force-pushed the security/fix-secret-gates branch 4 times, most recently from c7fdd53 to 1a6a503 Compare July 25, 2026 21:18
`pytest -v` composes parametrize ids from the parameter *values*, and the
unit-tests job runs with -v, so this suite's own credential-shaped fixtures were
printed into the build log of every run. The fixtures are fakes, so nothing was
disclosed — but it is the wrong habit for a security suite. Every parametrize
now carries an explicit id list naming the case, with a test that runs the suite
under -v and asserts the output contains no credential shapes.

Move the three security test files into tests/unit/security/, matching the
existing tests/unit/notebook/ grouping. Replace hardcoded
`Path(__file__).parents[N]` roots with `tests.utils.repo_root()`, which walks up
to the pyproject.toml: a fixed depth breaks silently on a move, and these tests
copy scripts out of the root, so a wrong root yields a fixture that exercises
nothing while still passing.

Two defects surfaced while verifying the move did not disconnect anything:

- The shell-level gate tests had stopped isolating the Graphistry rule. Their
  fixture value is also flagged by detect-secrets, so once the pre-commit path
  began scanning the index, the detect-secrets half rejected it on its own and
  the tests passed even with the credential gate unwired. Switched to a shape
  detect-secrets ignores; unwiring the gate now fails two tests again.
- The pre-commit path scans a materialised copy of the index from a temp
  directory, and a bare `uv run` there cannot locate the project, dying with
  "Failed to spawn: detect-secrets". Pin --project. CI never saw this because it
  activates the venv and skips the uv fallback; only a developer's hook hits it.

Internal hostnames
- Replace development hostnames in tests and .env.example with RFC 2606 example
  domains and the public endpoints, and align org fixtures on `example-org` —
  the replacement scripts/notebook-utils/clean-notebook-outputs.py already uses.
- Add an `internal-host` rule so they cannot come back. Screening previously
  existed only in an untracked .git/hooks/pre-commit, so no other contributor
  had it and CI never ran it; it also enumerated two specific hosts, letting any
  new subdomain through. The tracked rule is domain-level.
- Specific account and organisation names stay in the local hook by design: this
  repository is public, so a tracked denylist of the private strings we are
  keeping out would publish them. Documented in .secret-patterns.md.

Lockfile
- Declare `exclude-newer` in [tool.uv]. The lockfile already recorded it, but
  nothing declared it — it had been passed as a one-off CLI flag, so every
  `uv lock --check` reported the option as "removed" and demanded a re-resolve.
  That is what made `--locked` unusable. Declaring it fixes the check with zero
  package-version changes.
- CI now uses `uv sync --locked` and `uv run --locked` throughout, so a lockfile
  that has drifted from pyproject.toml fails the build rather than silently
  installing a stale set. `--frozen` installs the pinned set but does not detect
  drift.

Regenerate .secrets.baseline so its line numbers match the tree. The previous
one was generated before later edits, so several entries pointed at blank lines
and type annotations — the gate still worked (matching is by path and hash) but
the baseline could not be read or audited. The refreshed file is a strict
subset: 65 findings, none newly accepted.
@lmeyerov
lmeyerov force-pushed the security/fix-secret-gates branch from 1a6a503 to 4b1c898 Compare July 25, 2026 21:25
@lmeyerov
lmeyerov merged commit c7a0849 into main Jul 25, 2026
20 checks passed
@lmeyerov
lmeyerov deleted the security/fix-secret-gates branch July 25, 2026 21:40
lmeyerov added a commit that referenced this pull request Jul 26, 2026
Consolidates the two [Unreleased] sections (from #44 and #47) into one version
heading, and adds the security and internal entries for #45, which landed
without a changelog entry.

0.9.0 rather than 0.8.2: the release carries a breaking change to Response.text,
the most-used accessor.
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.

1 participant