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.
Files changed (33) hide show
  1. {codehound-1.4.0 → codehound-1.4.1}/PKG-INFO +36 -8
  2. {codehound-1.4.0 → codehound-1.4.1}/README.md +35 -7
  3. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/__init__.py +1 -1
  4. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/unclosed_socket.py +57 -9
  5. {codehound-1.4.0 → codehound-1.4.1}/tests/test_checks.py +66 -0
  6. {codehound-1.4.0 → codehound-1.4.1}/.gitignore +0 -0
  7. {codehound-1.4.0 → codehound-1.4.1}/LICENSE +0 -0
  8. {codehound-1.4.0 → codehound-1.4.1}/pyproject.toml +0 -0
  9. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/__init__.py +0 -0
  10. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/async_property.py +0 -0
  11. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  12. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/bare_except.py +0 -0
  13. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/blocking_async.py +0 -0
  14. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/collections_abc_import.py +0 -0
  15. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/datetime_utcnow.py +0 -0
  16. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/discarded_future.py +0 -0
  17. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/floating_process.py +0 -0
  18. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/floating_task.py +0 -0
  19. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/floating_thread.py +0 -0
  20. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/get_event_loop.py +0 -0
  21. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/loop_closure_capture.py +0 -0
  22. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/lru_cache_on_method.py +0 -0
  23. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/mutable_defaults.py +0 -0
  24. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  25. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/removed_getargspec.py +0 -0
  26. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/resource_leak.py +0 -0
  27. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/unawaited_coroutine.py +0 -0
  28. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/checks/unprotected_lock.py +0 -0
  29. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/cli.py +0 -0
  30. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/core.py +0 -0
  31. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/sarif.py +0 -0
  32. {codehound-1.4.0 → codehound-1.4.1}/src/codehound/terminal.py +0 -0
  33. {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.0
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.0
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. | hardening rulereal hits across litellm, vllm, accelerate, marimo, dspy |
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 and CH010 do - most are hardening rules for well-known Python
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 is the exception: it found a genuine, serious
173
- bug on its own, in HuggingFace's `accelerate` - see below. Building
174
- CH007-CH010 surfaced real false positives, each one fixed before shipping:
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.0
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. | hardening rulereal hits across litellm, vllm, accelerate, marimo, dspy |
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 and CH010 do - most are hardening rules for well-known Python
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 is the exception: it found a genuine, serious
153
- bug on its own, in HuggingFace's `accelerate` - see below. Building
154
- CH007-CH010 surfaced real false positives, each one fixed before shipping:
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
 
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  from codehound.checks import ALL_CHECKS, get_checks
13
13
  from codehound.core import Check, Finding, scan_file, scan_path
14
14
 
15
- __version__ = "1.4.0"
15
+ __version__ = "1.4.1"
16
16
 
17
17
  __all__ = [
18
18
  "ALL_CHECKS",
@@ -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 `return`ed ownership hand-off,
6
- leaks the file descriptor exactly like an unclosed file does - sockets are
7
- file descriptors on POSIX systems, and Python's socket module supports
8
- the context-manager protocol specifically so this doesn't happen.
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
- returns_socket = any(
74
- isinstance(n, ast.Return) and isinstance(n.value, ast.Name) and n.value.id in names
75
- for n in ast.walk(fn)
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 returns_socket or _has_close_call(fn, name):
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