silentparams 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 novasdiego1
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.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: silentparams
3
+ Version: 0.1.0
4
+ Summary: Find HTTP APIs that silently ignore misspelled query parameters
5
+ Author: novasdiego1
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/novasdiego1/silentparams
8
+ Project-URL: Issues, https://github.com/novasdiego1/silentparams/issues
9
+ Keywords: api,http,testing,query parameters,silent failures,ci
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # silentparams
23
+
24
+ Find HTTP APIs that silently ignore misspelled query parameters.
25
+
26
+ You call `GET /issues?states=closed`. The API only knows `state`. It does not
27
+ answer "unknown parameter": it answers **200 with every open issue**, and your
28
+ code (or your agent) believes it filtered. Nothing failed, so nothing alerts.
29
+
30
+ `silentparams` sends a handful of read-only requests with plausible misspellings
31
+ of each parameter in your URL and tells you, per name, whether the API rejects
32
+ it, accepts it as an alias, or ignores it without a word.
33
+
34
+ ```console
35
+ $ silentparams "https://api.github.com/repos/python/cpython/issues?state=closed&per_page=3"
36
+ Parameter 'state=closed': removing it changes the response
37
+ states HTTP 200 IGNORED silently (2xx, same as without the parameter)
38
+ STATE HTTP 200 IGNORED silently (2xx, same as without the parameter)
39
+ zzb22fbb9f HTTP 200 IGNORED silently (2xx, same as without the parameter)
40
+ ...
41
+ RESULT: this API silently ignores unknown parameter names. A typo in a filter returns unfiltered data with HTTP 200.
42
+ ```
43
+
44
+ That is not a bug in GitHub. Ignoring unknown parameters is a common design
45
+ choice. It just means **a typo in your client is invisible**, so you should know
46
+ which APIs behave this way before you trust a filtered result.
47
+
48
+ ## Install
49
+
50
+ ```console
51
+ pip install silentparams # no dependencies, Python 3.10+
52
+ # or the latest code: pip install git+https://github.com/novasdiego1/silentparams
53
+ ```
54
+
55
+ ## How it works
56
+
57
+ For each query parameter `p` in the URL:
58
+
59
+ | Request | Purpose |
60
+ |---|---|
61
+ | the URL as given, twice | baseline, and a check that the response is stable |
62
+ | the URL without `p` | what the API returns when `p` is absent |
63
+ | `p` renamed (plural/singular, trailing `_`, case, `snake`/`kebab`/`camel`, doubled letter) and one nonsense name | the decoys |
64
+
65
+ Each decoy is classified by comparing normalized response bodies (JSON is
66
+ canonicalized, so key order does not matter):
67
+
68
+ | Verdict | Meaning |
69
+ |---|---|
70
+ | `IGNORED silently` | 2xx and the same response as without `p`: the name was dropped without a word |
71
+ | `rejected` | 4xx: the API refuses unknown names (the safe behaviour) |
72
+ | `alias` | 2xx and the same response as the baseline: the variant is accepted |
73
+ | `different` | 2xx with some other response: inconclusive |
74
+
75
+ It also tells you when removing `p` changes nothing, which usually means `p`
76
+ itself is being ignored.
77
+
78
+ ## Options
79
+
80
+ ```text
81
+ -H 'Name: value' extra header (repeatable), e.g. an Authorization token
82
+ --ignore-key KEY JSON key to ignore when comparing (timestamps, request ids)
83
+ --decoys N decoy names per parameter (default 6)
84
+ --max-requests N hard cap on requests sent (default 60)
85
+ --delay S seconds between requests (default 0.5)
86
+ --json machine-readable report
87
+ ```
88
+
89
+ Exit code: `1` if the API silently ignores unknown names, `0` if not, `2` on
90
+ usage errors. That makes it usable as a CI check for an API you own.
91
+
92
+ If the same request returns different bodies (a timestamp, a request id), the
93
+ report warns that results are unreliable: add `--ignore-key` for those fields.
94
+
95
+ ## Be polite
96
+
97
+ It only sends `GET` requests, waits between them, and stops at
98
+ `--max-requests`. Point it at APIs you are allowed to query, and mind their
99
+ rate limits.
100
+
101
+ ## Why this exists
102
+
103
+ An auditor reported reaching a wrong conclusion this way. They tried four
104
+ spellings of a per-author filter (`author`, `agent`, `agent_id`, `pubkey`) on an
105
+ API that only declared `authors`. Every request returned 200 with unfiltered
106
+ rows, so they wrote that no such filter existed. The same API rejected a bad
107
+ `limit` value with 422: it checked values carefully and never checked names.
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,90 @@
1
+ # silentparams
2
+
3
+ Find HTTP APIs that silently ignore misspelled query parameters.
4
+
5
+ You call `GET /issues?states=closed`. The API only knows `state`. It does not
6
+ answer "unknown parameter": it answers **200 with every open issue**, and your
7
+ code (or your agent) believes it filtered. Nothing failed, so nothing alerts.
8
+
9
+ `silentparams` sends a handful of read-only requests with plausible misspellings
10
+ of each parameter in your URL and tells you, per name, whether the API rejects
11
+ it, accepts it as an alias, or ignores it without a word.
12
+
13
+ ```console
14
+ $ silentparams "https://api.github.com/repos/python/cpython/issues?state=closed&per_page=3"
15
+ Parameter 'state=closed': removing it changes the response
16
+ states HTTP 200 IGNORED silently (2xx, same as without the parameter)
17
+ STATE HTTP 200 IGNORED silently (2xx, same as without the parameter)
18
+ zzb22fbb9f HTTP 200 IGNORED silently (2xx, same as without the parameter)
19
+ ...
20
+ RESULT: this API silently ignores unknown parameter names. A typo in a filter returns unfiltered data with HTTP 200.
21
+ ```
22
+
23
+ That is not a bug in GitHub. Ignoring unknown parameters is a common design
24
+ choice. It just means **a typo in your client is invisible**, so you should know
25
+ which APIs behave this way before you trust a filtered result.
26
+
27
+ ## Install
28
+
29
+ ```console
30
+ pip install silentparams # no dependencies, Python 3.10+
31
+ # or the latest code: pip install git+https://github.com/novasdiego1/silentparams
32
+ ```
33
+
34
+ ## How it works
35
+
36
+ For each query parameter `p` in the URL:
37
+
38
+ | Request | Purpose |
39
+ |---|---|
40
+ | the URL as given, twice | baseline, and a check that the response is stable |
41
+ | the URL without `p` | what the API returns when `p` is absent |
42
+ | `p` renamed (plural/singular, trailing `_`, case, `snake`/`kebab`/`camel`, doubled letter) and one nonsense name | the decoys |
43
+
44
+ Each decoy is classified by comparing normalized response bodies (JSON is
45
+ canonicalized, so key order does not matter):
46
+
47
+ | Verdict | Meaning |
48
+ |---|---|
49
+ | `IGNORED silently` | 2xx and the same response as without `p`: the name was dropped without a word |
50
+ | `rejected` | 4xx: the API refuses unknown names (the safe behaviour) |
51
+ | `alias` | 2xx and the same response as the baseline: the variant is accepted |
52
+ | `different` | 2xx with some other response: inconclusive |
53
+
54
+ It also tells you when removing `p` changes nothing, which usually means `p`
55
+ itself is being ignored.
56
+
57
+ ## Options
58
+
59
+ ```text
60
+ -H 'Name: value' extra header (repeatable), e.g. an Authorization token
61
+ --ignore-key KEY JSON key to ignore when comparing (timestamps, request ids)
62
+ --decoys N decoy names per parameter (default 6)
63
+ --max-requests N hard cap on requests sent (default 60)
64
+ --delay S seconds between requests (default 0.5)
65
+ --json machine-readable report
66
+ ```
67
+
68
+ Exit code: `1` if the API silently ignores unknown names, `0` if not, `2` on
69
+ usage errors. That makes it usable as a CI check for an API you own.
70
+
71
+ If the same request returns different bodies (a timestamp, a request id), the
72
+ report warns that results are unreliable: add `--ignore-key` for those fields.
73
+
74
+ ## Be polite
75
+
76
+ It only sends `GET` requests, waits between them, and stops at
77
+ `--max-requests`. Point it at APIs you are allowed to query, and mind their
78
+ rate limits.
79
+
80
+ ## Why this exists
81
+
82
+ An auditor reported reaching a wrong conclusion this way. They tried four
83
+ spellings of a per-author filter (`author`, `agent`, `agent_id`, `pubkey`) on an
84
+ API that only declared `authors`. Every request returned 200 with unfiltered
85
+ rows, so they wrote that no such filter existed. The same API rejected a bad
86
+ `limit` value with 422: it checked values carefully and never checked names.
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "silentparams"
7
+ version = "0.1.0"
8
+ description = "Find HTTP APIs that silently ignore misspelled query parameters"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "novasdiego1"}]
13
+ keywords = ["api", "http", "testing", "query parameters", "silent failures", "ci"]
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 :: Internet :: WWW/HTTP",
21
+ "Topic :: Software Development :: Testing",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/novasdiego1/silentparams"
27
+ Issues = "https://github.com/novasdiego1/silentparams/issues"
28
+
29
+ [project.scripts]
30
+ silentparams = "silentparams.cli:main"
31
+
32
+ [tool.pytest.ini_options]
33
+ pythonpath = ["src"]
34
+ testpaths = ["tests"]
35
+
36
+ [tool.ruff]
37
+ line-length = 120
38
+ target-version = "py310"
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Find HTTP APIs that silently ignore misspelled query parameters."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from silentparams.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,76 @@
1
+ """Command line: silentparams URL [-H 'Name: value'] [--json]."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from dataclasses import asdict
8
+
9
+ from silentparams import __version__
10
+ from silentparams.core import Report, http_fetch, probe
11
+
12
+ VERDICT_TEXT = {
13
+ "ignored": "IGNORED silently (2xx, same as without the parameter)",
14
+ "rejected": "rejected (4xx)",
15
+ "alias": "accepted as an alias",
16
+ "different": "2xx, different response (inconclusive)",
17
+ "error": "server error or no response",
18
+ }
19
+
20
+
21
+ def render(report: Report) -> str:
22
+ lines = [f"URL: {report.url}", f"Baseline: HTTP {report.baseline.status}, {report.baseline.size} bytes"
23
+ + (f", {report.baseline.items} items" if report.baseline.items is not None else ""),
24
+ f"Requests sent: {report.requests}"]
25
+ if not report.stable:
26
+ lines.append("WARNING: the same request gave two different responses. Results are unreliable; "
27
+ "use --ignore-key for volatile fields (timestamps, request ids).")
28
+ for p in report.params:
29
+ effect = "changes the response" if p.effective else "does NOT change the response (ignored or no-op?)"
30
+ lines += ["", f"Parameter '{p.name}={p.value}': removing it {effect}"]
31
+ for d in p.decoys:
32
+ lines.append(f" {d.name:<24} HTTP {d.response.status:<4} {VERDICT_TEXT[d.verdict]}")
33
+ verdict = report.ignores_unknown_names
34
+ lines.append("")
35
+ if verdict is True:
36
+ lines.append("RESULT: this API silently ignores unknown parameter names. A typo in a filter returns "
37
+ "unfiltered data with HTTP 200.")
38
+ elif verdict is False:
39
+ lines.append("RESULT: this API rejects unknown parameter names. Typos fail loudly.")
40
+ else:
41
+ lines.append("RESULT: inconclusive. See the per-decoy lines above.")
42
+ return "\n".join(lines)
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ ap = argparse.ArgumentParser(prog="silentparams", description=__doc__)
47
+ ap.add_argument("url", help="a GET URL with at least one query parameter")
48
+ ap.add_argument("-H", "--header", action="append", default=[], help="extra header, 'Name: value'")
49
+ ap.add_argument("--decoys", type=int, default=6, help="decoy names per parameter (default 6)")
50
+ ap.add_argument("--max-requests", type=int, default=60, help="hard cap on requests sent (default 60)")
51
+ ap.add_argument("--delay", type=float, default=0.5, help="seconds between requests (default 0.5)")
52
+ ap.add_argument("--ignore-key", action="append", default=[], help="JSON key to ignore when comparing")
53
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
54
+ ap.add_argument("--version", action="version", version=__version__)
55
+ args = ap.parse_args(argv)
56
+ headers = {}
57
+ for h in args.header:
58
+ name, sep, value = h.partition(":")
59
+ if not sep:
60
+ ap.error(f"bad header {h!r}; use 'Name: value'")
61
+ headers[name.strip()] = value.strip()
62
+ try:
63
+ report = probe(args.url, http_fetch(headers), decoys_per_param=args.decoys,
64
+ max_requests=args.max_requests, delay=args.delay, ignore_keys=args.ignore_key)
65
+ except (ValueError, RuntimeError) as exc:
66
+ print(f"silentparams: {exc}", file=sys.stderr)
67
+ return 2
68
+ if args.json:
69
+ print(json.dumps({**asdict(report), "ignores_unknown_names": report.ignores_unknown_names}, indent=2))
70
+ else:
71
+ print(render(report))
72
+ return 1 if report.ignores_unknown_names else 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main())
@@ -0,0 +1,232 @@
1
+ """Probe an HTTP API for query parameters it silently ignores.
2
+
3
+ The failure this catches: a client sends `?author=alice` to an API that only
4
+ knows `authors`. Instead of rejecting the unknown name, the API answers 200
5
+ with the unfiltered result, and the client believes it filtered.
6
+
7
+ Method, per query parameter in the URL you give:
8
+
9
+ 1. baseline - the URL as given.
10
+ 2. without - the same URL with that parameter removed.
11
+ 3. decoys - the parameter renamed (typos, plural/singular, case, separators)
12
+ and one nonsense name, each with the same value.
13
+
14
+ A decoy that answers 2xx with the same response as `without` was ignored
15
+ silently. A decoy answered with 4xx was rejected (the safe behaviour). A decoy
16
+ that answers like `baseline` is an accepted alias.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import secrets
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+ from collections.abc import Callable, Iterable
27
+ from dataclasses import dataclass, field
28
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
29
+
30
+ USER_AGENT = "silentparams/0.1 (read-only API probe)"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Response:
35
+ status: int
36
+ fingerprint: str # hash of the normalized body
37
+ size: int
38
+ items: int | None = None # number of records, when the body is a JSON list or wraps one
39
+
40
+
41
+ @dataclass
42
+ class DecoyResult:
43
+ name: str
44
+ response: Response
45
+ verdict: str # "ignored", "rejected", "alias", "different", "error"
46
+
47
+
48
+ @dataclass
49
+ class ParamReport:
50
+ name: str
51
+ value: str
52
+ effective: bool # removing it changes the response
53
+ without: Response
54
+ decoys: list[DecoyResult] = field(default_factory=list)
55
+
56
+ @property
57
+ def silently_ignored(self) -> list[str]:
58
+ return [d.name for d in self.decoys if d.verdict == "ignored"]
59
+
60
+
61
+ @dataclass
62
+ class Report:
63
+ url: str
64
+ baseline: Response
65
+ stable: bool # the same request twice gave the same response
66
+ params: list[ParamReport]
67
+ requests: int
68
+
69
+ @property
70
+ def ignores_unknown_names(self) -> bool | None:
71
+ """True if any decoy was silently ignored, False if all were rejected, None if inconclusive."""
72
+ verdicts = [d.verdict for p in self.params for d in p.decoys]
73
+ if "ignored" in verdicts:
74
+ return True
75
+ if verdicts and all(v == "rejected" for v in verdicts):
76
+ return False
77
+ return None
78
+
79
+
80
+ # --- Decoy names ------------------------------------------------------------------------------
81
+
82
+ def _split_words(name: str) -> list[str]:
83
+ out, word = [], ""
84
+ for ch in name:
85
+ if ch in "_-.":
86
+ if word:
87
+ out.append(word)
88
+ word = ""
89
+ elif ch.isupper() and word and not word[-1].isupper():
90
+ out.append(word)
91
+ word = ch
92
+ else:
93
+ word += ch
94
+ if word:
95
+ out.append(word)
96
+ return [w.lower() for w in out]
97
+
98
+
99
+ def decoy_names(name: str, limit: int = 6, rng: Callable[[int], str] = secrets.token_hex) -> list[str]:
100
+ """Plausible mistakes for `name`, most likely first, plus one nonsense name. Never returns `name`."""
101
+ words = _split_words(name) or [name.lower()]
102
+ candidates = [
103
+ name[:-1] if name.endswith("s") and len(name) > 2 else name + "s", # authors <-> author
104
+ name + "_" if not name.endswith("_") else name.rstrip("_"),
105
+ name.upper() if name != name.upper() else name.lower(),
106
+ "_".join(words),
107
+ "-".join(words),
108
+ words[0] + "".join(w.capitalize() for w in words[1:]), # camelCase
109
+ name[:-1] + name[-1] * 2 if len(name) > 1 else name * 2, # doubled last letter
110
+ ]
111
+ seen, out = {name}, []
112
+ for c in candidates:
113
+ if c and c not in seen:
114
+ seen.add(c)
115
+ out.append(c)
116
+ out = out[: max(limit - 1, 0)]
117
+ out.append("zz" + rng(4)) # nonsense: no API should know it
118
+ return out
119
+
120
+
121
+ # --- HTTP -------------------------------------------------------------------------------------
122
+
123
+ def normalize(body: bytes, content_type: str, ignore_keys: Iterable[str] = ()) -> tuple[str, int | None]:
124
+ """Fingerprint a body. JSON is canonicalized and volatile keys dropped; anything else is hashed as is."""
125
+ ignore = set(ignore_keys)
126
+ items = None
127
+ if "json" in content_type or body[:1] in (b"{", b"["):
128
+ try:
129
+ data = json.loads(body)
130
+ except ValueError:
131
+ data = None
132
+ if data is not None:
133
+ def strip(x):
134
+ if isinstance(x, dict):
135
+ return {k: strip(v) for k, v in x.items() if k not in ignore}
136
+ if isinstance(x, list):
137
+ return [strip(v) for v in x]
138
+ return x
139
+ data = strip(data)
140
+ if isinstance(data, list):
141
+ items = len(data)
142
+ elif isinstance(data, dict):
143
+ lists = [v for v in data.values() if isinstance(v, list)]
144
+ if len(lists) == 1:
145
+ items = len(lists[0])
146
+ body = json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
147
+ return hashlib.sha256(body).hexdigest()[:16], items
148
+
149
+
150
+ Fetcher = Callable[[str], tuple[int, bytes, str]]
151
+
152
+
153
+ def http_fetch(headers: dict[str, str] | None = None, timeout: float = 20.0) -> Fetcher:
154
+ """A GET-only fetcher. Returns (status, body, content-type); HTTP errors are answers, not exceptions."""
155
+ base = {"User-Agent": USER_AGENT, "Accept": "application/json, */*;q=0.5", **(headers or {})}
156
+
157
+ def fetch(url: str) -> tuple[int, bytes, str]:
158
+ req = urllib.request.Request(url, headers=base, method="GET")
159
+ try:
160
+ with urllib.request.urlopen(req, timeout=timeout) as r:
161
+ return r.status, r.read(), r.headers.get("Content-Type", "")
162
+ except urllib.error.HTTPError as e:
163
+ return e.code, e.read(), e.headers.get("Content-Type", "") if e.headers else ""
164
+
165
+ return fetch
166
+
167
+
168
+ def with_params(url: str, params: list[tuple[str, str]]) -> str:
169
+ parts = urlsplit(url)
170
+ return urlunsplit(parts._replace(query=urlencode(params)))
171
+
172
+
173
+ # --- Probe ------------------------------------------------------------------------------------
174
+
175
+ def classify(decoy: Response, baseline: Response, without: Response) -> str:
176
+ if decoy.status >= 500 or decoy.status == 0:
177
+ return "error"
178
+ if 400 <= decoy.status < 500:
179
+ return "rejected"
180
+ same_as_without = decoy.fingerprint == without.fingerprint
181
+ same_as_baseline = decoy.fingerprint == baseline.fingerprint
182
+ if same_as_baseline and not same_as_without:
183
+ return "alias"
184
+ if same_as_without:
185
+ return "ignored"
186
+ return "different"
187
+
188
+
189
+ def probe(
190
+ url: str,
191
+ fetch: Fetcher,
192
+ *,
193
+ decoys_per_param: int = 6,
194
+ max_requests: int = 60,
195
+ delay: float = 0.5,
196
+ ignore_keys: Iterable[str] = (),
197
+ rng: Callable[[int], str] = secrets.token_hex,
198
+ ) -> Report:
199
+ params = parse_qsl(urlsplit(url).query, keep_blank_values=True)
200
+ if not params:
201
+ raise ValueError("the URL has no query parameters to probe")
202
+ ignore = list(ignore_keys)
203
+ count = 0
204
+
205
+ def get(u: str) -> Response:
206
+ nonlocal count
207
+ if count >= max_requests:
208
+ raise RuntimeError(f"request budget exhausted ({max_requests}); raise --max-requests")
209
+ if count and delay:
210
+ time.sleep(delay)
211
+ count += 1
212
+ try:
213
+ status, body, ctype = fetch(u)
214
+ except OSError:
215
+ return Response(0, "", 0)
216
+ fp, items = normalize(body, ctype, ignore)
217
+ return Response(status, fp, len(body), items)
218
+
219
+ baseline = get(url)
220
+ stable = get(url).fingerprint == baseline.fingerprint
221
+ reports = []
222
+ for i, (name, value) in enumerate(params):
223
+ rest = params[:i] + params[i + 1:]
224
+ without = get(with_params(url, rest))
225
+ report = ParamReport(name, value, effective=without.fingerprint != baseline.fingerprint, without=without)
226
+ for decoy in decoy_names(name, decoys_per_param, rng):
227
+ if any(decoy == n for n, _ in params):
228
+ continue
229
+ r = get(with_params(url, rest[:i] + [(decoy, value)] + rest[i:]))
230
+ report.decoys.append(DecoyResult(decoy, r, classify(r, baseline, without)))
231
+ reports.append(report)
232
+ return Report(url, baseline, stable, reports, count)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: silentparams
3
+ Version: 0.1.0
4
+ Summary: Find HTTP APIs that silently ignore misspelled query parameters
5
+ Author: novasdiego1
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/novasdiego1/silentparams
8
+ Project-URL: Issues, https://github.com/novasdiego1/silentparams/issues
9
+ Keywords: api,http,testing,query parameters,silent failures,ci
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # silentparams
23
+
24
+ Find HTTP APIs that silently ignore misspelled query parameters.
25
+
26
+ You call `GET /issues?states=closed`. The API only knows `state`. It does not
27
+ answer "unknown parameter": it answers **200 with every open issue**, and your
28
+ code (or your agent) believes it filtered. Nothing failed, so nothing alerts.
29
+
30
+ `silentparams` sends a handful of read-only requests with plausible misspellings
31
+ of each parameter in your URL and tells you, per name, whether the API rejects
32
+ it, accepts it as an alias, or ignores it without a word.
33
+
34
+ ```console
35
+ $ silentparams "https://api.github.com/repos/python/cpython/issues?state=closed&per_page=3"
36
+ Parameter 'state=closed': removing it changes the response
37
+ states HTTP 200 IGNORED silently (2xx, same as without the parameter)
38
+ STATE HTTP 200 IGNORED silently (2xx, same as without the parameter)
39
+ zzb22fbb9f HTTP 200 IGNORED silently (2xx, same as without the parameter)
40
+ ...
41
+ RESULT: this API silently ignores unknown parameter names. A typo in a filter returns unfiltered data with HTTP 200.
42
+ ```
43
+
44
+ That is not a bug in GitHub. Ignoring unknown parameters is a common design
45
+ choice. It just means **a typo in your client is invisible**, so you should know
46
+ which APIs behave this way before you trust a filtered result.
47
+
48
+ ## Install
49
+
50
+ ```console
51
+ pip install silentparams # no dependencies, Python 3.10+
52
+ # or the latest code: pip install git+https://github.com/novasdiego1/silentparams
53
+ ```
54
+
55
+ ## How it works
56
+
57
+ For each query parameter `p` in the URL:
58
+
59
+ | Request | Purpose |
60
+ |---|---|
61
+ | the URL as given, twice | baseline, and a check that the response is stable |
62
+ | the URL without `p` | what the API returns when `p` is absent |
63
+ | `p` renamed (plural/singular, trailing `_`, case, `snake`/`kebab`/`camel`, doubled letter) and one nonsense name | the decoys |
64
+
65
+ Each decoy is classified by comparing normalized response bodies (JSON is
66
+ canonicalized, so key order does not matter):
67
+
68
+ | Verdict | Meaning |
69
+ |---|---|
70
+ | `IGNORED silently` | 2xx and the same response as without `p`: the name was dropped without a word |
71
+ | `rejected` | 4xx: the API refuses unknown names (the safe behaviour) |
72
+ | `alias` | 2xx and the same response as the baseline: the variant is accepted |
73
+ | `different` | 2xx with some other response: inconclusive |
74
+
75
+ It also tells you when removing `p` changes nothing, which usually means `p`
76
+ itself is being ignored.
77
+
78
+ ## Options
79
+
80
+ ```text
81
+ -H 'Name: value' extra header (repeatable), e.g. an Authorization token
82
+ --ignore-key KEY JSON key to ignore when comparing (timestamps, request ids)
83
+ --decoys N decoy names per parameter (default 6)
84
+ --max-requests N hard cap on requests sent (default 60)
85
+ --delay S seconds between requests (default 0.5)
86
+ --json machine-readable report
87
+ ```
88
+
89
+ Exit code: `1` if the API silently ignores unknown names, `0` if not, `2` on
90
+ usage errors. That makes it usable as a CI check for an API you own.
91
+
92
+ If the same request returns different bodies (a timestamp, a request id), the
93
+ report warns that results are unreliable: add `--ignore-key` for those fields.
94
+
95
+ ## Be polite
96
+
97
+ It only sends `GET` requests, waits between them, and stops at
98
+ `--max-requests`. Point it at APIs you are allowed to query, and mind their
99
+ rate limits.
100
+
101
+ ## Why this exists
102
+
103
+ An auditor reported reaching a wrong conclusion this way. They tried four
104
+ spellings of a per-author filter (`author`, `agent`, `agent_id`, `pubkey`) on an
105
+ API that only declared `authors`. Every request returned 200 with unfiltered
106
+ rows, so they wrote that no such filter existed. The same API rejected a bad
107
+ `limit` value with 422: it checked values carefully and never checked names.
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/silentparams/__init__.py
5
+ src/silentparams/__main__.py
6
+ src/silentparams/cli.py
7
+ src/silentparams/core.py
8
+ src/silentparams.egg-info/PKG-INFO
9
+ src/silentparams.egg-info/SOURCES.txt
10
+ src/silentparams.egg-info/dependency_links.txt
11
+ src/silentparams.egg-info/entry_points.txt
12
+ src/silentparams.egg-info/top_level.txt
13
+ tests/test_probe.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ silentparams = silentparams.cli:main
@@ -0,0 +1 @@
1
+ silentparams
@@ -0,0 +1,117 @@
1
+ """End-to-end tests against a local server with one lax and one strict endpoint.
2
+
3
+ The lax endpoint reproduces the case that motivated the tool: it knows `authors`
4
+ and `limit`, validates their values, and silently drops any other name.
5
+ """
6
+ import json
7
+ import threading
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from urllib.parse import parse_qsl, urlsplit
10
+
11
+ import pytest
12
+
13
+ from silentparams.cli import main
14
+ from silentparams.core import Response, classify, decoy_names, http_fetch, normalize, probe
15
+
16
+ ROWS = [{"id": i, "author": ("alice" if i % 2 else "bob")} for i in range(10)]
17
+ KNOWN = {"authors", "limit"}
18
+
19
+
20
+ class Handler(BaseHTTPRequestHandler):
21
+ def log_message(self, *args):
22
+ pass
23
+
24
+ def do_GET(self):
25
+ parts = urlsplit(self.path)
26
+ q = dict(parse_qsl(parts.query))
27
+ if parts.path == "/strict" and set(q) - KNOWN:
28
+ return self._send(422, {"error": f"unknown parameter: {sorted(set(q) - KNOWN)[0]}"})
29
+ rows = ROWS
30
+ if "authors" in q:
31
+ rows = [r for r in rows if r["author"] in q["authors"].split(",")]
32
+ if "limit" in q:
33
+ if not q["limit"].isdigit() or not 0 < int(q["limit"]) <= 100:
34
+ return self._send(422, {"error": "bad limit"})
35
+ rows = rows[: int(q["limit"])]
36
+ self._send(200, {"rows": rows, "generated_at": self.server.tick()})
37
+
38
+ def _send(self, status, data):
39
+ body = json.dumps(data).encode()
40
+ self.send_response(status)
41
+ self.send_header("Content-Type", "application/json")
42
+ self.send_header("Content-Length", str(len(body)))
43
+ self.end_headers()
44
+ self.wfile.write(body)
45
+
46
+
47
+ @pytest.fixture(scope="module")
48
+ def server():
49
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
50
+ clock = iter(range(10**9))
51
+ srv.tick = lambda: next(clock) # a volatile field, like a timestamp
52
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
53
+ yield f"http://127.0.0.1:{srv.server_address[1]}"
54
+ srv.shutdown()
55
+
56
+
57
+ def fixed_rng(n):
58
+ return "ab" * n
59
+
60
+
61
+ def test_lax_api_is_caught(server):
62
+ report = probe(f"{server}/lax?authors=alice&limit=3", http_fetch(), delay=0,
63
+ ignore_keys=["generated_at"], rng=fixed_rng)
64
+ assert report.stable
65
+ assert report.ignores_unknown_names is True
66
+ authors = report.params[0]
67
+ assert authors.effective
68
+ assert "author" in authors.silently_ignored # the singular typo returns unfiltered rows with 200
69
+ assert "zzabababab" in authors.silently_ignored
70
+
71
+
72
+ def test_strict_api_passes(server):
73
+ report = probe(f"{server}/strict?authors=alice", http_fetch(), delay=0,
74
+ ignore_keys=["generated_at"], rng=fixed_rng)
75
+ assert report.ignores_unknown_names is False
76
+ assert all(d.verdict == "rejected" for d in report.params[0].decoys)
77
+
78
+
79
+ def test_volatile_field_makes_results_unstable_without_ignore_key(server):
80
+ report = probe(f"{server}/lax?authors=alice", http_fetch(), delay=0, rng=fixed_rng)
81
+ assert not report.stable
82
+
83
+
84
+ def test_request_budget_is_enforced(server):
85
+ with pytest.raises(RuntimeError, match="budget"):
86
+ probe(f"{server}/lax?authors=alice&limit=3", http_fetch(), delay=0, max_requests=4,
87
+ ignore_keys=["generated_at"])
88
+
89
+
90
+ def test_cli_exit_codes(server, capsys):
91
+ assert main([f"{server}/lax?authors=alice", "--delay", "0", "--ignore-key", "generated_at"]) == 1
92
+ assert "silently ignores unknown parameter names" in capsys.readouterr().out
93
+ assert main([f"{server}/strict?authors=alice", "--delay", "0", "--ignore-key", "generated_at"]) == 0
94
+ assert main([f"{server}/lax", "--delay", "0"]) == 2 # nothing to probe
95
+
96
+
97
+ def test_decoy_names():
98
+ names = decoy_names("authors", rng=fixed_rng)
99
+ assert names[0] == "author" and names[-1] == "zzabababab" and "authors" not in names
100
+ assert "pageSize" in decoy_names("page_size", limit=10, rng=fixed_rng)
101
+ assert "page_size" in decoy_names("pageSize", limit=10, rng=fixed_rng)
102
+ assert len(decoy_names("q", limit=3, rng=fixed_rng)) == 3
103
+
104
+
105
+ def test_normalize_ignores_key_order_and_volatile_keys():
106
+ a, n = normalize(b'{"rows":[1,2],"t":1}', "application/json", ["t"])
107
+ b, _ = normalize(b'{"t":2, "rows":[1,2]}', "application/json", ["t"])
108
+ assert a == b and n == 2
109
+
110
+
111
+ def test_classify():
112
+ base, without = Response(200, "aaa", 1), Response(200, "bbb", 1)
113
+ assert classify(Response(200, "bbb", 1), base, without) == "ignored"
114
+ assert classify(Response(422, "x", 1), base, without) == "rejected"
115
+ assert classify(Response(200, "aaa", 1), base, without) == "alias"
116
+ assert classify(Response(200, "ccc", 1), base, without) == "different"
117
+ assert classify(Response(503, "x", 1), base, without) == "error"