security: make the secret-detection gates actually reject - #45
Merged
Merged
Conversation
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
force-pushed
the
security/fix-secret-gates
branch
4 times, most recently
from
July 25, 2026 21:18
c7fdd53 to
1a6a503
Compare
`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
force-pushed
the
security/fix-secret-gates
branch
from
July 25, 2026 21:25
1a6a503 to
4b1c898
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_errorwas unreachable in both CI and pre-commit mode.Verified against the pre-fix scripts with a planted test secret staged:
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/tmppath whilecd'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.comhosts). The associated development test credentials have been rotated and no longer resolve.What changed
Gates
check_new_secrets.pydoes the comparison the shell was only assuming.(path, hashed_secret)— hash-only matching accepted a value allowlisted indocs/anywhere, includingsrc/louieai/_client.py.git add <secret>then clean/delete used to pass).--no-renames(git mv+ edit reportedR, dropped by--diff-filter=ACM).xargsARG_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_clienthardcoded an internal endpoint and discarded the registered Graphistry client. Now resolved fromLOUIE_SERVER, HTTPS-required, validated before authenticating..envis opt-in viaLOUIE_TEST_MODE, so a credential-free run stays offline (was 244 s and a hang; now 0.9 s).Toolchain — CI ignored
uv.lockand 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. Nowuv sync --frozen, with--frozenonuv runtoo (it otherwise re-resolves and drops the lockfile'sexclude-newerpin).Review focus
.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.scripts/ci/secret-detection.sh— the index materialisation and NUL handling are the subtle parts.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 --strictpasses. 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.pyfails on pristinemainwithout credentials — pre-existing, fixed by feat: reasoning-aware response API #44, untouched here.uv lock --checkexits 1, souv sync --locked(which would catch lock/pyproject drift) is not yet viable. Regenerate the lock, then switch.🤖 Generated with Claude Code
https://claude.ai/code/session_01MyiG851jxkDkqB5u9gnpox