fix(plugins): publish the salt atomically, and omit the hash when there is none
Review finding from @kartik-mem0 on this PR. O_CREAT|O_EXCL then write leaves a window where the salt file exists and is empty. Hooks are short-lived processes firing on every tool call and people run several agent windows, so a concurrent reader lands in that window, reads nothing, and falls back to a digest of the salt file's own path, memoized for its whole run. That path is guessable, so the race silently replaced the privacy control with something an attacker can compute, and hashed the same repository two ways depending on timing. The value is now written to a private temp file, fsynced, and published with os.link, which is atomic and fails if another process already published one. Link rather than replace, so losing the race adopts their salt instead of clobbering it. The temp file is removed either way. The derived fallback is gone rather than fixed. _scoped_digest returns "" when there is no salt and record() omits the property, because an unsalted digest over a git remote or a home-directory path is close to plaintext, and shipping one under a name that says hash is worse than sending nothing. Three tests: the racing reader never sees the name half-written, a second writer adopts the first's salt and leaves no temp file, and an unwritable data directory drops the property instead of emitting a weak one. The old test asserted the fallback behaviour and is replaced. 265 passed, 8 skipped. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
This commit is contained in:
@@ -116,36 +116,49 @@ def _install_salt() -> str:
|
||||
keys off that file, so recording an event would silently suppress the
|
||||
install event.
|
||||
|
||||
On a read-only or full data directory the fallback is derived from the data
|
||||
directory path: stable for the machine rather than random per call, so the
|
||||
failure mode is a weaker salt and not unbounded cardinality in PostHog.
|
||||
Published atomically, and there is deliberately no derived fallback. Creating
|
||||
the file with O_CREAT|O_EXCL and then writing into it leaves a window where
|
||||
the file exists and is empty, and a concurrent hook that reads it in that
|
||||
window gets nothing. Falling back to a digest of the path would hand that
|
||||
process a salt an attacker can compute, memoized for its whole run, which is
|
||||
the privacy control this function exists to provide silently turning itself
|
||||
off under load. The salt is written to a private temp file first and linked
|
||||
into place, so the name either does not exist or already has the full value.
|
||||
|
||||
Returns "" when it genuinely cannot persist. Callers omit the hash entirely
|
||||
rather than emit an unsalted one.
|
||||
"""
|
||||
global _salt_cache
|
||||
if _salt_cache:
|
||||
return _salt_cache
|
||||
|
||||
path = _salt_path()
|
||||
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
handle = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
stream.write(uuid.uuid4().hex)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
stream.write(uuid.uuid4().hex)
|
||||
# Atomic claim: fails if another process already published one.
|
||||
# os.link rather than replace, which would clobber theirs.
|
||||
os.link(temporary, path)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError:
|
||||
# Cannot persist. Stable-per-machine beats random-per-call.
|
||||
_salt_cache = hashlib.sha256(str(path).encode("utf-8")).hexdigest()
|
||||
return _salt_cache
|
||||
|
||||
try:
|
||||
_salt_cache = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
_salt_cache = ""
|
||||
if not _salt_cache:
|
||||
_salt_cache = hashlib.sha256(str(path).encode("utf-8")).hexdigest()
|
||||
return _salt_cache
|
||||
|
||||
|
||||
@@ -158,10 +171,17 @@ def _scoped_digest(value: str, length: int = 16) -> str:
|
||||
privacy control without the salt. Salting per install keeps every
|
||||
within-account join the analytics actually use and gives up only
|
||||
cross-machine joins on the same repository, which nothing computes.
|
||||
|
||||
Returns "" when there is no salt, so record() omits the property. An
|
||||
unsalted digest over this input space is close to plaintext, and emitting one
|
||||
under a name that implies it is hashed is worse than sending nothing.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
return hashlib.sha256(f"{_install_salt()}:{value}".encode("utf-8")).hexdigest()[:length]
|
||||
salt = _install_salt()
|
||||
if not salt:
|
||||
return ""
|
||||
return hashlib.sha256(f"{salt}:{value}".encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
@@ -252,10 +272,17 @@ def record(
|
||||
os=sys.platform,
|
||||
python_version=platform.python_version(),
|
||||
)
|
||||
# Assigned only when the digest is real. _scoped_digest returns "" when
|
||||
# the salt could not be persisted, and an empty property is worse than an
|
||||
# absent one: it survives the None filter below and reads as a value.
|
||||
if repo is not None:
|
||||
properties["repo_hash"] = _scoped_digest(getattr(repo, "identity", ""))
|
||||
repo_hash = _scoped_digest(getattr(repo, "identity", ""))
|
||||
if repo_hash:
|
||||
properties["repo_hash"] = repo_hash
|
||||
if session_id:
|
||||
properties["session_hash"] = _scoped_digest(session_id)
|
||||
session_hash = _scoped_digest(session_id)
|
||||
if session_hash:
|
||||
properties["session_hash"] = session_hash
|
||||
line = json.dumps(
|
||||
{
|
||||
"event": f"{EVENT_PREFIX}.{event}",
|
||||
|
||||
Reference in New Issue
Block a user