Skip to content

Stop logging the API key, and apply the log level and label settings - #137

Open
zeevmoney wants to merge 39 commits into
per-16676/release-ci-hardeningfrom
per-16680/stop-logging-api-key
Open

zeevmoney wants to merge 39 commits into
per-16676/release-ci-hardeningfrom
per-16680/stop-logging-api-key

Conversation

@zeevmoney

Copy link
Copy Markdown
Member

Linear issues

  • PER-16680: With logging enabled, the SDK wrote its API key to the log, and ignored the level, label and json log settings.

Why

With log={"enable": True}, creating a client (async Permit or permit.sync.Permit) logged json.dumps(config.dict()) at DEBUG. That dump includes token, and loguru's default stderr sink prints DEBUG, so the API key reached the log whatever log.level said. configure_logger() read only log.enable; level, label and json had no effect.

What changed

  • The config dump is gone. Creating a client logs Permit SDK initialized: api_url=..., pdp=... at DEBUG.
  • Every SDK log call goes through a new internal wrapper, permit/utils/sdk_logger.py. It drops records below log.level, replaces with [REDACTED] the API key of every client created in the process (longest key first, and the key without surrounding whitespace too), and puts [label] before the message. It logs with logger.opt(depth=2), so loguru still credits each record to the SDK module that logged it. The SDK adds no loguru sink and never calls logger.remove().
  • read_error_body() redacts the PDP error body it returns. The body goes into both the SDK's error record and the PermitConnectionError raised by check, bulk_check and authorized_users.
  • PermitConfig.token is declared with repr=False, so repr()/str() of a config, and tracebacks that print frame values (loguru's diagnose=True), do not show it.
  • log.enable False calls logger.disable("permit"), as before. True calls logger.enable("permit") only to undo that call from an earlier client.
  • wait_for_sync() yields a copy of the client whose PDP and API clients are rebuilt from the new config, through a _connect() hook the blocking client overrides. It no longer creates a new client, so it does not re-apply log settings.
  • A ruff banned-api rule (TID251) flags loguru.logger anywhere in permit/ except the wrapper and permit/logger.py.
  • The README gains a Logging section. The LoggerConfig docstring, its field descriptions and the configure_logger docstring describe the options as they now behave.

Behaviour changes

This is a bug and security fix inside 3.x, not an API break. It changes what users who enabled logs see.

  • With log.enable True, creating a client no longer logs its config. 3.0.0 and earlier wrote the API key to the log at any configured level whenever log.enable was True. Users who ran that way should rotate those keys and purge those logs.
  • Whatever the settings, the API key of every client in the process, and the same key without surrounding whitespace, is replaced with [REDACTED] in every message the SDK logs.
  • A PermitConnectionError for a non-200 PDP response from check, bulk_check or authorized_users quotes the response body with the key replaced by [REDACTED].
  • repr() and str() of PermitConfig no longer show token. The field, .dict() and .json() are unchanged.
  • log.level now applies when log.enable is True: SDK records below it are dropped before any sink, and the application's own records and sinks are not affected. With the default "info", the SDK's DEBUG records no longer appear: HTTP request/response lines, check/authorized_users/get_user_permissions bodies, API context changes and the init line. Set "debug" to keep them. Accepted names are loguru's built-in levels in any case, the aliases warn and fatal, and levels the application added with logger.level(), matched by their exact name (or in any case if the application named the level in upper case). For an unknown name the SDK logs a warning that names it and uses INFO; before, any string was accepted and ignored.
  • log.label now applies when log.enable is True: SDK messages start with [Permit] by default, or [<label>] . An empty label adds nothing.
  • A client created with log.enable True after one created with logging disabled now logs; before, it stayed silent. The SDK calls logger.enable("permit") only in that case, so a logger.disable() the application made for permit or one of its modules still applies. When the SDK does undo its own disable, loguru also drops any permit.* disable made after that earlier client.
  • The settings are process-wide, as loguru's logger is: the client created last decides whether the SDK logs, and the last one created with log.enable True sets the level and label.
  • wait_for_sync() with proxy_facts_via_pdp yields a copy of the client (same class, own PDP and API clients) instead of a new client. It no longer re-applies log settings or logs the init line.
  • log.log_as_json still has no effect. It is now documented as not applied, with the recipe: logger.remove(), then logger.add(sys.stderr, serialize=True).
  • Unchanged: log.enable False (the default) calls logger.disable("permit"), prints nothing, and does not read level, label or json. An application that calls logger.enable("permit") itself still gets the SDK's records, now without the key.

Known limitations, not changed here:

  • A user name and password written into the api_url or pdp URL are not redacted. The SDK's request lines logged them at DEBUG before this change too.
  • Only PDP error bodies are redacted in exceptions. Errors built from REST API responses are not scrubbed.
  • Two different keys that overlap at different positions in one string can leave a few characters of one of them.
  • Redaction cost per log call grows with the number of distinct keys in the process: 0.6 us at 10 keys and 6.5 us at 100, on a 329-character check() message.

How it was tested

  • tests/test_fix_logging.py: 53 offline tests. pytest_httpserver serves the PDP and REST API. Each test adds and removes its own sinks: a stderr sink in loguru's default format, read through capsys, and a JSON sink, both at DEBUG. An autouse fixture restores the SDK logger and loguru's permit switches. The tests cover:
    • the async and sync clients with level debug, info and unset, json on and off, through DEBUG, WARNING and ERROR records: no sentinel token and each record printed once;
    • a PDP that echoes the key back, in records and in raised errors (all three PDP paths, async and sync);
    • prefix keys, a trailing-space key, and empty or whitespace-only keys;
    • a logged traceback of a failed client creation with diagnose=True;
    • level filtering, unknown and app-added levels, label, and module attribution;
    • logging disabled;
    • the application's own disables surviving;
    • wait_for_sync() keeping log settings and sending X-Wait-Timeout/X-Timeout-Policy;
    • 40 clients adding no sinks.
  • pytest -m "not e2e": 361 passed, 3 skipped on both the pydantic 2 and pydantic 1 lanes, warnings as errors, none raised (base: 305 passed, 3 skipped). tests/test_typing_surface.py: 3 passed on both lanes; the sync stub is unchanged.
  • Mutation checks: each mutation was applied, the tests run, and the code restored. All were caught:
    • config dump restored as at the base: 25 tests fail, including every token test;
    • level check removed: 16 fail;
    • redaction removed: 5 fail;
    • label removed: 6 fail;
    • records credited to the wrapper (depth=0): 4 fail;
    • logger.disable dropped: 11 fail;
    • SDK-owned JSON sink: 9 fail;
    • a sink per client: 1 fails;
    • logger.remove(): 39 fail, 47 errors;
    • also caught: aliases removed, silent fallback on an unknown level, token registered only when enabled, repr=False removed, insertion-order and shortest-first redaction, untrimmed key only, scrub removed from error bodies, always/never logger.enable, the old wait_for_sync construction, a missing _connect, and a direct loguru import (flagged by ruff TID251).
  • pre-commit (ruff ALL, ruff format, strict mypy, typos, uv lock --check) passes. mypy is clean on both lanes. actionlint is clean. zizmor reports no findings.
  • e2e tests need a backend and keys: they were type-checked and collected (21 tests on both lanes) and run in CI.

Owner actions before merge

  • Let CI run the e2e and compatibility lanes.
  • log_as_json stays unapplied and documented for now; what it should do is decided separately.
  • Release notes: state the key exposure in 3.0.0 and earlier, advise affected users to rotate keys and purge logs, and list the behaviour changes above.
  • Update the docs repo (separate PR).

🤖 Generated with Claude Code

zeevmoney and others added 18 commits October 1, 2026 12:50
With log={"enable": True}, Permit() logged its whole config at DEBUG,
token included, and the SDK did not apply log.level, so the API key
reached the log whatever level was set (PER-16680). The line now names
the API and PDP URLs only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK's own log lines no longer hold the key, but some records carry
text from elsewhere: a PDP error body, an aiohttp error, a context the
caller passed to a check. The SDK's records now go through
permit.utils.sdk_logger, which replaces the API key of every client
created in the process with [REDACTED] before handing the record to
loguru, whether or not that client enabled logging (PER-16680).

Records are still attributed to the SDK module that logged them, so
logger.disable("permit") and the application's sinks see them as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LoggerConfig's level and label had no effect (PER-16680): loguru's
default sink printed every SDK record from DEBUG up, unlabelled. The
SDK still adds no sink and leaves the application's sinks and levels
alone. Instead, with log.enable True:

- level drops the SDK's records below it before they reach any sink.
  Names are loguru's, in any case, plus warn and fatal. An unknown
  name raises ValueError when the client is created.
- label is put in square brackets before every SDK message.
- logger.enable("permit") is called, so a client that enables logging
  after one that disabled it is no longer silenced.

log_as_json stays unapplied: loguru serializes per sink, and a JSON
sink of the SDK's own would print every record a second time through
loguru's default sink or the application's.

The settings are process-wide, as loguru's logger is. With log.enable
False nothing changes: the SDK calls logger.disable("permit") and
reads no other setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LoggerConfig's descriptions said each option set something on "the
Permit SDK Logger", which did not exist. They and a new Logging section
in the README now say where the SDK's records go, what enable, level
and label do, that json is not applied and how to get JSON from a
loguru sink, that the settings are process-wide, and that no record
holds the API key (PER-16680).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Offline tests for PER-16680, with loguru sinks added the way an
application adds them: a stderr sink in loguru's default format and a
JSON sink, both at DEBUG. For the async and the blocking client, at
level debug, info and unset, with json on and off, they go through
every kind of record the SDK logs and find neither the API key nor a
[REDACTED] marker. They also check that a key a PDP echoes back is
redacted, that level drops the SDK's records and no others, that label
prefixes every SDK message, that json leaves the format to the sinks,
that a disabled client logs nothing, that the client created last
decides, and that creating 40 clients adds no sink.

Each test starts from a fresh SdkLogger and puts loguru's permit switch
back the way it found it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loguru's level names are case-sensitive, and log.level was looked up
only in upper case, so a level such as logger.level("audit", no=35)
raised ValueError although the error message offers levels added with
logger.level(). Try the name as given first, then the upper-case name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unknown log level now raises ValueError while the client is created.
An application that logs that failure with loguru's logger.exception
(diagnose=True by default) printed the config's repr from the SDK's
frames, and with it the API key. repr=False keeps the token out of
repr() and str() of the config; the field itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keys were kept in a frozenset and replaced in its iteration order,
which depends on the hash seed. When a key that is a prefix of another
was replaced first, the rest of the longer key stayed in the record.
Keep the keys in a tuple sorted longest first, and move the replacement
into SdkLogger.scrub().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key read from a file or an environment variable may end with a space.
aiohttp sends it, and a PDP that echoes the key back returns it trimmed,
so the exact-match replacement missed it. Register the trimmed key as
well, and ignore a key that is only whitespace, which would otherwise
replace every space in the SDK's messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK replaced a key the PDP echoed back in its own error record, then
raised PermitConnectionError with the same body unchanged, so an
application that logged or reported the exception wrote the key. Scrub
the body where it is read, which covers check, bulk_check and
authorized_users, and say in the docs what is and is not replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every client created with log.enable True called logger.enable("permit").
In loguru that also drops every disable the application set for a
module of the package, and overrides an application-wide
logger.disable(""). Now the SDK calls logger.enable("permit") only to
undo a logger.disable("permit") that an earlier client made, which keeps
the fix for an enabled client created after a disabled one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wait_for_sync() created a new client from the config, which ran
configure_logger again on every call. That turned SDK logging back on
after a client created later with logging disabled, and reset the level
and label. It now yields a copy of the client whose PDP and API clients
are rebuilt from the new config, through the _connect() hook the
blocking client overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The documented logger.add(sys.stderr, serialize=True) keeps loguru's
default text sink, so every record was printed twice, once as text and
once as JSON. Say that the application replaces the default sink with
logger.remove() first, in the README, the log_as_json description and
the configure_logger docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API key redaction and the log level and label hold only while every
SDK log call goes through permit.utils.sdk_logger. A ruff banned-api
rule (TID251) now flags loguru.logger anywhere in permit/ except the
wrapper itself and permit/logger.py, which reads loguru's levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* origin/per-16676/release-ci-hardening:
  Expect the cloud PDP's tenant-association role in user permissions
  Test real decisions on the cloud PDP instead of a 501
  Test offline that a PDP's 501 makes the SDK raise
An unknown log.level was ignored before this branch, so raising
ValueError at client creation would break an app on upgrade. The SDK
now logs a warning that names the value and uses INFO. The traceback
test forces a failure inside configure_logger instead, and still checks
that the API key never shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 1, 2026

Copy link
Copy Markdown

PER-16680

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major)

