codehound 1.4.0__tar.gz → 1.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codehound-1.4.0 → codehound-1.4.1}/PKG-INFO +36 -8
- {codehound-1.4.0 → codehound-1.4.1}/README.md +35 -7
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/__init__.py +1 -1
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/unclosed_socket.py +57 -9
- {codehound-1.4.0 → codehound-1.4.1}/tests/test_checks.py +66 -0
- {codehound-1.4.0 → codehound-1.4.1}/.gitignore +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/LICENSE +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/pyproject.toml +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/__init__.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/loop_closure_capture.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/lru_cache_on_method.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/cli.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/core.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/sarif.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/src/codehound/terminal.py +0 -0
- {codehound-1.4.0 → codehound-1.4.1}/tests/test_output_formats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codehound
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kratos0718/codehound
|
|
6
6
|
Project-URL: Issues, https://github.com/kratos0718/codehound/issues
|
|
@@ -132,7 +132,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
132
132
|
```yaml
|
|
133
133
|
repos:
|
|
134
134
|
- repo: https://github.com/kratos0718/codehound
|
|
135
|
-
rev: v1.4.
|
|
135
|
+
rev: v1.4.1
|
|
136
136
|
hooks:
|
|
137
137
|
- id: codehound
|
|
138
138
|
```
|
|
@@ -153,7 +153,7 @@ repos:
|
|
|
153
153
|
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
154
154
|
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
155
155
|
| **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
156
|
-
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. |
|
|
156
|
+
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
|
|
157
157
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
158
158
|
| **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
|
|
159
159
|
| **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
|
|
@@ -167,11 +167,12 @@ repos:
|
|
|
167
167
|
`codehound list` prints this from the source of truth.
|
|
168
168
|
|
|
169
169
|
CH007-CH020 don't have found-and-merged bugs behind all of them the way
|
|
170
|
-
CH001-CH006
|
|
170
|
+
CH001-CH006 do - most are hardening rules for well-known Python
|
|
171
171
|
correctness gotchas rather than something this project personally
|
|
172
|
-
tracked down first. CH010
|
|
173
|
-
|
|
174
|
-
CH007-CH010 surfaced real false
|
|
172
|
+
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
173
|
+
genuine bugs on their own, in HuggingFace's `accelerate`, optuna, and
|
|
174
|
+
llama_index - see below. Building CH007-CH010 surfaced real false
|
|
175
|
+
positives, each one fixed before shipping:
|
|
175
176
|
|
|
176
177
|
- **CH007** (agno): a bare `self.foo()` call matched against an unrelated
|
|
177
178
|
same-named `async def foo` on a *different* class (agno's own
|
|
@@ -209,6 +210,33 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
209
210
|
match same-file names by design, and most real cases of either are
|
|
210
211
|
plausibly cross-module.
|
|
211
212
|
|
|
213
|
+
**The optuna and llama_index finds (CH011):** both are `@lru_cache(maxsize=
|
|
214
|
+
None)` decorating an instance method - a strong reference to `self`
|
|
215
|
+
retained forever. In optuna, `_FanovaTree`'s node-lookup methods leak
|
|
216
|
+
every tree built for a `get_param_importances()` call (one per
|
|
217
|
+
random-forest estimator). In llama_index, `VectaraIndex._get_corpus_key`
|
|
218
|
+
leaks the index itself - and since `VectaraIndex.__del__` exists
|
|
219
|
+
specifically to close the index's `requests.Session` on garbage
|
|
220
|
+
collection, the leak silently disables that cleanup too, so an HTTP
|
|
221
|
+
session leaks along with every index. Both fixed the same way: move the
|
|
222
|
+
cache from a class-level decorator to a per-instance one built in
|
|
223
|
+
`__init__`, so it's freed with the instance instead of outliving it. Both
|
|
224
|
+
have a regression test verified to fail pre-fix and pass post-fix.
|
|
225
|
+
PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
|
|
226
|
+
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
|
|
227
|
+
|
|
228
|
+
**CH016 found and fixed its own false positive the day it shipped.** The
|
|
229
|
+
first real-corpus scan of `unclosed-socket` turned up three hits in
|
|
230
|
+
vllm's distributed process-group rendezvous code - all three sockets were
|
|
231
|
+
actually handed off correctly (returned inside a tuple, collected into a
|
|
232
|
+
list that's itself returned, passed as an argument into a function that
|
|
233
|
+
takes ownership), just not in a shape CH005 (the check this one was
|
|
234
|
+
modeled on) ever needed to recognize, since files aren't handed off this
|
|
235
|
+
way nearly as often as rendezvous sockets are. Fixed by treating a name
|
|
236
|
+
as escaped when it's returned as part of a tuple/list or passed as an
|
|
237
|
+
argument to any call. A full corpus rescan afterward found zero remaining
|
|
238
|
+
CH016 hits.
|
|
239
|
+
|
|
212
240
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
213
241
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
214
242
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -269,7 +297,7 @@ codehound/
|
|
|
269
297
|
|
|
270
298
|
Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
|
|
271
299
|
|
|
272
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
300
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
273
301
|
|
|
274
302
|
---
|
|
275
303
|
|
|
@@ -112,7 +112,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
112
112
|
```yaml
|
|
113
113
|
repos:
|
|
114
114
|
- repo: https://github.com/kratos0718/codehound
|
|
115
|
-
rev: v1.4.
|
|
115
|
+
rev: v1.4.1
|
|
116
116
|
hooks:
|
|
117
117
|
- id: codehound
|
|
118
118
|
```
|
|
@@ -133,7 +133,7 @@ repos:
|
|
|
133
133
|
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
134
134
|
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
135
135
|
| **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
|
|
136
|
-
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. |
|
|
136
|
+
| **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
|
|
137
137
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
138
138
|
| **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
|
|
139
139
|
| **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
|
|
@@ -147,11 +147,12 @@ repos:
|
|
|
147
147
|
`codehound list` prints this from the source of truth.
|
|
148
148
|
|
|
149
149
|
CH007-CH020 don't have found-and-merged bugs behind all of them the way
|
|
150
|
-
CH001-CH006
|
|
150
|
+
CH001-CH006 do - most are hardening rules for well-known Python
|
|
151
151
|
correctness gotchas rather than something this project personally
|
|
152
|
-
tracked down first. CH010
|
|
153
|
-
|
|
154
|
-
CH007-CH010 surfaced real false
|
|
152
|
+
tracked down first. CH010 and CH011 are the exceptions: both found
|
|
153
|
+
genuine bugs on their own, in HuggingFace's `accelerate`, optuna, and
|
|
154
|
+
llama_index - see below. Building CH007-CH010 surfaced real false
|
|
155
|
+
positives, each one fixed before shipping:
|
|
155
156
|
|
|
156
157
|
- **CH007** (agno): a bare `self.foo()` call matched against an unrelated
|
|
157
158
|
same-named `async def foo` on a *different* class (agno's own
|
|
@@ -189,6 +190,33 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
189
190
|
match same-file names by design, and most real cases of either are
|
|
190
191
|
plausibly cross-module.
|
|
191
192
|
|
|
193
|
+
**The optuna and llama_index finds (CH011):** both are `@lru_cache(maxsize=
|
|
194
|
+
None)` decorating an instance method - a strong reference to `self`
|
|
195
|
+
retained forever. In optuna, `_FanovaTree`'s node-lookup methods leak
|
|
196
|
+
every tree built for a `get_param_importances()` call (one per
|
|
197
|
+
random-forest estimator). In llama_index, `VectaraIndex._get_corpus_key`
|
|
198
|
+
leaks the index itself - and since `VectaraIndex.__del__` exists
|
|
199
|
+
specifically to close the index's `requests.Session` on garbage
|
|
200
|
+
collection, the leak silently disables that cleanup too, so an HTTP
|
|
201
|
+
session leaks along with every index. Both fixed the same way: move the
|
|
202
|
+
cache from a class-level decorator to a per-instance one built in
|
|
203
|
+
`__init__`, so it's freed with the instance instead of outliving it. Both
|
|
204
|
+
have a regression test verified to fail pre-fix and pass post-fix.
|
|
205
|
+
PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
|
|
206
|
+
[run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
|
|
207
|
+
|
|
208
|
+
**CH016 found and fixed its own false positive the day it shipped.** The
|
|
209
|
+
first real-corpus scan of `unclosed-socket` turned up three hits in
|
|
210
|
+
vllm's distributed process-group rendezvous code - all three sockets were
|
|
211
|
+
actually handed off correctly (returned inside a tuple, collected into a
|
|
212
|
+
list that's itself returned, passed as an argument into a function that
|
|
213
|
+
takes ownership), just not in a shape CH005 (the check this one was
|
|
214
|
+
modeled on) ever needed to recognize, since files aren't handed off this
|
|
215
|
+
way nearly as often as rendezvous sockets are. Fixed by treating a name
|
|
216
|
+
as escaped when it's returned as part of a tuple/list or passed as an
|
|
217
|
+
argument to any call. A full corpus rescan afterward found zero remaining
|
|
218
|
+
CH016 hits.
|
|
219
|
+
|
|
192
220
|
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
193
221
|
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
194
222
|
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
@@ -249,7 +277,7 @@ codehound/
|
|
|
249
277
|
|
|
250
278
|
Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
|
|
251
279
|
|
|
252
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
280
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
253
281
|
|
|
254
282
|
---
|
|
255
283
|
|
|
@@ -2,10 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
The same shape as CH005's unclosed file handle, for a different resource:
|
|
4
4
|
`s = socket.socket(...)` outside a `with` block, with no matching
|
|
5
|
-
`s.close()` anywhere in the function and no
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
`s.close()` anywhere in the function and no ownership hand-off, leaks the
|
|
6
|
+
file descriptor exactly like an unclosed file does - sockets are file
|
|
7
|
+
descriptors on POSIX systems, and Python's socket module supports the
|
|
8
|
+
context-manager protocol specifically so this doesn't happen.
|
|
9
|
+
|
|
10
|
+
Unlike a file handle, a socket used for rendezvous/handshake code is
|
|
11
|
+
routinely handed off by passing it into another call rather than by
|
|
12
|
+
`return`ing it bare or closing it locally - real false positives found in
|
|
13
|
+
vllm's distributed process-group setup: `return port, s` (returned inside
|
|
14
|
+
a tuple, not as the bare name), `socks.append(s)` with the list itself
|
|
15
|
+
returned, and `listen_socket=listen_socket` passed straight into a
|
|
16
|
+
`create_tcp_store(...)` call that takes ownership of it. So a socket name
|
|
17
|
+
is also treated as escaped if it's returned as part of a tuple/list, or
|
|
18
|
+
passed as an argument to any call other than a method called *on* the
|
|
19
|
+
socket itself (`s.bind(...)`, `s.close()`, etc., where the socket is the
|
|
20
|
+
receiver, not an argument).
|
|
9
21
|
"""
|
|
10
22
|
|
|
11
23
|
from __future__ import annotations
|
|
@@ -50,6 +62,36 @@ def _has_close_call(scope: ast.AST, name: str) -> bool:
|
|
|
50
62
|
return False
|
|
51
63
|
|
|
52
64
|
|
|
65
|
+
def _is_passed_as_argument(scope: ast.AST, name: str) -> bool:
|
|
66
|
+
"""Is `name` ever passed into a call as an argument (not as the receiver
|
|
67
|
+
of an attribute call, e.g. `name.bind(...)`)? A socket handed to another
|
|
68
|
+
call - `store_it(s)`, `things.append(s)`, `f(listen_socket=s)` - has its
|
|
69
|
+
ownership transferred there, same as a `return`."""
|
|
70
|
+
for node in ast.walk(scope):
|
|
71
|
+
if not isinstance(node, ast.Call):
|
|
72
|
+
continue
|
|
73
|
+
for arg in node.args:
|
|
74
|
+
if isinstance(arg, ast.Name) and arg.id == name:
|
|
75
|
+
return True
|
|
76
|
+
for kw in node.keywords:
|
|
77
|
+
if isinstance(kw.value, ast.Name) and kw.value.id == name:
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _return_targets(value: ast.expr | None) -> list[str]:
|
|
83
|
+
if value is None:
|
|
84
|
+
return []
|
|
85
|
+
if isinstance(value, ast.Name):
|
|
86
|
+
return [value.id]
|
|
87
|
+
if isinstance(value, (ast.Tuple, ast.List)):
|
|
88
|
+
out: list[str] = []
|
|
89
|
+
for elt in value.elts:
|
|
90
|
+
out.extend(_return_targets(elt))
|
|
91
|
+
return out
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
53
95
|
class UnclosedSocket(Check):
|
|
54
96
|
code = "CH016"
|
|
55
97
|
name = "unclosed-socket"
|
|
@@ -70,12 +112,18 @@ class UnclosedSocket(Check):
|
|
|
70
112
|
fn = enclosing_function(node, parents)
|
|
71
113
|
if fn is None:
|
|
72
114
|
continue
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
for
|
|
76
|
-
|
|
115
|
+
returned_names = {
|
|
116
|
+
n
|
|
117
|
+
for r in ast.walk(fn)
|
|
118
|
+
if isinstance(r, ast.Return)
|
|
119
|
+
for n in _return_targets(r.value)
|
|
120
|
+
}
|
|
77
121
|
for name in names:
|
|
78
|
-
if
|
|
122
|
+
if (
|
|
123
|
+
name in returned_names
|
|
124
|
+
or _has_close_call(fn, name)
|
|
125
|
+
or _is_passed_as_argument(fn, name)
|
|
126
|
+
):
|
|
79
127
|
continue
|
|
80
128
|
findings.append(
|
|
81
129
|
Finding(
|
|
@@ -724,6 +724,72 @@ def test_ch016_ignores_returned_socket():
|
|
|
724
724
|
assert _run(code, ["CH016"]) == []
|
|
725
725
|
|
|
726
726
|
|
|
727
|
+
def test_ch016_ignores_socket_returned_inside_a_tuple():
|
|
728
|
+
# Real false positive found in vllm's distributed process-group setup:
|
|
729
|
+
# `return port, s` hands the socket off to the caller just as much as a
|
|
730
|
+
# bare `return s` does - it's just wrapped in a tuple alongside other data.
|
|
731
|
+
code = (
|
|
732
|
+
"import socket\n"
|
|
733
|
+
"def bind():\n"
|
|
734
|
+
" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
|
|
735
|
+
" s.bind(('localhost', 0))\n"
|
|
736
|
+
" port = s.getsockname()[1]\n"
|
|
737
|
+
" return port, s\n"
|
|
738
|
+
)
|
|
739
|
+
assert _run(code, ["CH016"]) == []
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def test_ch016_ignores_socket_appended_to_a_returned_list():
|
|
743
|
+
# Real false positive found in vllm: sockets are collected into a list
|
|
744
|
+
# inside a loop, and the list itself (not any individual socket name) is
|
|
745
|
+
# returned - `socks.append(s)` is the hand-off.
|
|
746
|
+
code = (
|
|
747
|
+
"import socket\n"
|
|
748
|
+
"def bind_group():\n"
|
|
749
|
+
" socks = []\n"
|
|
750
|
+
" for _ in range(3):\n"
|
|
751
|
+
" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
|
|
752
|
+
" s.bind(('localhost', 0))\n"
|
|
753
|
+
" socks.append(s)\n"
|
|
754
|
+
" return socks\n"
|
|
755
|
+
)
|
|
756
|
+
assert _run(code, ["CH016"]) == []
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def test_ch016_ignores_socket_passed_as_call_argument():
|
|
760
|
+
# Real false positive found in vllm: a listen socket is built, then
|
|
761
|
+
# handed straight into another function that takes ownership of it
|
|
762
|
+
# (`create_tcp_store(..., listen_socket=listen_socket)`), never returned
|
|
763
|
+
# and never closed in this function because the callee owns it now.
|
|
764
|
+
code = (
|
|
765
|
+
"import socket\n"
|
|
766
|
+
"def setup(host, port):\n"
|
|
767
|
+
" listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
|
|
768
|
+
" listen_socket.bind((host, port))\n"
|
|
769
|
+
" listen_socket.listen()\n"
|
|
770
|
+
" store = create_tcp_store(host, port, listen_socket=listen_socket)\n"
|
|
771
|
+
" return store\n"
|
|
772
|
+
)
|
|
773
|
+
assert _run(code, ["CH016"]) == []
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def test_ch016_still_flags_socket_used_only_as_a_call_receiver():
|
|
777
|
+
# Guard against the passed-as-argument escape swallowing real bugs:
|
|
778
|
+
# `s.connect(...)`/`s.recv(...)` use `s` as the receiver of the call, not
|
|
779
|
+
# as an argument, so this must still be flagged.
|
|
780
|
+
code = (
|
|
781
|
+
"import socket\n"
|
|
782
|
+
"def f():\n"
|
|
783
|
+
" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
|
|
784
|
+
" s.connect(('localhost', 80))\n"
|
|
785
|
+
" data = s.recv(1024)\n"
|
|
786
|
+
" print(data)\n"
|
|
787
|
+
)
|
|
788
|
+
findings = _run(code, ["CH016"])
|
|
789
|
+
assert len(findings) == 1
|
|
790
|
+
assert findings[0].code == "CH016"
|
|
791
|
+
|
|
792
|
+
|
|
727
793
|
# --- CH017 collections-abc-import ----------------------------------------------------
|
|
728
794
|
|
|
729
795
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|