security: create secret files private, never chmod them after (#143) - #291
Open
Mr-Neutr0n wants to merge 1 commit into
Open
security: create secret files private, never chmod them after (#143)#291Mr-Neutr0n wants to merge 1 commit into
Mr-Neutr0n wants to merge 1 commit into
Conversation
This was referenced Jul 28, 2026
…yng#143) Both writers in secrets.py created the temp with Path.write_text and only restricted it afterwards: tmp.write_text(json.dumps(store, indent=2)) # umask default -> 0644 _restrict_to_user(tmp, is_dir=False) # chmod 0600, too late os.replace(tmp, self.path) Measured on macOS by sampling the mode at the moment _restrict_to_user is called, i.e. while the file already holds the plaintext: before: temp held the plaintext at 0o644 -> WORLD/GROUP READABLE after: temp held the plaintext at 0o600 -> user-only Every local process could read every API key for the length of the write, as could anything indexing or backing up that directory. Fixed by routing both writers through _atomic_private_write, which uses tempfile.mkstemp: the file is created 0600 and empty, the Windows ACL is applied while it is still empty, and only then is the content written. Two further problems fall out of the same change, both proven by tests that fail on the previous code: The temp name was the fixed <name>.tmp. Pre-creating that path as a symlink redirected the write, so an unrelated file was overwritten with the contents of secrets.json - an arbitrary overwrite that also discloses every key to a location the attacker picked. mkstemp uses O_EXCL and a random suffix, so a pre-existing path is not followed. A write that failed partway left the temp behind. It is now removed on any exception and the previous secrets.json is left intact. Tests: tests/test_secrets_file_mode.py. Three of the seven fail on the previous code (mid-write mode, hostile temp symlink, cleanup after a failed write); the rest pin the final mode and round-tripping.
Mr-Neutr0n
force-pushed
the
security/secrets-created-private
branch
from
July 28, 2026 20:52
3e55774 to
ee94da2
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.
Fixes #143.
Both writers in
secrets.pycreated the temp file at the umask default and only restricted it afterwards:Measured
Sampling the mode at the moment
_restrict_to_useris called — i.e. while the file already holds the plaintext:Every local process could read every stored API key for the duration of the write, as could anything indexing or backing up that directory.
The fix
Both writers now route through
_atomic_private_write, built ontempfile.mkstemp: the file is created 0600 and empty, the Windows ACL is applied while it is still empty, and only then is the content written. The Windows path matters —mkstempgives no mode bits there, so ordering theicaclscall before the write is what makes it equivalent.Two more problems fall out of the same change
Both are proven by tests that fail on the current code:
1. The temp name was predictable. It was a fixed
<name>.tmp. Pre-creating that path as a symlink redirected the write — the test writesvictim.txt, symlinkssecrets.json.tmpat it, and on current code the victim comes back containing:So it's an arbitrary file overwrite and it discloses every key to a location the attacker chose.
mkstempusesO_EXCLplus a random suffix, so a pre-existing path is never followed.2. A failed write left the temp behind. Now removed on any exception, with the previous
secrets.jsonleft intact.Scope
This does not address #287 (secrets being plaintext-at-rest at all). That needs an OS keychain and is a much larger change. This is strictly about not widening the window further than the design already implies.
Tests
tests/test_secrets_file_mode.py— 7 cases. Three fail on the current code: mid-write mode, the hostile temp symlink, and cleanup after a failed write. The others pin the final mode, round-tripping, and no leftover temps. POSIX-mode assertions are skipped on Windows, which uses the ACL path.Full suite: 923 passed. The 14 failures reproduce on a clean checkout —
test_fake_slack.py(dead-port timeouts, #252) andtest_bedrock_provider.py(missingbedrockextra, #284/#285).Reviewed and tested locally; drafted with AI assistance.