❌ 6 fixable HIGH/CRITICAL advisories -- blocking this build.

Severity Count
‼️ CRITICAL 1
🔴 HIGH 5
🔶 MEDIUM 18
⚪ LOW 5
❔ UNKNOWN 28
Severity Package Installed Fixed in Advisory
‼️ CRITICAL uv 0.12.17 0.13.4 GHSA-hq85-5cpj-mr62
🔴 HIGH aiohttp 3.14.3 3.14.4 CVE-2026-111064
🔴 HIGH aiohttp 3.14.3 3.14.4 CVE-2026-111066
🔴 HIGH aiohttp 3.14.3 3.14.4 GHSA-2g87-pp6c-29x5
🔴 HIGH dnspython 2.8.0 2.9.0 CVE-2026-114605
🔴 HIGH filelock 4.1.0 4.2.1 CVE-2026-112940
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111063
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111065
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111748
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111750
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112906
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112908
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112909
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112910
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112911
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112914
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112915
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112934
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112935
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112937
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112938
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112939
🔶 MEDIUM uv 0.12.17 0.12.18 CVE-2026-104843
🔶 MEDIUM werkzeug 3.1.8 3.1.9 CVE-2026-102598
⚪ LOW aiohttp 3.14.3 3.14.4 CVE-2026-111749
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112907
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112912
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112913
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112941
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111063 (GHSA-494j-54xm-rwp3)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111064 (GHSA-5wr8-gcqf-gqf3)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111065 (GHSA-6cjc-53gc-5rqm)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111066 (GHSA-9xw5-w5jj-pvcg)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111748 (GHSA-f9xw-jh8m-3mwq)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111749 (GHSA-gx76-c99x-2c96)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111750 (GHSA-pqxq-w44r-73xw)
❔ UNKNOWN dnspython 2.8.0 2.9.0 CVE-2026-114605 (GHSA-45c3-73f7-pv4m)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112906 (GHSA-2g95-wg33-c5x2)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112907 (GHSA-567q-5qvw-pf92)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112908 (GHSA-6627-m3xw-93mp)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112909 (GHSA-69hj-f68j-2879)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112910 (GHSA-8mx5-7m5m-r77g)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112911 (GHSA-9c4x-gm8v-wgc4)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112912 (GHSA-9j6q-wvj5-jgjg)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112913 (GHSA-9vhv-jxww-xfm5)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112914 (GHSA-crv4-6mhj-j5jh)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112915 (GHSA-cwqr-hh33-gh44)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112934 (GHSA-fj95-fr6c-h845)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112935 (GHSA-fq56-3cx3-fjxp)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112937 (GHSA-jqc4-5cvw-7w85)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112938 (GHSA-m462-7rxw-wvpg)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112939 (GHSA-rfpc-qcw5-3pc4)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112940 (GHSA-v4x8-3wr5-3wq8)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112941 (GHSA-wfhq-q4j2-c37m)
❔ UNKNOWN uv 0.12.17 0.12.18 PYSEC-2026-4305 (CVE-2026-104843, GHSA-2cv4-cqwr-gwf7)
❔ UNKNOWN uv 0.12.17 0.13.4 PYSEC-2026-4321 (GHSA-hq85-5cpj-mr62)
❔ UNKNOWN werkzeug 3.1.8 3.1.9 PYSEC-2026-4312 (CVE-2026-102598, GHSA-g6x2-hccm-hh4m)
Advisory details

