api-medic 1.0.0__py3-none-any.whl
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.
- api_medic/__init__.py +3 -0
- api_medic/cli/__init__.py +1 -0
- api_medic/cli/main.py +306 -0
- api_medic/core/__init__.py +23 -0
- api_medic/core/captured.py +94 -0
- api_medic/core/checks/__init__.py +52 -0
- api_medic/core/checks/auth.py +248 -0
- api_medic/core/checks/body.py +139 -0
- api_medic/core/checks/http.py +208 -0
- api_medic/core/checks/network.py +215 -0
- api_medic/core/checks/rate_limit.py +129 -0
- api_medic/core/engine.py +72 -0
- api_medic/core/export_schema.py +24 -0
- api_medic/core/models.py +139 -0
- api_medic/core/parser.py +141 -0
- api_medic/core/render/__init__.py +8 -0
- api_medic/core/render/html.py +205 -0
- api_medic/core/render/json.py +14 -0
- api_medic/core/render/markdown.py +136 -0
- api_medic/core/render/terminal.py +159 -0
- api_medic/core/runner.py +248 -0
- api_medic/core/runner_safety.py +97 -0
- api_medic/web/__init__.py +1 -0
- api_medic/web/__main__.py +5 -0
- api_medic/web/app.py +87 -0
- api_medic/web/frontend/assets/index-B9VfTj3I.css +1 -0
- api_medic/web/frontend/assets/index-DdtPIxCH.js +40 -0
- api_medic/web/frontend/fixtures/01-healthy.json +49 -0
- api_medic/web/frontend/fixtures/02-jwt-expired.json +62 -0
- api_medic/web/frontend/fixtures/03-rate-limited.json +53 -0
- api_medic/web/frontend/fixtures/04-cors-misconfigured.json +63 -0
- api_medic/web/frontend/fixtures/05-tls-expiring.json +50 -0
- api_medic/web/frontend/fixtures/06-malformed-body.json +52 -0
- api_medic/web/frontend/fixtures/07-redirect-loop.json +42 -0
- api_medic/web/frontend/fixtures/08-slow-tls.json +60 -0
- api_medic/web/frontend/fixtures/index.json +1 -0
- api_medic/web/frontend/index.html +13 -0
- api_medic/web/server.py +25 -0
- api_medic-1.0.0.dist-info/METADATA +135 -0
- api_medic-1.0.0.dist-info/RECORD +43 -0
- api_medic-1.0.0.dist-info/WHEEL +4 -0
- api_medic-1.0.0.dist-info/entry_points.txt +2 -0
- api_medic-1.0.0.dist-info/licenses/LICENSE +21 -0
api_medic/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI surface (Typer). Populated in Phase 4."""
|
api_medic/cli/main.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""api-medic command-line interface.
|
|
2
|
+
|
|
3
|
+
The `api-medic` entry point lives at api_medic.cli.main:cli_entry
|
|
4
|
+
(see pyproject.toml). `api-medic <URL>` is shorthand for `api-medic run
|
|
5
|
+
<URL>` — the bare-URL form is handled by `_rewrite_bare_url` in
|
|
6
|
+
`cli_entry` before Typer parses, since putting a positional on the
|
|
7
|
+
parent group would swallow subcommand names like `serve`.
|
|
8
|
+
|
|
9
|
+
Subcommands:
|
|
10
|
+
run Execute an HTTP request and produce a Report.
|
|
11
|
+
from-curl Parse a curl command and (by default) execute it.
|
|
12
|
+
from-har Analyze a captured HAR file (no execution).
|
|
13
|
+
serve Launch the local web UI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import sys
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from importlib.metadata import PackageNotFoundError
|
|
22
|
+
from importlib.metadata import version as _pkg_version
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Annotated
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
|
|
28
|
+
from ..core.engine import analyze
|
|
29
|
+
from ..core.models import Report
|
|
30
|
+
from ..core.parser import parse_curl, parse_har
|
|
31
|
+
from ..core.render import render_html, render_json, render_markdown, render_terminal
|
|
32
|
+
from ..core.runner import run as runner_run
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = _pkg_version("api-medic")
|
|
36
|
+
except PackageNotFoundError: # pragma: no cover
|
|
37
|
+
__version__ = "0.0.0+unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
name="api-medic",
|
|
42
|
+
help="Diagnose HTTP API issues — run a request, parse a curl, analyze a HAR.",
|
|
43
|
+
add_completion=False,
|
|
44
|
+
no_args_is_help=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_SUBCOMMANDS = {"run", "from-curl", "from-har", "serve"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _rewrite_bare_url(argv: list[str]) -> list[str]:
|
|
51
|
+
"""Rewrite `<URL> [flags]` to `run <URL> [flags]` so the bare-URL form
|
|
52
|
+
works without putting a positional on the parent callback (which would
|
|
53
|
+
swallow subcommand names like `serve`).
|
|
54
|
+
"""
|
|
55
|
+
if not argv:
|
|
56
|
+
return argv
|
|
57
|
+
first = argv[0]
|
|
58
|
+
if first.startswith(("http://", "https://")) and first not in _SUBCOMMANDS:
|
|
59
|
+
return ["run", *argv]
|
|
60
|
+
return argv
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cli_entry() -> None:
|
|
64
|
+
"""Production entry point referenced by pyproject.toml's `api-medic` script."""
|
|
65
|
+
# On Windows the default console encoding is cp1252, which can't encode
|
|
66
|
+
# the arrow / em-dash glyphs the renderers use. Reconfigure to UTF-8 so
|
|
67
|
+
# output round-trips on every platform the architecture commits to
|
|
68
|
+
# (macOS, Linux, Windows).
|
|
69
|
+
for stream in (sys.stdout, sys.stderr):
|
|
70
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
71
|
+
if reconfigure is not None:
|
|
72
|
+
with contextlib.suppress(OSError, ValueError):
|
|
73
|
+
reconfigure(encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
argv = _rewrite_bare_url(sys.argv[1:])
|
|
76
|
+
app(argv)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OutputFormat(str, Enum):
|
|
80
|
+
terminal = "terminal"
|
|
81
|
+
json = "json"
|
|
82
|
+
markdown = "markdown"
|
|
83
|
+
html = "html"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _version_callback(value: bool) -> None:
|
|
87
|
+
if value:
|
|
88
|
+
typer.echo(f"api-medic {__version__}")
|
|
89
|
+
raise typer.Exit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Shared option types -------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
OutputOption = Annotated[
|
|
95
|
+
OutputFormat,
|
|
96
|
+
typer.Option("--output", "-o", help="Output format.", case_sensitive=False),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
SaveOption = Annotated[
|
|
100
|
+
Path | None,
|
|
101
|
+
typer.Option("--save", help="Write the rendered report to a file instead of stdout."),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
NoColorOption = Annotated[
|
|
105
|
+
bool, typer.Option("--no-color", help="Disable ANSI colors in terminal output.")
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
VerboseOption = Annotated[
|
|
109
|
+
bool, typer.Option("--verbose", "-v", help="Print a one-line findings summary to stderr.")
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Helpers -------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _emit(
|
|
117
|
+
report: Report,
|
|
118
|
+
*,
|
|
119
|
+
output: OutputFormat,
|
|
120
|
+
save: Path | None,
|
|
121
|
+
no_color: bool,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Render `report` and write it to stdout (or `save` if given)."""
|
|
124
|
+
if output is OutputFormat.terminal:
|
|
125
|
+
# Colour when stdout is a terminal AND user didn't suppress. When
|
|
126
|
+
# writing to a file we still emit colour — modern terminals can `cat`
|
|
127
|
+
# ANSI files happily — except when --no-color is set.
|
|
128
|
+
color = not no_color and (save is not None or sys.stdout.isatty())
|
|
129
|
+
text = render_terminal(report, color=color)
|
|
130
|
+
elif output is OutputFormat.json:
|
|
131
|
+
text = render_json(report)
|
|
132
|
+
elif output is OutputFormat.markdown:
|
|
133
|
+
text = render_markdown(report)
|
|
134
|
+
elif output is OutputFormat.html:
|
|
135
|
+
text = render_html(report)
|
|
136
|
+
else: # pragma: no cover — Enum exhausts above
|
|
137
|
+
raise typer.BadParameter(f"Unknown output format: {output}")
|
|
138
|
+
|
|
139
|
+
if save is not None:
|
|
140
|
+
save.write_text(text, encoding="utf-8")
|
|
141
|
+
typer.echo(f"Wrote {save}", err=True)
|
|
142
|
+
else:
|
|
143
|
+
typer.echo(text)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _emit_findings_summary(report: Report) -> None:
|
|
147
|
+
"""One-line stderr summary, used by --verbose."""
|
|
148
|
+
n = len(report.findings)
|
|
149
|
+
if n == 0:
|
|
150
|
+
typer.echo("No findings.", err=True)
|
|
151
|
+
return
|
|
152
|
+
critical = sum(1 for f in report.findings if f.severity == "critical")
|
|
153
|
+
warning = sum(1 for f in report.findings if f.severity == "warning")
|
|
154
|
+
info = sum(1 for f in report.findings if f.severity == "info")
|
|
155
|
+
typer.echo(
|
|
156
|
+
f"Findings: {n} total ({critical} critical, {warning} warning, {info} info)",
|
|
157
|
+
err=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_headers(items: list[str]) -> dict[str, str]:
|
|
162
|
+
"""Convert ['Name: value', ...] to {Name: value, ...}."""
|
|
163
|
+
out: dict[str, str] = {}
|
|
164
|
+
for raw in items:
|
|
165
|
+
if ":" not in raw:
|
|
166
|
+
raise typer.BadParameter(f"Header must be 'Name: value' — got {raw!r}.")
|
|
167
|
+
name, value = raw.split(":", 1)
|
|
168
|
+
out[name.strip()] = value.strip()
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Callback (top-level options only — bare-URL is handled by cli_entry) -----
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.callback()
|
|
176
|
+
def main(
|
|
177
|
+
_version: Annotated[
|
|
178
|
+
bool,
|
|
179
|
+
typer.Option(
|
|
180
|
+
"--version",
|
|
181
|
+
callback=_version_callback,
|
|
182
|
+
is_eager=True,
|
|
183
|
+
help="Show the api-medic version and exit.",
|
|
184
|
+
),
|
|
185
|
+
] = False,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""api-medic — diagnose HTTP API issues.
|
|
188
|
+
|
|
189
|
+
Pass a URL as the first argument as a shortcut for `run <URL>` —
|
|
190
|
+
e.g. `api-medic https://api.example.com/health`.
|
|
191
|
+
"""
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Subcommands --------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def run(
|
|
200
|
+
url: Annotated[str, typer.Argument(help="URL to request.")],
|
|
201
|
+
method: Annotated[str, typer.Option("--method", "-X", help="HTTP method.")] = "GET",
|
|
202
|
+
header: Annotated[
|
|
203
|
+
list[str] | None,
|
|
204
|
+
typer.Option(
|
|
205
|
+
"--header",
|
|
206
|
+
"-H",
|
|
207
|
+
help="Header in 'Name: value' form. Repeat for multiple.",
|
|
208
|
+
),
|
|
209
|
+
] = None,
|
|
210
|
+
body: Annotated[
|
|
211
|
+
str | None,
|
|
212
|
+
typer.Option("--body", "-d", help="Request body as a literal string."),
|
|
213
|
+
] = None,
|
|
214
|
+
body_file: Annotated[
|
|
215
|
+
Path | None,
|
|
216
|
+
typer.Option("--body-file", help="Read the request body from a file."),
|
|
217
|
+
] = None,
|
|
218
|
+
timeout: Annotated[
|
|
219
|
+
float, typer.Option("--timeout", help="Per-request timeout in seconds.")
|
|
220
|
+
] = 30.0,
|
|
221
|
+
output: OutputOption = OutputFormat.terminal,
|
|
222
|
+
save: SaveOption = None,
|
|
223
|
+
no_color: NoColorOption = False,
|
|
224
|
+
verbose: VerboseOption = False,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Execute an HTTP request and produce a diagnostic report."""
|
|
227
|
+
if body is not None and body_file is not None:
|
|
228
|
+
raise typer.BadParameter("--body and --body-file are mutually exclusive.")
|
|
229
|
+
|
|
230
|
+
body_data: str | bytes | None = body_file.read_bytes() if body_file is not None else body
|
|
231
|
+
|
|
232
|
+
headers = _parse_headers(header or [])
|
|
233
|
+
|
|
234
|
+
captured = runner_run(method, url, headers=headers, body=body_data, timeout=timeout)
|
|
235
|
+
report = analyze(captured)
|
|
236
|
+
if verbose:
|
|
237
|
+
_emit_findings_summary(report)
|
|
238
|
+
_emit(report, output=output, save=save, no_color=no_color)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command(name="from-curl")
|
|
242
|
+
def from_curl(
|
|
243
|
+
curl: Annotated[str, typer.Argument(help="curl command string to parse.")],
|
|
244
|
+
no_execute: Annotated[
|
|
245
|
+
bool,
|
|
246
|
+
typer.Option(
|
|
247
|
+
"--no-execute",
|
|
248
|
+
help=(
|
|
249
|
+
"Skip executing the parsed request — analyze the request shape "
|
|
250
|
+
"only. Useful when inspecting credentials you don't want to send."
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
] = False,
|
|
254
|
+
output: OutputOption = OutputFormat.terminal,
|
|
255
|
+
save: SaveOption = None,
|
|
256
|
+
no_color: NoColorOption = False,
|
|
257
|
+
verbose: VerboseOption = False,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Parse a curl command and (by default) execute it."""
|
|
260
|
+
captured = parse_curl(curl)
|
|
261
|
+
if not no_execute:
|
|
262
|
+
# Re-issue the parsed request through the live Runner so we get a real
|
|
263
|
+
# response, timing, and DNS/TLS data.
|
|
264
|
+
captured = runner_run(
|
|
265
|
+
captured.method,
|
|
266
|
+
captured.url,
|
|
267
|
+
headers=captured.headers,
|
|
268
|
+
body=captured.body if captured.body else None,
|
|
269
|
+
)
|
|
270
|
+
report = analyze(captured)
|
|
271
|
+
if verbose:
|
|
272
|
+
_emit_findings_summary(report)
|
|
273
|
+
_emit(report, output=output, save=save, no_color=no_color)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command(name="from-har")
|
|
277
|
+
def from_har(
|
|
278
|
+
file: Annotated[Path, typer.Argument(help="Path to a .har file.")],
|
|
279
|
+
output: OutputOption = OutputFormat.terminal,
|
|
280
|
+
save: SaveOption = None,
|
|
281
|
+
no_color: NoColorOption = False,
|
|
282
|
+
verbose: VerboseOption = False,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Analyze a captured HAR file (no execution)."""
|
|
285
|
+
raw = file.read_text(encoding="utf-8")
|
|
286
|
+
captured = parse_har(raw)
|
|
287
|
+
report = analyze(captured)
|
|
288
|
+
if verbose:
|
|
289
|
+
_emit_findings_summary(report)
|
|
290
|
+
_emit(report, output=output, save=save, no_color=no_color)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@app.command()
|
|
294
|
+
def serve(
|
|
295
|
+
host: Annotated[str, typer.Option("--host", help="Host to bind.")] = "127.0.0.1",
|
|
296
|
+
port: Annotated[int, typer.Option("--port", help="Port to listen on.")] = 8765,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Start the local web UI."""
|
|
299
|
+
import uvicorn
|
|
300
|
+
|
|
301
|
+
typer.echo(f"api-medic web UI on http://{host}:{port} (Ctrl+C to stop)", err=True)
|
|
302
|
+
uvicorn.run("api_medic.web.app:app", host=host, port=port, reload=False)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__": # pragma: no cover
|
|
306
|
+
cli_entry()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Core engine: pure-Python diagnostic library with no web/CLI/AWS dependencies."""
|
|
2
|
+
|
|
3
|
+
from api_medic.core.models import (
|
|
4
|
+
Finding,
|
|
5
|
+
Report,
|
|
6
|
+
RequestSummary,
|
|
7
|
+
ResponseSummary,
|
|
8
|
+
Severity,
|
|
9
|
+
Source,
|
|
10
|
+
TimingBreakdown,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Finding",
|
|
15
|
+
"Report",
|
|
16
|
+
"RequestSummary",
|
|
17
|
+
"ResponseSummary",
|
|
18
|
+
"Severity",
|
|
19
|
+
"Source",
|
|
20
|
+
"TimingBreakdown",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = "1.0"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Internal types used by the engine before it produces a Report.
|
|
2
|
+
|
|
3
|
+
CapturedRequest is what Runner and Parser both produce. It carries full
|
|
4
|
+
request and response bodies and any extra metadata the checks need, before
|
|
5
|
+
the engine summarises it down into a public-shape Report (with truncated
|
|
6
|
+
body previews) for downstream consumers.
|
|
7
|
+
|
|
8
|
+
These types are deliberately separate from `models.py` — they're an internal
|
|
9
|
+
contract, not the public schema, and don't need TypeScript codegen.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
from .models import Source, TimingBreakdown
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CapturedDns(BaseModel):
|
|
22
|
+
"""DNS lookup result for the request's host. None when DNS wasn't probed
|
|
23
|
+
(HAR/curl sources, or live runs with probing disabled)."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
records: list[str] = Field(
|
|
28
|
+
default_factory=list, description="Resolved A/AAAA records (IPv4 + IPv6)."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CapturedTls(BaseModel):
|
|
33
|
+
"""TLS handshake details — populated only for HTTPS live runs. None for
|
|
34
|
+
HAR, curl, and HTTP-scheme requests."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
not_before: datetime
|
|
39
|
+
not_after: datetime
|
|
40
|
+
subject_common_name: str | None = None
|
|
41
|
+
subject_alt_names: list[str] = Field(default_factory=list)
|
|
42
|
+
issuer_common_name: str | None = None
|
|
43
|
+
negotiated_protocol_version: str = Field(default="", description="e.g. 'TLSv1.2', 'TLSv1.3'.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CapturedResponse(BaseModel):
|
|
47
|
+
"""Full response captured by the Runner, or extracted from a HAR/raw
|
|
48
|
+
HTTP source by the Parser. Body is the raw bytes; renderers truncate
|
|
49
|
+
for display.
|
|
50
|
+
|
|
51
|
+
`raw_headers` is the original (name, value) sequence preserving
|
|
52
|
+
duplicates and case. None when the source didn't preserve them
|
|
53
|
+
(Phase 3a Runner output, for instance — populated in 3b-F).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
57
|
+
|
|
58
|
+
status_code: int = Field(..., ge=100, le=599)
|
|
59
|
+
status_text: str = ""
|
|
60
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
61
|
+
raw_headers: list[tuple[str, str]] | None = None
|
|
62
|
+
body: bytes = b""
|
|
63
|
+
protocol: str = "HTTP/1.1"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CapturedRequest(BaseModel):
|
|
67
|
+
"""Full captured request + optional response, used by every check.
|
|
68
|
+
|
|
69
|
+
`response` is None for inputs that don't include one (e.g. a curl
|
|
70
|
+
command we haven't executed yet). Checks that need a response should
|
|
71
|
+
no-op when it's missing.
|
|
72
|
+
|
|
73
|
+
`raw_headers` mirrors CapturedResponse.raw_headers — original
|
|
74
|
+
(name, value) sequence with duplicates preserved.
|
|
75
|
+
|
|
76
|
+
`redirect_chain` is the list of URLs visited starting from the
|
|
77
|
+
user-supplied URL and ending at the final one. None when not captured
|
|
78
|
+
(HAR/curl don't carry it; Runner populates in 3b-F).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
82
|
+
|
|
83
|
+
method: str
|
|
84
|
+
url: str
|
|
85
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
86
|
+
raw_headers: list[tuple[str, str]] | None = None
|
|
87
|
+
body: bytes = b""
|
|
88
|
+
|
|
89
|
+
response: CapturedResponse | None = None
|
|
90
|
+
timing: TimingBreakdown = Field(default_factory=TimingBreakdown)
|
|
91
|
+
redirect_chain: list[str] | None = None
|
|
92
|
+
dns: CapturedDns | None = None
|
|
93
|
+
tls: CapturedTls | None = None
|
|
94
|
+
source: Source
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Check registry.
|
|
2
|
+
|
|
3
|
+
A check is a function `(CapturedRequest) -> Finding | list[Finding] | None`.
|
|
4
|
+
Returning `None` means the check didn't fire — return only when something
|
|
5
|
+
is actually wrong (or worth reporting as info).
|
|
6
|
+
|
|
7
|
+
Each check module under `core.checks` registers its functions on import. The
|
|
8
|
+
engine glue (core.engine) imports those modules so registration happens
|
|
9
|
+
before `run_all_checks` is called.
|
|
10
|
+
|
|
11
|
+
Tests that want isolation can instantiate their own `CheckRegistry` rather
|
|
12
|
+
than touching `default_registry`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from ..captured import CapturedRequest
|
|
20
|
+
from ..models import Finding
|
|
21
|
+
|
|
22
|
+
CheckResult = Finding | list[Finding] | None
|
|
23
|
+
CheckFn = Callable[[CapturedRequest], CheckResult]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CheckRegistry:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._checks: list[CheckFn] = []
|
|
29
|
+
|
|
30
|
+
def register(self, fn: CheckFn) -> CheckFn:
|
|
31
|
+
self._checks.append(fn)
|
|
32
|
+
return fn
|
|
33
|
+
|
|
34
|
+
def all(self) -> list[CheckFn]:
|
|
35
|
+
return list(self._checks)
|
|
36
|
+
|
|
37
|
+
def run(self, captured: CapturedRequest) -> list[Finding]:
|
|
38
|
+
out: list[Finding] = []
|
|
39
|
+
for fn in self._checks:
|
|
40
|
+
result = fn(captured)
|
|
41
|
+
if result is None:
|
|
42
|
+
continue
|
|
43
|
+
if isinstance(result, list):
|
|
44
|
+
out.extend(result)
|
|
45
|
+
else:
|
|
46
|
+
out.append(result)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
default_registry = CheckRegistry()
|
|
51
|
+
register = default_registry.register
|
|
52
|
+
run_all_checks = default_registry.run
|