idemcheck 0.1.0__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.
- idemcheck-0.1.0/LICENSE +21 -0
- idemcheck-0.1.0/PKG-INFO +120 -0
- idemcheck-0.1.0/README.md +100 -0
- idemcheck-0.1.0/idemcheck/__init__.py +3 -0
- idemcheck-0.1.0/idemcheck/analyze.py +251 -0
- idemcheck-0.1.0/idemcheck/cli.py +88 -0
- idemcheck-0.1.0/idemcheck.egg-info/PKG-INFO +120 -0
- idemcheck-0.1.0/idemcheck.egg-info/SOURCES.txt +13 -0
- idemcheck-0.1.0/idemcheck.egg-info/dependency_links.txt +1 -0
- idemcheck-0.1.0/idemcheck.egg-info/entry_points.txt +2 -0
- idemcheck-0.1.0/idemcheck.egg-info/requires.txt +3 -0
- idemcheck-0.1.0/idemcheck.egg-info/top_level.txt +1 -0
- idemcheck-0.1.0/pyproject.toml +37 -0
- idemcheck-0.1.0/setup.cfg +4 -0
- idemcheck-0.1.0/tests/test_idemcheck.py +180 -0
idemcheck-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mgkvibez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
idemcheck-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idemcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find the HTTP handlers that will double-charge when a client retries.
|
|
5
|
+
Author: mgkvibez
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: idempotency,api,retries,payments,fastapi,flask,static-analysis
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# idemcheck
|
|
22
|
+
|
|
23
|
+
Find the HTTP handlers that will double-charge when a client retries.
|
|
24
|
+
|
|
25
|
+
A client retries because the network lied. If your handler writes before it can answer,
|
|
26
|
+
the retry writes again. Idempotency libraries exist for every framework, and nothing tells
|
|
27
|
+
you which of your routes is missing the guard.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
$ idemcheck examples/
|
|
31
|
+
idemcheck: 1 finding(s) across 2 mutating route(s) in 1 file(s), worst high
|
|
32
|
+
|
|
33
|
+
[HIGH] examples/app.py:12 (missing-idempotency)
|
|
34
|
+
route: POST /charge
|
|
35
|
+
why: charge with no idempotency key, dedupe store or unique constraint
|
|
36
|
+
calls: db.orders.insert_one, db.session.commit
|
|
37
|
+
fix: read an Idempotency-Key and store it before the first write, or make the write
|
|
38
|
+
itself unique so a second delivery cannot land twice
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The example file contains two POST handlers: `/charge` writes and is flagged, `/charge-safe`
|
|
42
|
+
reads an `Idempotency-Key` first and is not.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install idemcheck # once it is on PyPI
|
|
48
|
+
# or straight from the repo:
|
|
49
|
+
pipx install git+https://github.com/mgkvibez/idemcheck
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.9 or newer. No runtime dependencies.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
idemcheck # scan the current directory
|
|
58
|
+
idemcheck src/ app.py # scan specific paths
|
|
59
|
+
idemcheck --json | jq . # machine-readable
|
|
60
|
+
idemcheck --fail-on medium # stricter gate, for a payments service
|
|
61
|
+
idemcheck --include-tests # include tests/ (skipped by default)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Exit codes: `0` nothing at or above the threshold, `1` findings, `2` usage error.
|
|
65
|
+
|
|
66
|
+
`--fail-on` defaults to `high`, so a `PUT` that writes without a guard reports and exits 0
|
|
67
|
+
until you ask otherwise.
|
|
68
|
+
|
|
69
|
+
## What it looks for
|
|
70
|
+
|
|
71
|
+
A handler is a finding when it answers a mutating method **and** calls something that
|
|
72
|
+
writes **and** nothing in it mentions a guard.
|
|
73
|
+
|
|
74
|
+
| Severity | When |
|
|
75
|
+
|---|---|
|
|
76
|
+
| high | `POST`, or any outbound money/HTTP send, with unguarded writes |
|
|
77
|
+
| medium | `PUT`/`PATCH`/`DELETE` with unguarded writes (they should already be idempotent) |
|
|
78
|
+
|
|
79
|
+
Writes are recognised by method name: `commit`, `flush`, `insert*`, `update*`, `delete*`,
|
|
80
|
+
`save`, `upsert`, `execute`, `add`, plus money movement like `charge`, `capture`,
|
|
81
|
+
`transfer`, `refund`, and queue sends like `delay` and `enqueue`.
|
|
82
|
+
|
|
83
|
+
A guard is recognised by any of: `idempotency`, `idempotent`, `idem_key`, `request_id`,
|
|
84
|
+
`dedupe`, `dedup_key`, `nonce`, `client_reference`, `get_or_create`, `update_or_create`,
|
|
85
|
+
`on_conflict`, `unique_together`.
|
|
86
|
+
|
|
87
|
+
Routes are read from the decorator: `@app.post`, `@router.put`, and the
|
|
88
|
+
`methods=[...]` keyword on `@app.route` and `@app.api_route` (Flask and FastAPI).
|
|
89
|
+
|
|
90
|
+
## What this deliberately does not do
|
|
91
|
+
|
|
92
|
+
Static analysis cannot prove a handler is safe, and this tool does not claim to.
|
|
93
|
+
|
|
94
|
+
- A guard installed by a decorator defined in another module is invisible here.
|
|
95
|
+
- An ORM that commits on your behalf, or a write hidden inside a helper function, is
|
|
96
|
+
invisible here.
|
|
97
|
+
- Django class-based views, generic routers and dynamically constructed handlers are not
|
|
98
|
+
detected at all.
|
|
99
|
+
- A handler that mentions `request_id` incidentally is treated as guarded, even when it is
|
|
100
|
+
not. That is a false negative, and it is the direction this tool errs on purpose: a
|
|
101
|
+
checker that cries wolf gets uninstalled after one afternoon.
|
|
102
|
+
|
|
103
|
+
The clean line is exactly what it says: no unguarded write was **recognised**.
|
|
104
|
+
|
|
105
|
+
## Tests
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m pytest -q # 19 tests
|
|
109
|
+
bash scripts/verify.sh # the release gate
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Contributing
|
|
113
|
+
|
|
114
|
+
Adding a framework means teaching the decorator reader one more name in `analyze.py`, and
|
|
115
|
+
adding a fixture that proves both the flagged and the guarded case. A rule that cannot show
|
|
116
|
+
both is not ready.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# idemcheck
|
|
2
|
+
|
|
3
|
+
Find the HTTP handlers that will double-charge when a client retries.
|
|
4
|
+
|
|
5
|
+
A client retries because the network lied. If your handler writes before it can answer,
|
|
6
|
+
the retry writes again. Idempotency libraries exist for every framework, and nothing tells
|
|
7
|
+
you which of your routes is missing the guard.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
$ idemcheck examples/
|
|
11
|
+
idemcheck: 1 finding(s) across 2 mutating route(s) in 1 file(s), worst high
|
|
12
|
+
|
|
13
|
+
[HIGH] examples/app.py:12 (missing-idempotency)
|
|
14
|
+
route: POST /charge
|
|
15
|
+
why: charge with no idempotency key, dedupe store or unique constraint
|
|
16
|
+
calls: db.orders.insert_one, db.session.commit
|
|
17
|
+
fix: read an Idempotency-Key and store it before the first write, or make the write
|
|
18
|
+
itself unique so a second delivery cannot land twice
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The example file contains two POST handlers: `/charge` writes and is flagged, `/charge-safe`
|
|
22
|
+
reads an `Idempotency-Key` first and is not.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pipx install idemcheck # once it is on PyPI
|
|
28
|
+
# or straight from the repo:
|
|
29
|
+
pipx install git+https://github.com/mgkvibez/idemcheck
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Python 3.9 or newer. No runtime dependencies.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
idemcheck # scan the current directory
|
|
38
|
+
idemcheck src/ app.py # scan specific paths
|
|
39
|
+
idemcheck --json | jq . # machine-readable
|
|
40
|
+
idemcheck --fail-on medium # stricter gate, for a payments service
|
|
41
|
+
idemcheck --include-tests # include tests/ (skipped by default)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Exit codes: `0` nothing at or above the threshold, `1` findings, `2` usage error.
|
|
45
|
+
|
|
46
|
+
`--fail-on` defaults to `high`, so a `PUT` that writes without a guard reports and exits 0
|
|
47
|
+
until you ask otherwise.
|
|
48
|
+
|
|
49
|
+
## What it looks for
|
|
50
|
+
|
|
51
|
+
A handler is a finding when it answers a mutating method **and** calls something that
|
|
52
|
+
writes **and** nothing in it mentions a guard.
|
|
53
|
+
|
|
54
|
+
| Severity | When |
|
|
55
|
+
|---|---|
|
|
56
|
+
| high | `POST`, or any outbound money/HTTP send, with unguarded writes |
|
|
57
|
+
| medium | `PUT`/`PATCH`/`DELETE` with unguarded writes (they should already be idempotent) |
|
|
58
|
+
|
|
59
|
+
Writes are recognised by method name: `commit`, `flush`, `insert*`, `update*`, `delete*`,
|
|
60
|
+
`save`, `upsert`, `execute`, `add`, plus money movement like `charge`, `capture`,
|
|
61
|
+
`transfer`, `refund`, and queue sends like `delay` and `enqueue`.
|
|
62
|
+
|
|
63
|
+
A guard is recognised by any of: `idempotency`, `idempotent`, `idem_key`, `request_id`,
|
|
64
|
+
`dedupe`, `dedup_key`, `nonce`, `client_reference`, `get_or_create`, `update_or_create`,
|
|
65
|
+
`on_conflict`, `unique_together`.
|
|
66
|
+
|
|
67
|
+
Routes are read from the decorator: `@app.post`, `@router.put`, and the
|
|
68
|
+
`methods=[...]` keyword on `@app.route` and `@app.api_route` (Flask and FastAPI).
|
|
69
|
+
|
|
70
|
+
## What this deliberately does not do
|
|
71
|
+
|
|
72
|
+
Static analysis cannot prove a handler is safe, and this tool does not claim to.
|
|
73
|
+
|
|
74
|
+
- A guard installed by a decorator defined in another module is invisible here.
|
|
75
|
+
- An ORM that commits on your behalf, or a write hidden inside a helper function, is
|
|
76
|
+
invisible here.
|
|
77
|
+
- Django class-based views, generic routers and dynamically constructed handlers are not
|
|
78
|
+
detected at all.
|
|
79
|
+
- A handler that mentions `request_id` incidentally is treated as guarded, even when it is
|
|
80
|
+
not. That is a false negative, and it is the direction this tool errs on purpose: a
|
|
81
|
+
checker that cries wolf gets uninstalled after one afternoon.
|
|
82
|
+
|
|
83
|
+
The clean line is exactly what it says: no unguarded write was **recognised**.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python -m pytest -q # 19 tests
|
|
89
|
+
bash scripts/verify.sh # the release gate
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Contributing
|
|
93
|
+
|
|
94
|
+
Adding a framework means teaching the decorator reader one more name in `analyze.py`, and
|
|
95
|
+
adding a fixture that proves both the flagged and the guarded case. A rule that cannot show
|
|
96
|
+
both is not ready.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Find mutating routes whose writes have no protection against a second delivery.
|
|
2
|
+
|
|
3
|
+
A client retries because the network lied. If the handler charges, inserts, or sends
|
|
4
|
+
before it can answer, the retry charges twice. Libraries that solve this exist per
|
|
5
|
+
framework; nothing tells you which of your routes is missing the guard.
|
|
6
|
+
|
|
7
|
+
This is static analysis over the AST, so it can only see what is written down. A guard
|
|
8
|
+
built by a decorator defined in another module, or an ORM method that commits for you,
|
|
9
|
+
is invisible. The README says so instead of implying the tool proves safety.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
MUTATING = ("POST", "PUT", "PATCH", "DELETE")
|
|
20
|
+
|
|
21
|
+
#: Methods that end in a write, anywhere in the codebase.
|
|
22
|
+
WRITE_METHODS = frozenset({
|
|
23
|
+
"commit", "flush", "save", "insert", "insert_one", "insert_many", "update",
|
|
24
|
+
"update_one", "upsert", "delete", "delete_one", "execute", "create", "add",
|
|
25
|
+
"charge", "capture", "transfer", "refund", "enqueue", "delay", "send_message",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
#: Outbound calls that move money or send something a retry would duplicate.
|
|
29
|
+
OUTBOUND = ("requests.post", "requests.put", "httpx.post", "httpx.put", "aiohttp.",
|
|
30
|
+
"stripe.", "session.post", "client.post", "urlopen")
|
|
31
|
+
|
|
32
|
+
#: Any of these in the handler means somebody thought about the retry.
|
|
33
|
+
PROTECTION = ("idempotency", "idempotent", "idem_key", "idemkey", "request_id",
|
|
34
|
+
"requestid", "dedupe", "dedup_key", "nonce", "client_reference",
|
|
35
|
+
"get_or_create", "update_or_create", "on_conflict", "unique_together")
|
|
36
|
+
|
|
37
|
+
ROUTE_DECORATORS = frozenset({"post", "put", "patch", "delete", "route", "api_route"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Route:
|
|
42
|
+
"""A handler and the mutating methods it answers."""
|
|
43
|
+
|
|
44
|
+
line: int
|
|
45
|
+
methods: Tuple[str, ...]
|
|
46
|
+
name: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Finding:
|
|
51
|
+
path: str
|
|
52
|
+
line: int
|
|
53
|
+
rule: str
|
|
54
|
+
severity: str
|
|
55
|
+
route: str
|
|
56
|
+
detail: str
|
|
57
|
+
writes: List[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict:
|
|
60
|
+
return {"path": self.path, "line": self.line, "rule": self.rule,
|
|
61
|
+
"severity": self.severity, "route": self.route,
|
|
62
|
+
"detail": self.detail, "writes": self.writes}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Report:
|
|
67
|
+
files: int = 0
|
|
68
|
+
routes: int = 0
|
|
69
|
+
findings: List[Finding] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _dotted(node: ast.AST) -> str:
|
|
73
|
+
"""The dotted name of a call target, or '' when it is not a plain name."""
|
|
74
|
+
parts = []
|
|
75
|
+
while isinstance(node, ast.Attribute):
|
|
76
|
+
parts.append(node.attr)
|
|
77
|
+
node = node.value
|
|
78
|
+
if isinstance(node, ast.Name):
|
|
79
|
+
parts.append(node.id)
|
|
80
|
+
return ".".join(reversed(parts))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _methods_from_decorator(node: ast.AST) -> Optional[Tuple[str, ...]]:
|
|
84
|
+
"""Return the mutating methods a decorator registers, or None if it is not a route."""
|
|
85
|
+
if not isinstance(node, ast.Call):
|
|
86
|
+
return None
|
|
87
|
+
target = _dotted(node.func).rsplit(".", 1)[-1]
|
|
88
|
+
if target not in ROUTE_DECORATORS:
|
|
89
|
+
return None
|
|
90
|
+
if target in ("route", "api_route"):
|
|
91
|
+
for kw in node.keywords:
|
|
92
|
+
if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
93
|
+
names = [e.value.upper() for e in kw.value.elts
|
|
94
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str)]
|
|
95
|
+
picked = tuple(m for m in names if m in MUTATING)
|
|
96
|
+
return picked or None
|
|
97
|
+
return None
|
|
98
|
+
method = target.upper()
|
|
99
|
+
return (method,) if method in MUTATING else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strings_and_names(func: ast.AST) -> str:
|
|
103
|
+
"""Every identifier and string literal in the handler, lowercased, for guard hunting."""
|
|
104
|
+
chunks: List[str] = []
|
|
105
|
+
for node in ast.walk(func):
|
|
106
|
+
if isinstance(node, ast.Name):
|
|
107
|
+
chunks.append(node.id)
|
|
108
|
+
elif isinstance(node, ast.Attribute):
|
|
109
|
+
chunks.append(node.attr)
|
|
110
|
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
111
|
+
chunks.append(node.value)
|
|
112
|
+
elif isinstance(node, ast.keyword) and node.arg:
|
|
113
|
+
chunks.append(node.arg)
|
|
114
|
+
return " ".join(chunks).lower()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _writes(func: ast.AST) -> List[str]:
|
|
118
|
+
"""Dotted names of calls that look like writes or outbound sends."""
|
|
119
|
+
found: List[str] = []
|
|
120
|
+
for node in ast.walk(func):
|
|
121
|
+
if not isinstance(node, ast.Call):
|
|
122
|
+
continue
|
|
123
|
+
name = _dotted(node.func)
|
|
124
|
+
short = name.rsplit(".", 1)[-1]
|
|
125
|
+
low = name.lower()
|
|
126
|
+
if short in WRITE_METHODS:
|
|
127
|
+
found.append(name or short)
|
|
128
|
+
elif any(marker in low for marker in OUTBOUND):
|
|
129
|
+
found.append(name or low)
|
|
130
|
+
elif short == "execute" and any(
|
|
131
|
+
kw in low for kw in ("insert", "update", "delete")
|
|
132
|
+
):
|
|
133
|
+
found.append(name)
|
|
134
|
+
# dedupe, keep first-seen order
|
|
135
|
+
seen: List[str] = []
|
|
136
|
+
for name in found:
|
|
137
|
+
if name not in seen:
|
|
138
|
+
seen.append(name)
|
|
139
|
+
return seen
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _docstring_node(func: ast.AST) -> Optional[ast.AST]:
|
|
143
|
+
body = getattr(func, "body", [])
|
|
144
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
|
|
145
|
+
and isinstance(body[0].value.value, str):
|
|
146
|
+
return body[0]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _has_guard(func: ast.AST) -> bool:
|
|
151
|
+
"""Is there any sign that a second delivery was considered?
|
|
152
|
+
|
|
153
|
+
The handler's own docstring is excluded: a docstring that says "TODO: idempotency"
|
|
154
|
+
is evidence of the bug, not of the fix.
|
|
155
|
+
"""
|
|
156
|
+
doc = _docstring_node(func)
|
|
157
|
+
doc_text = doc.value if isinstance(doc, ast.Expr) else None
|
|
158
|
+
skip = {id(doc), id(doc_text)}
|
|
159
|
+
chunks: List[str] = []
|
|
160
|
+
for node in ast.walk(func):
|
|
161
|
+
if id(node) in skip:
|
|
162
|
+
continue
|
|
163
|
+
if isinstance(node, ast.Name):
|
|
164
|
+
chunks.append(node.id)
|
|
165
|
+
elif isinstance(node, ast.Attribute):
|
|
166
|
+
chunks.append(node.attr)
|
|
167
|
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
168
|
+
chunks.append(node.value)
|
|
169
|
+
elif isinstance(node, ast.keyword) and node.arg:
|
|
170
|
+
chunks.append(node.arg)
|
|
171
|
+
text = " ".join(chunks).lower()
|
|
172
|
+
return any(token in text for token in PROTECTION)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _route_path(dec: ast.AST) -> str:
|
|
176
|
+
if isinstance(dec, ast.Call) and dec.args:
|
|
177
|
+
first = dec.args[0]
|
|
178
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
179
|
+
return first.value
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def analyze_source(source: str, path: str = "<src>") -> Tuple[int, List[Finding]]:
|
|
184
|
+
"""Return (mutating routes seen, findings) for one source file."""
|
|
185
|
+
try:
|
|
186
|
+
tree = ast.parse(source)
|
|
187
|
+
except SyntaxError:
|
|
188
|
+
return 0, []
|
|
189
|
+
routes = 0
|
|
190
|
+
findings: List[Finding] = []
|
|
191
|
+
for node in ast.walk(tree):
|
|
192
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
193
|
+
continue
|
|
194
|
+
methods: List[str] = []
|
|
195
|
+
label = ""
|
|
196
|
+
for dec in node.decorator_list:
|
|
197
|
+
picked = _methods_from_decorator(dec)
|
|
198
|
+
if picked:
|
|
199
|
+
methods.extend(picked)
|
|
200
|
+
label = label or _route_path(dec)
|
|
201
|
+
if not methods:
|
|
202
|
+
continue
|
|
203
|
+
routes += 1
|
|
204
|
+
writes = _writes(node)
|
|
205
|
+
if not writes or _has_guard(node):
|
|
206
|
+
continue
|
|
207
|
+
outbound = any(any(m in w.lower() for m in OUTBOUND) for w in writes)
|
|
208
|
+
severity = "high" if (outbound or "POST" in methods) else "medium"
|
|
209
|
+
route = "%s %s" % ("|".join(sorted(set(methods))), label or node.name)
|
|
210
|
+
findings.append(Finding(
|
|
211
|
+
path=path, line=node.lineno, rule="missing-idempotency", severity=severity,
|
|
212
|
+
route=route,
|
|
213
|
+
detail="%s with no idempotency key, dedupe store or unique constraint"
|
|
214
|
+
% node.name,
|
|
215
|
+
writes=writes,
|
|
216
|
+
))
|
|
217
|
+
return routes, findings
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _looks_like_tests(path: Path) -> bool:
|
|
221
|
+
parts = set(path.parts)
|
|
222
|
+
return "tests" in parts or path.name.startswith("test_") or path.name == "conftest.py"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def analyze_files(paths: Iterable[Path], include_tests: bool = False) -> Report:
|
|
226
|
+
report = Report()
|
|
227
|
+
seen: set = set()
|
|
228
|
+
for base in paths:
|
|
229
|
+
files: Sequence[Path]
|
|
230
|
+
if base.is_dir():
|
|
231
|
+
files = sorted(p for p in base.rglob("*.py")
|
|
232
|
+
if not any(skip in p.parts for skip in
|
|
233
|
+
(".git", "build", "dist", "node_modules", "__pycache__")))
|
|
234
|
+
else:
|
|
235
|
+
files = [base]
|
|
236
|
+
for path in files:
|
|
237
|
+
key = str(path.resolve())
|
|
238
|
+
if key in seen or (not include_tests and _looks_like_tests(path)):
|
|
239
|
+
continue
|
|
240
|
+
seen.add(key)
|
|
241
|
+
try:
|
|
242
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
243
|
+
except OSError:
|
|
244
|
+
continue
|
|
245
|
+
routes, findings = analyze_source(source, str(path))
|
|
246
|
+
report.files += 1
|
|
247
|
+
report.routes += routes
|
|
248
|
+
report.findings.extend(findings)
|
|
249
|
+
report.findings.sort(key=lambda f: ({"high": 0, "medium": 1, "low": 2}[f.severity],
|
|
250
|
+
f.path, f.line))
|
|
251
|
+
return report
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The command line: read paths, print findings, and answer with an exit code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .analyze import analyze_files
|
|
13
|
+
|
|
14
|
+
EXIT_OK = 0
|
|
15
|
+
EXIT_FINDINGS = 1
|
|
16
|
+
EXIT_USAGE = 2
|
|
17
|
+
|
|
18
|
+
SEVERITIES = ("high", "medium", "low")
|
|
19
|
+
SEVERITY_RANK = {name: index for index, name in enumerate(SEVERITIES)}
|
|
20
|
+
|
|
21
|
+
FIX_HINT = (
|
|
22
|
+
"read an Idempotency-Key and store it before the first write, or make the write\n"
|
|
23
|
+
" itself unique so a second delivery cannot land twice"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render(findings: List[dict], files: int, routes: int) -> str:
|
|
28
|
+
if not findings:
|
|
29
|
+
return ("No issues found by idemcheck v%s across %d mutating route(s) in %d file(s).\n"
|
|
30
|
+
"That means no unguarded write was recognised, not that every handler is safe."
|
|
31
|
+
% (__version__, routes, files))
|
|
32
|
+
|
|
33
|
+
worst = min((f["severity"] for f in findings), key=lambda s: SEVERITY_RANK[s])
|
|
34
|
+
lines = ["idemcheck: %d finding(s) across %d mutating route(s) in %d file(s), worst %s"
|
|
35
|
+
% (len(findings), routes, files, worst), ""]
|
|
36
|
+
for f in findings:
|
|
37
|
+
lines.append("[%s] %s:%d (%s)" % (f["severity"].upper(), f["path"], f["line"], f["rule"]))
|
|
38
|
+
lines.append(" route: %s" % f["route"])
|
|
39
|
+
lines.append(" why: %s" % f["detail"])
|
|
40
|
+
if f["writes"]:
|
|
41
|
+
lines.append(" calls: %s" % ", ".join(f["writes"]))
|
|
42
|
+
lines.append(" fix: %s" % FIX_HINT)
|
|
43
|
+
lines.append("")
|
|
44
|
+
return "\n".join(lines).rstrip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="idemcheck",
|
|
50
|
+
description="Find the HTTP handlers that will double-charge when a client retries.",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument("paths", nargs="*", default=["."],
|
|
53
|
+
help="files or directories to scan (default: .)")
|
|
54
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
55
|
+
parser.add_argument("--fail-on", choices=("high", "medium", "low", "none"), default="high",
|
|
56
|
+
help="lowest severity that fails the run (default: high)")
|
|
57
|
+
parser.add_argument("--include-tests", action="store_true",
|
|
58
|
+
help="also scan tests/ and test_*.py")
|
|
59
|
+
parser.add_argument("--version", action="version", version="idemcheck %s" % __version__)
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
targets = [Path(p) for p in args.paths]
|
|
63
|
+
missing = [str(p) for p in targets if not p.exists()]
|
|
64
|
+
if missing:
|
|
65
|
+
print("no such path: %s" % ", ".join(missing), file=sys.stderr)
|
|
66
|
+
return EXIT_USAGE
|
|
67
|
+
|
|
68
|
+
report = analyze_files(targets, include_tests=args.include_tests)
|
|
69
|
+
findings = [f.to_dict() for f in report.findings]
|
|
70
|
+
|
|
71
|
+
if args.json:
|
|
72
|
+
print(json.dumps({
|
|
73
|
+
"findings": findings,
|
|
74
|
+
"summary": {"files": report.files, "routes": report.routes,
|
|
75
|
+
"findings": len(findings), "fail_on": args.fail_on},
|
|
76
|
+
}, indent=2))
|
|
77
|
+
else:
|
|
78
|
+
print(render(findings, report.files, report.routes))
|
|
79
|
+
|
|
80
|
+
if args.fail_on == "none" or not findings:
|
|
81
|
+
return EXIT_OK
|
|
82
|
+
limit = SEVERITY_RANK[args.fail_on]
|
|
83
|
+
return EXIT_FINDINGS if any(SEVERITY_RANK[f["severity"]] <= limit for f in findings) \
|
|
84
|
+
else EXIT_OK
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__": # so `python -m idemcheck.cli` works too
|
|
88
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idemcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find the HTTP handlers that will double-charge when a client retries.
|
|
5
|
+
Author: mgkvibez
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: idempotency,api,retries,payments,fastapi,flask,static-analysis
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# idemcheck
|
|
22
|
+
|
|
23
|
+
Find the HTTP handlers that will double-charge when a client retries.
|
|
24
|
+
|
|
25
|
+
A client retries because the network lied. If your handler writes before it can answer,
|
|
26
|
+
the retry writes again. Idempotency libraries exist for every framework, and nothing tells
|
|
27
|
+
you which of your routes is missing the guard.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
$ idemcheck examples/
|
|
31
|
+
idemcheck: 1 finding(s) across 2 mutating route(s) in 1 file(s), worst high
|
|
32
|
+
|
|
33
|
+
[HIGH] examples/app.py:12 (missing-idempotency)
|
|
34
|
+
route: POST /charge
|
|
35
|
+
why: charge with no idempotency key, dedupe store or unique constraint
|
|
36
|
+
calls: db.orders.insert_one, db.session.commit
|
|
37
|
+
fix: read an Idempotency-Key and store it before the first write, or make the write
|
|
38
|
+
itself unique so a second delivery cannot land twice
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The example file contains two POST handlers: `/charge` writes and is flagged, `/charge-safe`
|
|
42
|
+
reads an `Idempotency-Key` first and is not.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install idemcheck # once it is on PyPI
|
|
48
|
+
# or straight from the repo:
|
|
49
|
+
pipx install git+https://github.com/mgkvibez/idemcheck
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.9 or newer. No runtime dependencies.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
idemcheck # scan the current directory
|
|
58
|
+
idemcheck src/ app.py # scan specific paths
|
|
59
|
+
idemcheck --json | jq . # machine-readable
|
|
60
|
+
idemcheck --fail-on medium # stricter gate, for a payments service
|
|
61
|
+
idemcheck --include-tests # include tests/ (skipped by default)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Exit codes: `0` nothing at or above the threshold, `1` findings, `2` usage error.
|
|
65
|
+
|
|
66
|
+
`--fail-on` defaults to `high`, so a `PUT` that writes without a guard reports and exits 0
|
|
67
|
+
until you ask otherwise.
|
|
68
|
+
|
|
69
|
+
## What it looks for
|
|
70
|
+
|
|
71
|
+
A handler is a finding when it answers a mutating method **and** calls something that
|
|
72
|
+
writes **and** nothing in it mentions a guard.
|
|
73
|
+
|
|
74
|
+
| Severity | When |
|
|
75
|
+
|---|---|
|
|
76
|
+
| high | `POST`, or any outbound money/HTTP send, with unguarded writes |
|
|
77
|
+
| medium | `PUT`/`PATCH`/`DELETE` with unguarded writes (they should already be idempotent) |
|
|
78
|
+
|
|
79
|
+
Writes are recognised by method name: `commit`, `flush`, `insert*`, `update*`, `delete*`,
|
|
80
|
+
`save`, `upsert`, `execute`, `add`, plus money movement like `charge`, `capture`,
|
|
81
|
+
`transfer`, `refund`, and queue sends like `delay` and `enqueue`.
|
|
82
|
+
|
|
83
|
+
A guard is recognised by any of: `idempotency`, `idempotent`, `idem_key`, `request_id`,
|
|
84
|
+
`dedupe`, `dedup_key`, `nonce`, `client_reference`, `get_or_create`, `update_or_create`,
|
|
85
|
+
`on_conflict`, `unique_together`.
|
|
86
|
+
|
|
87
|
+
Routes are read from the decorator: `@app.post`, `@router.put`, and the
|
|
88
|
+
`methods=[...]` keyword on `@app.route` and `@app.api_route` (Flask and FastAPI).
|
|
89
|
+
|
|
90
|
+
## What this deliberately does not do
|
|
91
|
+
|
|
92
|
+
Static analysis cannot prove a handler is safe, and this tool does not claim to.
|
|
93
|
+
|
|
94
|
+
- A guard installed by a decorator defined in another module is invisible here.
|
|
95
|
+
- An ORM that commits on your behalf, or a write hidden inside a helper function, is
|
|
96
|
+
invisible here.
|
|
97
|
+
- Django class-based views, generic routers and dynamically constructed handlers are not
|
|
98
|
+
detected at all.
|
|
99
|
+
- A handler that mentions `request_id` incidentally is treated as guarded, even when it is
|
|
100
|
+
not. That is a false negative, and it is the direction this tool errs on purpose: a
|
|
101
|
+
checker that cries wolf gets uninstalled after one afternoon.
|
|
102
|
+
|
|
103
|
+
The clean line is exactly what it says: no unguarded write was **recognised**.
|
|
104
|
+
|
|
105
|
+
## Tests
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m pytest -q # 19 tests
|
|
109
|
+
bash scripts/verify.sh # the release gate
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Contributing
|
|
113
|
+
|
|
114
|
+
Adding a framework means teaching the decorator reader one more name in `analyze.py`, and
|
|
115
|
+
adding a fixture that proves both the flagged and the guarded case. A rule that cannot show
|
|
116
|
+
both is not ready.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
idemcheck/__init__.py
|
|
5
|
+
idemcheck/analyze.py
|
|
6
|
+
idemcheck/cli.py
|
|
7
|
+
idemcheck.egg-info/PKG-INFO
|
|
8
|
+
idemcheck.egg-info/SOURCES.txt
|
|
9
|
+
idemcheck.egg-info/dependency_links.txt
|
|
10
|
+
idemcheck.egg-info/entry_points.txt
|
|
11
|
+
idemcheck.egg-info/requires.txt
|
|
12
|
+
idemcheck.egg-info/top_level.txt
|
|
13
|
+
tests/test_idemcheck.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
idemcheck
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "idemcheck"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find the HTTP handlers that will double-charge when a client retries."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "mgkvibez" }]
|
|
13
|
+
keywords = ["idempotency", "api", "retries", "payments", "fastapi", "flask", "static-analysis"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["pytest>=8"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
idemcheck = "idemcheck.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["idemcheck"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
target-version = "py39"
|
|
35
|
+
line-length = 100
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "W", "I"]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The analyser and the CLI contract."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from idemcheck.analyze import analyze_files, analyze_source
|
|
8
|
+
from idemcheck.cli import EXIT_FINDINGS, EXIT_OK, EXIT_USAGE, main
|
|
9
|
+
|
|
10
|
+
CHARGE = '''
|
|
11
|
+
@app.post("/charge")
|
|
12
|
+
def charge():
|
|
13
|
+
db.session.add(order)
|
|
14
|
+
db.session.commit()
|
|
15
|
+
'''
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_a_post_that_writes_with_no_guard_is_high():
|
|
19
|
+
routes, findings = analyze_source(CHARGE)
|
|
20
|
+
assert routes == 1
|
|
21
|
+
assert [f.severity for f in findings] == ["high"]
|
|
22
|
+
assert findings[0].rule == "missing-idempotency"
|
|
23
|
+
assert "db.session.commit" in findings[0].writes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_a_guard_anywhere_in_the_handler_clears_it():
|
|
27
|
+
src = '''
|
|
28
|
+
@app.post("/charge")
|
|
29
|
+
def charge():
|
|
30
|
+
key = request.headers.get("Idempotency-Key")
|
|
31
|
+
db.session.commit()
|
|
32
|
+
'''
|
|
33
|
+
assert analyze_source(src)[1] == []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_a_docstring_saying_todo_idempotency_is_the_bug_not_the_fix():
|
|
37
|
+
"""The first cut skipped the docstring node but not its string constant."""
|
|
38
|
+
src = '''
|
|
39
|
+
@app.post("/charge")
|
|
40
|
+
def charge():
|
|
41
|
+
"""Charge the card. TODO: idempotency."""
|
|
42
|
+
db.session.commit()
|
|
43
|
+
'''
|
|
44
|
+
findings = analyze_source(src)[1]
|
|
45
|
+
assert [f.severity for f in findings] == ["high"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_put_and_patch_are_medium_because_they_should_already_be_idempotent():
|
|
49
|
+
src = '''
|
|
50
|
+
@app.put("/order")
|
|
51
|
+
def update_order():
|
|
52
|
+
db.session.commit()
|
|
53
|
+
'''
|
|
54
|
+
findings = analyze_source(src)[1]
|
|
55
|
+
assert [f.severity for f in findings] == ["medium"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_a_read_only_route_is_not_a_finding():
|
|
59
|
+
src = '''
|
|
60
|
+
@app.get("/orders")
|
|
61
|
+
def orders():
|
|
62
|
+
return db.orders.find({})
|
|
63
|
+
'''
|
|
64
|
+
routes, findings = analyze_source(src)
|
|
65
|
+
assert routes == 0 # GET is not a mutating route
|
|
66
|
+
assert findings == []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_a_mutating_route_that_only_reads_is_not_a_finding():
|
|
70
|
+
src = '''
|
|
71
|
+
@app.post("/preview")
|
|
72
|
+
def preview():
|
|
73
|
+
return db.orders.find_one({"id": 1})
|
|
74
|
+
'''
|
|
75
|
+
routes, findings = analyze_source(src)
|
|
76
|
+
assert routes == 1
|
|
77
|
+
assert findings == []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_api_route_methods_keyword_is_understood():
|
|
81
|
+
src = '''
|
|
82
|
+
@app.api_route("/charge", methods=["POST", "GET"])
|
|
83
|
+
def charge():
|
|
84
|
+
db.session.commit()
|
|
85
|
+
'''
|
|
86
|
+
findings = analyze_source(src)[1]
|
|
87
|
+
assert len(findings) == 1 and findings[0].severity == "high"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_flask_route_methods_keyword_is_understood():
|
|
91
|
+
src = '''
|
|
92
|
+
@app.route("/charge", methods=["POST"])
|
|
93
|
+
def charge():
|
|
94
|
+
db.session.commit()
|
|
95
|
+
'''
|
|
96
|
+
assert len(analyze_source(src)[1]) == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_flask_route_defaulting_to_get_is_not_a_finding():
|
|
100
|
+
src = '''
|
|
101
|
+
@app.route("/health")
|
|
102
|
+
def health():
|
|
103
|
+
db.session.commit()
|
|
104
|
+
'''
|
|
105
|
+
assert analyze_source(src)[1] == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_one_finding_per_handler_however_many_writes():
|
|
109
|
+
src = '''
|
|
110
|
+
@app.post("/checkout")
|
|
111
|
+
def checkout():
|
|
112
|
+
db.session.add(a)
|
|
113
|
+
db.session.commit()
|
|
114
|
+
payments.charge_card(a)
|
|
115
|
+
queue.delay(a)
|
|
116
|
+
'''
|
|
117
|
+
findings = analyze_source(src)[1]
|
|
118
|
+
assert len(findings) == 1
|
|
119
|
+
assert len(findings[0].writes) >= 3
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_a_syntax_error_file_is_skipped_not_fatal():
|
|
123
|
+
assert analyze_source("def broken(:\n") == (0, [])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_tests_are_excluded_by_default(tmp_path):
|
|
127
|
+
(tmp_path / "tests").mkdir()
|
|
128
|
+
(tmp_path / "tests" / "test_api.py").write_text(CHARGE)
|
|
129
|
+
(tmp_path / "app.py").write_text(CHARGE)
|
|
130
|
+
|
|
131
|
+
assert analyze_files([tmp_path]).files == 1
|
|
132
|
+
assert len(analyze_files([tmp_path]).findings) == 1
|
|
133
|
+
assert analyze_files([tmp_path], include_tests=True).files == 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- CLI contract -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_cli_exits_1_on_a_high_finding(tmp_path, capsys):
|
|
140
|
+
(tmp_path / "app.py").write_text(CHARGE)
|
|
141
|
+
assert main([str(tmp_path)]) == EXIT_FINDINGS
|
|
142
|
+
assert "missing-idempotency" in capsys.readouterr().out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_cli_exits_0_when_nothing_is_unguarded(tmp_path, capsys):
|
|
146
|
+
(tmp_path / "app.py").write_text('@app.get("/x")\ndef x():\n return 1\n')
|
|
147
|
+
assert main([str(tmp_path)]) == EXIT_OK
|
|
148
|
+
out = capsys.readouterr().out
|
|
149
|
+
assert "No issues found by idemcheck v" in out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_fail_on_none_never_fails_the_run(tmp_path):
|
|
153
|
+
(tmp_path / "app.py").write_text(CHARGE)
|
|
154
|
+
assert main([str(tmp_path), "--fail-on", "none"]) == EXIT_OK
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_a_medium_finding_does_not_fail_the_default_threshold(tmp_path):
|
|
158
|
+
(tmp_path / "app.py").write_text('@app.put("/x")\ndef x():\n db.session.commit()\n')
|
|
159
|
+
assert main([str(tmp_path)]) == EXIT_OK
|
|
160
|
+
assert main([str(tmp_path), "--fail-on", "medium"]) == EXIT_FINDINGS
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_json_output_is_parseable(tmp_path, capsys):
|
|
164
|
+
(tmp_path / "app.py").write_text(CHARGE)
|
|
165
|
+
main([str(tmp_path), "--json"])
|
|
166
|
+
payload = json.loads(capsys.readouterr().out)
|
|
167
|
+
assert payload["summary"]["findings"] == 1
|
|
168
|
+
assert payload["findings"][0]["severity"] == "high"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_a_missing_path_is_a_usage_error(capsys):
|
|
172
|
+
assert main(["/no/such/path/here"]) == EXIT_USAGE
|
|
173
|
+
assert "no such path" in capsys.readouterr().err
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_version_exits_cleanly(capsys):
|
|
177
|
+
with pytest.raises(SystemExit) as exc:
|
|
178
|
+
main(["--version"])
|
|
179
|
+
assert exc.value.code == 0
|
|
180
|
+
assert capsys.readouterr().out.startswith("idemcheck ")
|