CRITICAL -- GHSA-hq85-5cpj-mr62 (uv 0.12.17)

Found by: trivy:dev-ceiling

uv's bundled CA store trusts an emSign root whose intermediate CA keys are compromised

HIGH -- CVE-2026-111064 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Denial of Service via missing initial request timeout

HIGH -- CVE-2026-111066 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Denial of Service via file descriptor exhaustion in multipart request processing

HIGH -- GHSA-2g87-pp6c-29x5 (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2, trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: Multiple instances of excessive or unbounded memory/CPU use

HIGH -- CVE-2026-114605 (dnspython 2.8.0)

Found by: trivy:dev-ceiling, trivy:runtime-ceiling, trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

dnspython: dnspython: Denial of Service via excessive DNS compression pointer traversal

HIGH -- CVE-2026-112940 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Arbitrary file overwrite via insecure database file reopening

MEDIUM -- CVE-2026-111063 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Information disclosure via unenforced Secure attribute on shared cookies

MEDIUM -- CVE-2026-111065 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Denial of Service via Max-Age processing overflow

MEDIUM -- CVE-2026-111748 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Security control bypass via request scheme spoofing

MEDIUM -- CVE-2026-111750 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: HTTP request smuggling via ambiguous request framing

MEDIUM -- CVE-2026-112906 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Mutual exclusion bypass via improper stale lock removal

MEDIUM -- CVE-2026-112908 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write bit on them can end a live hold

MEDIUM -- CVE-2026-112909 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: arbitrary file truncation via lock path replacement

MEDIUM -- CVE-2026-112910 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: WindowsFileLock ignores mode: a 0o600 lock file and the ReadWriteLock database inherit the directory ACL

MEDIUM -- CVE-2026-112911 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Lock bypass via pre-created shared protocol directory

MEDIUM -- CVE-2026-112914 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized file exposure via symbolic link manipulation in SoftFileLock

MEDIUM -- CVE-2026-112915 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Denial of Service via crafted SQLite lock database

MEDIUM -- CVE-2026-112934 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Denial of Service via insecure holder record refresh

MEDIUM -- CVE-2026-112935 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized concurrent access via stale lock cleanup race condition

MEDIUM -- CVE-2026-112937 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock is a platform-independent file lock for Python. From 4.1.0 un ...

MEDIUM -- CVE-2026-112938 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: StrictSoftFileLock follows a symbolic link swapped in at <path>.filelock and creates claim files in a directory another local user chooses

MEDIUM -- CVE-2026-112939 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized lock acquisition via forged process identifier in SoftFileLock

MEDIUM -- CVE-2026-104843 (uv 0.12.17)

Found by: trivy:dev-ceiling

uv: uv: Arbitrary code execution via malicious package extraction on Windows

MEDIUM -- CVE-2026-102598 (werkzeug 3.1.8)

Found by: trivy:dev-ceiling

werkzeug: werkzeug: Denial of Service via safe_join() Windows special device paths

LOW -- CVE-2026-111749 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Host-Only cookie protection bypass via untrusted subdomain

LOW -- CVE-2026-112907 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: On Windows an oplock on a soft lock's marker holds SoftFileLock.acquire() past its timeout and blocks the stale break

LOW -- CVE-2026-112912 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Information disclosure via symbolic link following in SoftFileLock

LOW -- CVE-2026-112913 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: ReadWriteLock on Windows acts on SQLite side files another local user places beside its database

LOW -- CVE-2026-112941 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized file write via lock directory replacement

UNKNOWN -- CVE-2026-111063 (GHSA-494j-54xm-rwp3) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary The Secure attribute was not being enforced for the shared cookie. ### Impact It may be possible to send a shared cookie marked Secure through an unencrypted request.

UNKNOWN -- CVE-2026-111064 (GHSA-5wr8-gcqf-gqf3) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary The keepalive timeout was not used during the initial portion of a request. ### Impact An attacker could keep an idle connection open indefinitely before sending any data. ### Workaround Use a reverse proxy (as recommended for all production deployments) with sensible keepalive limits.

UNKNOWN -- CVE-2026-111065 (GHSA-6cjc-53gc-5rqm) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary An overflow in Max-Age processing can skip closing a connection. ### Impact An attacker may be able to exhaust the connection pool temporarily, until garbage collection resolves the connections.

UNKNOWN -- CVE-2026-111066 (GHSA-9xw5-w5jj-pvcg) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary An attacker can exhaust file descriptors with specially crafted multipart requests. ### Impact If a handler uses `Request.post()`, it may be possible for an attacker to use an unlimited number of file descriptors, potentially resulting in resource exhaustion.

UNKNOWN -- CVE-2026-111748 (GHSA-f9xw-jh8m-3mwq) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary An attacker sending an absolute-form request can spoof the scheme used. ### Impact If user code depends on the `Request.scheme` attribute for anything security related, an attacker may be able to circumvent this logic, tricking the application into thinking a connection is encrypted when it is not, or vice versa. ### Workaround A WAF rule to reject absolute-form requests (legitimate requests should only use this form for proxies).

UNKNOWN -- CVE-2026-111749 (GHSA-gx76-c99x-2c96) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary A subdomain can remove the Host-Only flag from an existing cookie to a parent domain. ### Impact A user accessing untrusted subdomains could see their Host-Only protections stripped from cookies. Given most usecases won't reflect these cookies to a browser, the impact is limited.

UNKNOWN -- CVE-2026-111750 (GHSA-pqxq-w44r-73xw) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary The pure Python parser is vulnerable to an HTTP request smuggling attack. ### Impact If users are using the Python parser (`AIOHTTP_NO_EXTENSIONS`, or not a standard wheel installed), then an application may be vulnerable to a request smuggling attack. ### Workaround Switch to the default C parser.

UNKNOWN -- CVE-2026-114605 (GHSA-45c3-73f7-pv4m) (dnspython 2.8.0)

Found by: pip-audit:dev-ceiling, pip-audit:runtime-ceiling, pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Impact A maliciously crafted DNS message may cause dns.name.from_wire_parser() to use excessive CPU for DNS name decompression. Applications that process untrusted DNS wire form messages with dnspython may be degraded or DoS'd by the extra CPU use required. ### Patches This problem is fixed in dnspython 2.9.0. ### Workarounds There are no known workarounds. ### Credit Evgenios Gkritsis, Department of Informatics, University of Piraeus & Athena Research Center, Greece Constantinos Patsakis, Department of Informatics, University of Piraeus & Athena Research Center, Greece George Stergiopoulos, Department of Informatics, Athens University of Economics and Business, Greece

UNKNOWN -- CVE-2026-112906 (GHSA-2g95-wg33-c5x2) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftFileLock` and `MarkerSoftFileLock` evict an unparsable marker (the lock file at `<path>`, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. `SoftFileLease` expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone. Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock. Two of the lock user's processes then run the critical se…

UNKNOWN -- CVE-2026-112907 (GHSA-567q-5qvw-pf92) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

On Windows the soft locks (`SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async forms, and a `FileLock` that fell back to a soft lock) read their marker by path. The marker is the lock file whose presence says a process holds the lock. Each `acquire()` attempt that finds a marker opens it with `os.open` to read the holder's process id, and the lock renames and deletes a marker it judges stale, by path. On Windows each of these calls waits for an oplock. An oplock (opportunistic lock) is a request by a process that holds a handle on a file to be told before another process opens, renames or deletes that file; the other process waits until the holder answers the break or closes its handle. Another local account that can open the marker for reading can take a Level 1 oplock on it while no process holds it open, and leave the break unanswered. Every `acquire()` by the user running the lock (below: the lock user) then waits inside the open, past its `timeout`, `blocking=False` and `cancel_check`, and a stale marker under that oplock is not broken. Mutual exclusion holds and the lock touches no other file. Affected versions are filelock 3.22.0 through 4.2.0 on Windows. The…

UNKNOWN -- CVE-2026-112908 (GHSA-6627-m3xw-93mp) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`StrictSoftFileLock` and `AsyncStrictSoftFileLock` apply the caller's `mode` to the sentinel (the lock file at `<path>`) and to both claim files (the files whose presence says a process wants or holds the lock). They create the two directories that hold the claims, `<path>.filelock` and `<path>.filelock/claims`, with a bare `mkdir()`. That gives `0o777` filtered through the umask (a per-process mask that removes permission bits from new files and directories) or, under a parent with a default ACL (an access control list on a directory that sets the permissions of entries created inside it), through that ACL. A hold consists of two claim names in that directory, which has no sticky bit (a directory flag that lets only a file's owner remove it). When the environment leaves another local user (the other user below) a write bit on the directory, that user can unlink the holder's `0600` claims. Three settings do this: umask `000` in a sticky directory such as `/tmp`, umask `002` with the other user in the directory's group, or a default ACL on the lock's parent that grants the group write (the kernel then ignores the umask). After the unlink, the next contender of the holder's account …

UNKNOWN -- CVE-2026-112909 (GHSA-69hj-f68j-2879) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`UnixFileLock`, the backend of `FileLock` and `AsyncFileLock` on Unix, empties whatever regular file it finds at the lock path once it holds the `flock` (a kernel lock on an open file). With an explicit `mode` it also chmods that file (sets its permission bits), whether or not this acquire created it. Another local user who can rename or hard-link one of the files of the user running the lock (below: the lock user) onto the lock path gets that file truncated by the lock user's next `acquire()`. A hard link is a second name for the same file. With a mode such as `0o666` the file, still owned by the lock user, also becomes readable and writable by that other user. The other user can then read and alter everything the lock user writes to it afterwards. `O_NOFOLLOW`, the defence added for the symlink variant (CVE-2025-68146), does not see a renamed file or a hard link. The rename route needs the file's directory to be writable without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The hard-link route into a sticky directory such as `/tmp` needs `fs.protected_hardlinks=0`, a Linux kernel setting. Affected versions are every filelock release up to a…

UNKNOWN -- CVE-2026-112910 (GHSA-8mx5-7m5m-r77g) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

filelock on Windows ignores the `mode` argument. `FileLock(path, mode=0o600)` creates the same file as `FileLock(path)`: one with no security descriptor of its own (the Windows record that says who may access a file). Its access list, or ACL, is whatever the lock directory hands down to new files. filelock creates the `ReadWriteLock` database, which it asks to be `0o600`, the same way. In a directory whose inheritable ACL grants other local accounts access to new files, those accounts can open the lock file or database. They can take the byte-range lock (a lock on a span of bytes in a file) once the owner releases it and keep the owner's later `acquire()` calls waiting or timing out. They can read holder metadata an `on_acquired` hook left in a `preserve_lock_file=True` file. Where the directory hands down modify access, they can write to both files. `lock.mode` still reports `0o600`. The documentation neither promises an ACL on Windows nor says that Windows drops the mode. On Unix the same mode keeps other accounts out. Mutual exclusion between processes that follow the protocol holds on either platform. Affected versions are filelock 3.10.0 through 4.2.0 on Windows. Anthropic's …

UNKNOWN -- CVE-2026-112911 (GHSA-9c4x-gm8v-wgc4) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`StrictSoftFileLock`, `AsyncStrictSoftFileLock`, `SoftReadWriteLock` and `AsyncSoftReadWriteLock` keep their whole ownership state as files in a directory derived from the lock path (`<path>.filelock/claims`, `<path>.rw/{gen,holders}`). When that directory already exists they adopt it after checking only that it is a real directory, not who owns it. Another local user who can create names in the lock's directory can create the tree before the first acquire on that path by the user running the lock (below: the lock user). In `/tmp`, `/var/tmp` or another sticky shared directory any user can do that; the sticky bit is a directory flag that lets only a file's owner remove or rename it. The other user then owns the tree. They can unlink the lock user's claims while the lock user holds the lock, so a second process of the lock user, root included, enters the same critical section and neither process sees an error. Against `SoftReadWriteLock` the same position also admits two writers with `on_compromise` silent. It redirects the lock user's own creates and renames into a directory the other user picks (the scanner replaced a repository's `.git/HEAD` with a filelock record written with t…

UNKNOWN -- CVE-2026-112912 (GHSA-9j6q-wvj5-jgjg) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`raise_on_not_writable_file()` is the check that `SoftFileLock` and the locks sharing its acquire path run before every attempt. It looks at the lock path with `os.lstat`, so that it judges a planted symlink (a file that points at another path) by itself. When the node has no owner-write bit, it then calls `os.access(filename, os.W_OK)`, which follows symlinks. A local user who can create entries in the lock's directory can swap the path between a mode-`0466` file and a symlink to a path of their choosing. The second call then reports on the symlink's target with the permissions of the user running the lock (below: the lock user). The lock user's `acquire()` ends in `PermissionError` within a fraction of a second when the target is missing or not writable by the lock user, and in the ordinary `Timeout` otherwise. That yields one bit per watched acquire. The bit says whether a path exists and is writable by the lock taker. It covers paths under directories the other user cannot search, or any path when root takes the lock. The lock does not open, create or modify anything through the symlink. On Linux, `fs.protected_symlinks` (a kernel setting that refuses to follow another user's …

UNKNOWN -- CVE-2026-112913 (GHSA-9vhv-jxww-xfm5) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

On Windows, `ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) connect SQLite to the lock database by its path, in every directory. SQLite derives three side files from that path: `<database>-journal`, the rollback journal (pages saved to undo an interrupted write); `<database>-wal`, the write-ahead log (changes kept outside the main file); and `<database>-shm`, the shared-memory index of the WAL. SQLite acts on whichever of these it finds beside the database before the lock runs any statement. Another local account that can create files in the database's directory, but not delete files of other accounts there, places such a side file beside the database and denies everyone `DELETE` on it, which a file's creator may do. `C:\Windows\Temp` (the temporary directory of services, granting `BUILTIN\Users` create but not delete) and `C:\Users\Public` are such directories. With a `-wal` in place, every `read_lock()` and `write_lock()` of the user running the lock (below: the lock user) raises `sqlite3.OperationalError: disk I/O error` at once, whatever `timeout` says, until that account removes its file; the lock user cannot. With a `-journal` beside a lock da…

UNKNOWN -- CVE-2026-112914 (GHSA-crv4-6mhj-j5jh) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

When a soft-lock stale break finds that the marker (the lock file at `<path>`) it renamed aside was a live one recreated by a successor, the restore branch of `break_lock_file()` checks the break name with `lstat` (a check that reads a name without following a symbolic link) and then calls `os.link(break_path, lock_file)` by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls. The `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` or `lifetime` break of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links that `fs.protected_hardlinks` (a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes t…

UNKNOWN -- CVE-2026-112915 (GHSA-cwqr-hh33-gh44) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) run `PRAGMA journal_mode=MEMORY` on a fresh connection for every acquire. That statement makes SQLite load the whole schema (the `sqlite_master` table, which lists every table and index with its `CREATE` text) of whatever file sits at the lock path. The caller's `timeout` reaches SQLite only as `busy_timeout`, which bounds waits for another connection's lock, not schema loading. The only content checks reject non-regular files and WAL mode (write-ahead log, an SQLite mode that keeps changes in a side file). A local user who can place or overwrite the file at a `ReadWriteLock`'s database path can therefore make every `acquire_read` / `acquire_write` of every process using that lock ignore its `timeout` and `blocking=False`. That position needs a non-sticky shared directory, or a database owned by the user running the lock (below: the lock user) that other users may write. One 37 KB crafted file stalls each acquire for as long as its author chooses while the process allocates about 1 GB. A second 28 KB layout makes the acquire of a free lock run without end, spinning at 100% CPU and ignoring Ctrl-C. The …

UNKNOWN -- CVE-2026-112934 (GHSA-fj95-fr6c-h845) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) refreshes its holder record (the file under `<path>.rw/holders/` that says a process holds or wants the lock) on every poll of a waiting acquire and on every heartbeat (the periodic refresh a holder runs to keep its record fresh). It does so by reopening `<path>.rw/holders/<token>` by name with a plain blocking `O_WRONLY | O_NOFOLLOW` open and writing at offset 0, without checking what it opened. `O_NOFOLLOW` makes the open fail only when the final name is a symbolic link (a file that redirects to another path). A different local user (the other user below) who owns the lock's `.rw` tree reads the token of the user running the lock (the lock user below) from the `holders/` listing and renames another node over the record. The other user owns the tree because it created it first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), or because it can write a lock directory without the sticky bit. A FIFO (a named pipe: a file whose open for writing blocks until some process opens it for reading) makes the lock user's `write_lock()` / `read_lock()`…

UNKNOWN -- CVE-2026-112935 (GHSA-fq56-3cx3-fjxp) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

The stale-lock break shared by `SoftFileLock`, `MarkerSoftFileLock` and `SoftFileLease` (and their async variants, and a `FileLock` that has fallen back to a soft lock on `ENOSYS`, the "not implemented" error) recognises the marker it judged stale by two values. The marker is the lock file at `<path>`, whose presence says a process holds the lock. The values are its inode number (the kernel's identity for a file, independent of its name) and its modification time (mtime) not being newer. The check runs after the contender has already closed the marker, so nothing keeps the inode allocated. On a filesystem that hands a freed inode number straight to the next file (ext4, xfs, overlayfs on them) the next holder's marker can carry that number. A contender still between its read and its break then renames and unlinks the live marker. Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), forces this at will. It plants a marker with a stale owner record and a modification time in the future and unlinks it as soon as a contender has read it. Two processe…

UNKNOWN -- CVE-2026-112937 (GHSA-jqc4-5cvw-7w85) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) remove their private `.filelock-*` link directories with `shutil.rmtree` (a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable `/proc/self/fd` (a Linux path that names the process's own open files), such as a chroot or container that does not mount `/proc`. Another local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with `/proc` mounted is not affected. Affected versions are fileloc…

UNKNOWN -- CVE-2026-112938 (GHSA-m462-7rxw-wvpg) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`StrictSoftFileLock` and `AsyncStrictSoftFileLock` check `<path>.filelock` (the coordination directory that holds the lock's state) with one `lstat` call on its name. `lstat` reports what a name is without following a symbolic link (a file that redirects to another path). After that check, the code reaches `<path>.filelock/claims` by pathname for every later operation (`mkdir`, scan, publish, link, unlink). `_open_directory` passes `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link), but the flag covers only the final `claims` component. The kernel follows a symbolic link in any earlier component. Another local user (the other user below) who can rename `<path>.filelock` exchanges it for a symbolic link after the check. In a shared directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it), any user with write access can do this. Where the shared directory is sticky, such as `/tmp`, only the user who created `<path>.filelock` before the user running the lock (the lock user below) first used it can. The lock user's process (root's, if root takes the lock) then creates a `claims` directory and claim files (t…

UNKNOWN -- CVE-2026-112939 (GHSA-rfpc-qcw5-3pc4) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async variants and a `FileLock` that has fallen back to a soft lock on `ENOSYS` decide from the record inside the marker file whether its owner is gone. The marker is the file whose presence says a process holds the lock. They do not check who could have written that record. A local user who can write the marker but cannot remove it rewrites the holder's record to name a dead PID (process id). That position arises with an explicit `mode` with group or other write bits, or the default mode under a lax umask (the process setting that strips permission bits from new files), in a sticky or otherwise non-removable directory. The sticky bit is a directory flag that lets only a file's owner remove or rename it. The next contender running as the holder's account, or root, judges the lock stale, removes the live marker on that user's behalf and acquires it. Two processes then run the critical section together and neither gets an error. Affected versions are filelock 3.22.0 through 4.2.0 (finding `ANT-2026-W1H9NXUH`). ## Severity **CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N` scores 4.7 (Medium).** `AV:L` (local): the o…

UNKNOWN -- CVE-2026-112940 (GHSA-v4x8-3wr5-3wq8) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to `/proc/self/fd/<n>`, a Linux path that names the process's own open file `n`. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on `-journal`, `-wal` and `-shm` files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL. Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary `read_lock()` …

UNKNOWN -- CVE-2026-112941 (GHSA-wfhq-q4j2-c37m) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) checks once per lock object, by name, that `<path>.rw`, `<path>.rw/gen` and `<path>.rw/holders` are real directories and not symbolic links (files that redirect to another path). It then keeps only a boolean. Every later record operation resolves the full path again, and `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link) protects the last component only. Another local user (the other user below) who can write the directory holding the lock, or who created `<path>.rw` first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), can put a tree under that name whose `gen` or `holders` is a symbolic link into a directory of the user running the lock (the lock user below). A long-running process of the lock user that has already passed the check then creates, renames over and deletes files there with the lock user's privileges, including in `0700` directories the other user cannot open. The names and bytes are filelock's own lock records, not chosen by the other user. The scanner's reproducer replaces `.git/HEAD` in …

UNKNOWN -- PYSEC-2026-4305 (CVE-2026-104843, GHSA-2cv4-cqwr-gwf7) (uv 0.12.17)

Found by: pip-audit:dev-ceiling

### Impact In versions of uv from 0.12.7 to 0.12.18 on Windows, uv could be induced into writing a file outside of the installation prefix during wheel installation. A malicious wheel could use this to place an executable outside of the intended environment, including in a directory already present on the user's PATH. This vulnerability only affects Windows hosts; no other platforms are affected. ### Patches uv 0.12.18 and newer address this vulnerability. Users are encouraged to upgrade to 0.12.18. ### Workarounds There is no workaround other than upgrading to uv 0.12.18.

UNKNOWN -- PYSEC-2026-4321 (GHSA-hq85-5cpj-mr62) (uv 0.12.17)

Found by: pip-audit:dev-ceiling

By default, uv verifies TLS connections against a vendored copy of the `webpki-root-certs` bundle. Versions before 0.13.4 include the `emSign ECC Root CA - G3` root. Eight eMudhra intermediate CA certificates that chain to that root have compromised private keys, and the CA has revoked them. An attacker with one of the compromised keys can issue certificates that uv accepts for any hostname, and can be used to issue certificates to enable MITM attacks. uv 0.13.4 removes the root from its vendored bundle, following `webpki-root-certs` 1.0.10. Upgrade to 0.13.4 or later.

UNKNOWN -- PYSEC-2026-4312 (CVE-2026-102598, GHSA-g6x2-hccm-hh4m) (werkzeug 3.1.8)

Found by: pip-audit:dev-ceiling

Werkzeug's `safe_join` function allows Windows device names as filenames when they have an empty ADS marker on NTFS. This was previously reported as https://github.com/pallets/werkzeug/security/advisories/GHSA-hgf8-39gv-g3f2, but the added filtering failed to account for the fact Windows allows special device names with an empty ADS marker, such as `NUL:`. `send_from_directory` uses `safe_join` to safely serve files at user-specified paths under a directory. If the application is running on Windows and NTFS, and the requested path ends with a special device name, the file will be opened successfully, but reading will hang indefinitely.

How to fix

Raise the affected lower bound in pyproject.toml ([project].dependencies, or the pin in the dev dependency group) to at least the Fixed in version above, then run uv lock. Because this package publishes open >= ranges, the floor is what consumers can actually install -- bumping only the resolved version does not close the hole.

This audit resolves without the 7-day publish-age cooldown (exclude-newer) in [tool.uv], but uv lock applies it. If uv lock says the fixed version was filtered by exclude-newer, add exclude-newer-package = { <package> = false } under [tool.uv] in pyproject.toml, run uv lock and commit both files. Once the release is 7 days old, remove the entry and run uv lock again.

An advisory with no fix available does not block the build -- it is reported here so it can be tracked, but no version bump can resolve it. Suppression files are deliberately not honoured: the scan runs with --ignorefile /dev/null so nothing can disappear from this report silently.

zeevmoney and others added 5 commits October 1, 2026 20:20
* origin/per-16676/release-ci-hardening:
  Invite with a role of the invited resource in the invites e2e test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* origin/per-16676/release-ci-hardening:
  Poll for the PDP's role assignment list in the RBAC e2e tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@EliMoshkovich

Copy link
Copy Markdown
Collaborator

A port of this PR's own changes to main, without the #136 stack, is in #163, with review follow-ups on top. It's there so the logging fix can ship in a patch release.

@EliMoshkovich
EliMoshkovich marked this pull request as ready for review October 28, 2026 17:48
Copilot AI balanced review requested due to automatic review settings October 28, 2026 17:48
EliMoshkovich and others added 3 commits October 29, 2026 13:22
…nnect hook

Permit.__init__ called self._connect(), a new override point: an application
subclass with its own _connect method had it called by the SDK's constructor.
The logging change doesn't need it, so the base constructor builds the four
clients again and the blocking client's constructor builds its own after it, as
before. A test checks that a subclass's own _connect is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b5de9d1)
- SdkLogger logs with loguru's lazy option, so the label, the key replacement and
  the message itself are built only for a record loguru passes on. With the SDK's
  logging off (the default) a debug record is dropped without being built or
  scrubbed. An application that turns the permit records on itself still gets
  them in full.
- The enforcer's debug lines, which pformat the request and the response, pass a
  function that builds the message, so the pformat runs only for a record a sink
  receives.
- A log level such as "²", which str.isdigit accepts but int() doesn't, is now an
  unknown level that falls back to INFO with a warning, instead of failing
  client creation.
- Tests: the echoed-key test runs on both clients, at trace, debug, info, error and
  critical, with logging off and with the application turning it on; the level
  sweep runs on both clients, checks the key and that no level falls back to INFO,
  and covers "fatal"; tests for the 12-character boundary, the label on the
  unknown-level path, an enabled client undoing the SDK's own disable only once, and
  a disable the application made for an SDK module before. The fixture resets the
  set of level names already warned about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a8bf8c2)
…s; lint and deps

- README: list where the key is replaced (log messages, exception messages and
  error bodies, the aiohttp objects the exceptions carry) and where it isn't (the
  response's own headers and raw body, frame values in tracebacks), and keep the
  user-name-and-password sentence to the start-up line.
- README and docstrings: undoing the SDK's own disable with logger.enable("permit")
  also drops every disable set for a permit module, before or since, and overrides
  an application-wide logger.disable("") for the SDK's records. A test covers both.
- The repr=False comment on PermitConfig.token now says what it covers: repr() and
  str() of the config, not every value a traceback prints.
- ruff bans the standard logging module in the package, as it bans loguru's logger.
- multidict, imported directly, is a declared dependency, in aiohttp's range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bce2118)
Copilot AI balanced review requested due to automatic review settings October 29, 2026 18:22
@EliMoshkovich

Copy link
Copy Markdown
Collaborator

Pushed the four review-round commits from #163 (cherry-picked, so the two PRs stay identical):

  • Error bodies labelled JSON that aren't JSON are scrubbed, and so is the aiohttp error text.
  • Clients are built in the constructors again, without a _connect hook.
  • A log record is built and scrubbed only when a sink will receive it (opt(lazy=True)).
  • The README and docstring wording is corrected, ruff bans stdlib logging, and multidict is declared.

permit/, tests/test_fix_logging.py, README.md and pyproject.toml match #163's head (bce2118). Offline suites: 410 passed. Pre-commit is clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

EliMoshkovich and others added 3 commits October 29, 2026 14:29
…er-cased key

- The aiohttp error an SDK exception carries is scrubbed together with every
  exception chained under it, such as the HTTP parser errors aiohttp raises it
  from: their string args, message, line, and the URL an InvalidURL error prints.
  Tested against the whole formatted traceback, for a bad header, a bad status
  line, a redirect to an invalid URL and one to a non-HTTP URL, on the PDP and
  the REST API, with both clients. The non-HTTP redirect keeps the key in args only.
- Each key is also replaced in lower case: aiohttp lower-cases some server text,
  such as a Content-Type, in its error messages. The test key is now mixed case.
- The enforcer reads a PDP error body with errors="replace", so a body that isn't
  valid UTF-8 raises PermitConnectionError instead of a UnicodeDecodeError that
  keeps the raw bytes.
- PermitConnectionError keeps a message that isn't a string as it is, as before.
- README: say what the redaction covers, now including the chained exceptions, and
  that URLs and a redirect response's own headers are not covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e0c373d)
The test now runs bulk_check, authorized_users and get_user_permissions as well
as check, and with the records on asserts that the message was built and
scrubbed, without pinning how many pformat calls it makes. Two parametrizations
use CLIENT_CLASSES instead of copying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 8cc038f)
The multidict floor stays at aiohttp's own, 4.5, so declaring it narrows nothing
users can install. The comment now says that the lowest-direct floor lanes
therefore run 4.5.0, which has no C extension build for Python 3.10+.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 1eea9d8)
@EliMoshkovich

Copy link
Copy Markdown
Collaborator

Pushed the three round-2 commits from #163 (cherry-picked):

  • the exceptions chained under an aiohttp error are scrubbed, and so is a lower-cased key;
  • non-UTF-8 PDP error bodies are read safely, and non-string messages are guarded;
  • test cleanups and the multidict floor note.

permit/, tests/test_fix_logging.py, README.md and pyproject.toml match #163's head (1eea9d8).

Copilot AI balanced review requested due to automatic review settings October 29, 2026 19:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

EliMoshkovich and others added 2 commits October 29, 2026 16:20
…crub

The walk over the exceptions chained under a carried aiohttp error reached, through
__context__, an exception the application was handling when it called the SDK,
and wrote to it. A frozen dataclass exception, read-only args or a headers dict
with a value that isn't a string then made the scrub raise inside
PermitConnectionError.__init__, so the caller got that error instead of a
PermitConnectionError.

- The walk keeps only exceptions raised and caught within the request: every frame
  of their traceback is in aiohttp, the libraries it raises errors from, asyncio or
  the SDK. It doesn't go past any other exception, so the application's exception
  and its own chain are never changed.
- A value is written only when it holds a key, each write is guarded, and
  scrub_request_info never raises: it returns False when a value that holds a key
  can't be replaced. PermitConnectionError then attaches a plain ClientError that
  says so, and every raise site raises from original_error, so the unscrubbed error
  is neither attached nor the cause. handle_api_error doesn't chain to it either.
- Every string and bytes argument and attribute of those exceptions is scrubbed,
  not a fixed list, and so is a yarl URL, which becomes a string when it holds a key.
- Tests: the application's exception (frozen, headers with an int, read-only args)
  is left alone and PermitConnectionError is still raised, on both clients, for the
  PDP and the REST API; a scrub that fails is not attached or the cause, at every
  raise site; an error chained only as the context; a __cause__ cycle, in a
  subprocess; raw answers whose errors keep the key as bytes (a header line too
  long, a NUL in a header) and as a yarl URL; a padded key echoed in lower case.
  assert_holds_no_key also checks every attribute of every chained exception, and
  the raw-server test checks that each case still reaches a key to replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 3294360)
…ceptions alone

The redaction list says which chained exceptions are covered (those aiohttp and its
libraries raised during the request), that string and bytes values and an
invalid-URL error's URL are, the latter becoming a string, and that values that
aren't text, such as a parsed HTTP message, are not. It says the SDK never changes
an exception it didn't raise, and what it attaches when the key can't be removed.
"Request URLs" now names the URLs that are not covered, so it no longer reads as
contradicting the invalid-URL item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d2d1f86)
@EliMoshkovich

Copy link
Copy Markdown
Collaborator

Pushed the two round-3 commits from #163 (cherry-picked):

  • the scrub changes only what the request raised, and never fails;
  • if scrubbing fails, the original error isn't attached;
  • the README wording is updated.

permit/, tests/test_fix_logging.py, README.md and pyproject.toml match #163's head (d2d1f86).

Copilot AI balanced review requested due to automatic review settings October 29, 2026 21:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

…rub can't reach

- The error the SDK caught is always scrubbed; the frame check applies only to what is
  chained under it.
- The exception the application was handling when it called the SDK is passed in as
  `handled` and is left alone, with everything under it.
- Any other exception left as it is (raised in a package off the list, or with a frame of
  the application's), or text the scrub can't rewrite (a UnicodeError's `object`), makes
  the scrub return False if it still holds a key.
- PermitConnectionError and PermitApiError are raised after the `except` block, so an
  error that wasn't attached isn't their context.
- Tests: the caught error with a foreign frame; an idna error in a redirect host; a
  yarl-raised error; all four PDP calls with the application's exception; a refused
  connection; the frame-check, fail-closed, bytearray and builtin-field paths; and the raw
  answers on aiohttp's pure-Python parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 30, 2026 13:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

zeevmoney and others added 2 commits October 30, 2026 10:21
A traceback shows every member of an exception group and what is chained
under each, so the scrub now walks them as it walks a cause and a context:
members raised during the request are scrubbed, and a key in one left as
it is fails the scrub. Works with the exceptiongroup backport on 3.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis
The check for a key the scrub could not reach also catches most write
failures, so several tests passed whether or not the scrub worked. They
now check that the error is attached where the scrub should succeed, and
new cases cover: a write that fails out of sight of that check, an error
without the key that can't be written to, a key held only in an error's
text or only in its args, a cycle among the exceptions the scrub leaves
as they are, and the exception the application was handling, under one
left as it is, with frames only in the request's packages, and while an
error body that isn't JSON is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis
Copilot AI balanced review requested due to automatic review settings October 30, 2026 15:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

EliMoshkovich added a commit that referenced this pull request Oct 30, 2026
)

* PER-16680: log through an SDK logger that applies the log settings

Ported to main from #137 (Zeev Manilovich), which is stacked on the release-CI
draft #136; this carries only #137's own changes.

- The client no longer logs its config on creation; it logs the PDP and API URL.
- permit.utils.sdk_logger applies log.level and log.label to the SDK's records
  without touching the application's loguru sinks, and replaces every client's
  API key with [REDACTED] in what the SDK logs and in PDP error bodies.
- PermitConfig.token is repr=False.
- ruff bans logging straight to loguru inside the package.
- README: what each log option does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: review follow-ups: scrub every raised error body, URL user info, wait_for_sync

- handle_api_error scrubs the registered keys from the body it puts in an
  exception, text or JSON, so the REST API and the PDP's local API raise
  without them, as the enforcer already did.
- The start-up line logs api_url and pdp without a user name or password.
- wait_for_sync builds a client of the same class again, so a subclass's own
  setup runs, and keeps the process's log settings through the
  keep_log_settings context variable.
- A secret shorter than 12 characters is not redacted, so a short test token
  doesn't mangle the SDK's text.
- log.level takes a loguru severity number and surrounding whitespace; an
  unknown level is warned about once per process.
- The SDK's enable/disable switch changes under a lock.
- Drop sync.Permit.__init__, which only called super().
- README and docstrings match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: second review pass: scrub carried request headers, safe URL helper

- aiohttp keeps the request's headers, Authorization included, on the
  ClientResponse and ClientResponseError the SDK's exceptions carry
  (original_error, response, the cause), and their repr prints them.
  scrub_request_info replaces every registered key there before an exception
  holds them, including the response's cached request_info.
- without_userinfo moves to permit.utils.sdk_logger. It never raises (a bad
  port no longer fails client creation, an unparseable URL shows as
  <invalid URL>) and keeps IPv6 brackets.
- PDP error messages show the PDP URL without a user name or password.
- README: wait_for_sync builds a new client of the same class; what the key
  is replaced in.
- Tests: REST and PDP errors for a non-JSON answer (200 and 502) and for an
  echoed key carry no key in their cause, context, original_error or response;
  without_userinfo cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: scrub JSON object keys and the labelled log line

- handle_api_error's JSON scrubber replaces registered keys in object keys as
  well as values, so an error body that uses the key as a key doesn't expose it.
- The SDK logger scrubs the message after the label is added, so a key placed
  in log.label is replaced too.
- Tests: a key echoed as a JSON object key; a key in the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: scrub the request info aiohttp keeps in an error's args and redirect history

A ClientResponseError keeps its RequestInfo as args[0] as well as in
request_info, and the redirect responses in its (or a response's) history
keep their own. scrub_request_info replaces the key in all of them.
Test: neither the carried objects nor their args print the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: scrub error bodies labelled JSON that aren't JSON, and aiohttp error text

- An error response whose Content-Type says JSON but whose body isn't JSON (a
  proxy's error page, a truncated body, one that isn't UTF-8) now raises
  PermitApiError with the scrubbed body as its details, instead of letting a
  JSONDecodeError or UnicodeDecodeError, which keep the raw body, escape. The
  body is scrubbed before it is parsed, and the decode error isn't chained.
- PermitConnectionError replaces the key in its message itself, so every raise
  site is covered, including the ones that build it from an aiohttp error's text.
- The aiohttp error it carries has the key replaced in its message, the response
  headers it keeps and its string arguments, not only in the request headers.
- Tests: a body labelled JSON that isn't JSON; an aiohttp error whose text echoes
  the key, for every enforcer call and the REST API; an echoed key after a followed
  redirect, and in a list of errors and an object key spelled with JSON escapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: build the clients in the constructors again, without a _connect hook

Permit.__init__ called self._connect(), a new override point: an application
subclass with its own _connect method had it called by the SDK's constructor.
The logging change doesn't need it, so the base constructor builds the four
clients again and the blocking client's constructor builds its own after it, as
before. A test checks that a subclass's own _connect is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: build and scrub a log record only when a sink will receive it

- SdkLogger logs with loguru's lazy option, so the label, the key replacement and
  the message itself are built only for a record loguru passes on. With the SDK's
  logging off (the default) a debug record is dropped without being built or
  scrubbed. An application that turns the permit records on itself still gets
  them in full.
- The enforcer's debug lines, which pformat the request and the response, pass a
  function that builds the message, so the pformat runs only for a record a sink
  receives.
- A log level such as "²", which str.isdigit accepts but int() doesn't, is now an
  unknown level that falls back to INFO with a warning, instead of failing
  client creation.
- Tests: the echoed-key test runs on both clients, at trace, debug, info, error and
  critical, with logging off and with the application turning it on; the level
  sweep runs on both clients, checks the key and that no level falls back to INFO,
  and covers "fatal"; tests for the 12-character boundary, the label on the
  unknown-level path, an enabled client undoing the SDK's own disable only once, and
  a disable the application made for an SDK module before. The fixture resets the
  set of level names already warned about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: say exactly what is redacted and what "enable": True undoes; lint and deps

- README: list where the key is replaced (log messages, exception messages and
  error bodies, the aiohttp objects the exceptions carry) and where it isn't (the
  response's own headers and raw body, frame values in tracebacks), and keep the
  user-name-and-password sentence to the start-up line.
- README and docstrings: undoing the SDK's own disable with logger.enable("permit")
  also drops every disable set for a permit module, before or since, and overrides
  an application-wide logger.disable("") for the SDK's records. A test covers both.
- The repr=False comment on PermitConfig.token now says what it covers: repr() and
  str() of the config, not every value a traceback prints.
- ruff bans the standard logging module in the package, as it bans loguru's logger.
- multidict, imported directly, is a declared dependency, in aiohttp's range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: scrub the errors chained under an aiohttp error, and a lower-cased key

- The aiohttp error an SDK exception carries is scrubbed together with every
  exception chained under it, such as the HTTP parser errors aiohttp raises it
  from: their string args, message, line, and the URL an InvalidURL error prints.
  Tested against the whole formatted traceback, for a bad header, a bad status
  line, a redirect to an invalid URL and one to a non-HTTP URL, on the PDP and
  the REST API, with both clients. The non-HTTP redirect keeps the key in args only.
- Each key is also replaced in lower case: aiohttp lower-cases some server text,
  such as a Content-Type, in its error messages. The test key is now mixed case.
- The enforcer reads a PDP error body with errors="replace", so a body that isn't
  valid UTF-8 raises PermitConnectionError instead of a UnicodeDecodeError that
  keeps the raw bytes.
- PermitConnectionError keeps a message that isn't a string as it is, as before.
- README: say what the redaction covers, now including the chained exceptions, and
  that URLs and a redirect response's own headers are not covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: the lazy-record test covers all four enforcer debug lines

The test now runs bulk_check, authorized_users and get_user_permissions as well
as check, and with the records on asserts that the message was built and
scrubbed, without pinning how many pformat calls it makes. Two parametrizations
use CLIENT_CLASSES instead of copying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: note that the floor lanes run multidict 4.5.0 as pure Python

The multidict floor stays at aiohttp's own, 4.5, so declaring it narrows nothing
users can install. The comment now says that the lowest-direct floor lanes
therefore run 4.5.0, which has no C extension build for Python 3.10+.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: change only what the request raised, and never fail on a scrub

The walk over the exceptions chained under a carried aiohttp error reached, through
__context__, an exception the application was handling when it called the SDK,
and wrote to it. A frozen dataclass exception, read-only args or a headers dict
with a value that isn't a string then made the scrub raise inside
PermitConnectionError.__init__, so the caller got that error instead of a
PermitConnectionError.

- The walk keeps only exceptions raised and caught within the request: every frame
  of their traceback is in aiohttp, the libraries it raises errors from, asyncio or
  the SDK. It doesn't go past any other exception, so the application's exception
  and its own chain are never changed.
- A value is written only when it holds a key, each write is guarded, and
  scrub_request_info never raises: it returns False when a value that holds a key
  can't be replaced. PermitConnectionError then attaches a plain ClientError that
  says so, and every raise site raises from original_error, so the unscrubbed error
  is neither attached nor the cause. handle_api_error doesn't chain to it either.
- Every string and bytes argument and attribute of those exceptions is scrubbed,
  not a fixed list, and so is a yarl URL, which becomes a string when it holds a key.
- Tests: the application's exception (frozen, headers with an int, read-only args)
  is left alone and PermitConnectionError is still raised, on both clients, for the
  PDP and the REST API; a scrub that fails is not attached or the cause, at every
  raise site; an error chained only as the context; a __cause__ cycle, in a
  subprocess; raw answers whose errors keep the key as bytes (a header line too
  long, a NUL in a header) and as a yarl URL; a padded key echoed in lower case.
  assert_holds_no_key also checks every attribute of every chained exception, and
  the raw-server test checks that each case still reaches a key to replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: README: what the scrub covers, and that it leaves other exceptions alone

The redaction list says which chained exceptions are covered (those aiohttp and its
libraries raised during the request), that string and bytes values and an
invalid-URL error's URL are, the latter becoming a string, and that values that
aren't text, such as a parsed HTTP message, are not. It says the SDK never changes
an exception it didn't raise, and what it attaches when the key can't be removed.
"Request URLs" now names the URLs that are not covered, so it no longer reads as
contradicting the invalid-URL item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: always scrub the caught error; fail closed on a key the scrub can't reach

- The error the SDK caught is always scrubbed; the frame check applies only to what is
  chained under it.
- The exception the application was handling when it called the SDK is passed in as
  `handled` and is left alone, with everything under it.
- Any other exception left as it is (raised in a package off the list, or with a frame of
  the application's), or text the scrub can't rewrite (a UnicodeError's `object`), makes
  the scrub return False if it still holds a key.
- PermitConnectionError and PermitApiError are raised after the `except` block, so an
  error that wasn't attached isn't their context.
- Tests: the caught error with a foreign frame; an idna error in a redirect host; a
  yarl-raised error; all four PDP calls with the application's exception; a refused
  connection; the frame-check, fail-closed, bytearray and builtin-field paths; and the raw
  answers on aiohttp's pure-Python parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PER-16680: walk the members of an exception group under an aiohttp error

A traceback shows every member of an exception group and what is chained
under each, so the scrub now walks them as it walks a cause and a context:
members raised during the request are scrubbed, and a key in one left as
it is fails the scrub. Works with the exceptiongroup backport on 3.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis

* PER-16680: test the scrub paths the final key check would otherwise hide

The check for a key the scrub could not reach also catches most write
failures, so several tests passed whether or not the scrub worked. They
now check that the error is attached where the scrub should succeed, and
new cases cover: a write that fails out of sight of that check, an error
without the key that can't be written to, a key held only in an error's
text or only in its args, a cycle among the exceptions the scrub leaves
as they are, and the exception the application was handling, under one
left as it is, with frames only in the request's packages, and while an
error body that isn't JSON is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Zeev Manilovich <zeevmanilovich@gmail.com>

This branch has not been deployed

No deployments
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.

3 participants